1. 程式人生 > >基於UNIX的網路程式設計(理論篇:設計迭代伺服器)

基於UNIX的網路程式設計(理論篇:設計迭代伺服器)

這裡寫圖片描述
注意:read的返回值為0說明讀的位元組數,如果為EOF則為0.

之前騰訊一面的時候,面試官看見了我的最近一篇部落格《TCP和UDP詳解》感覺我的理論還行。但是,問我有沒實際做過TCP/UDP的專案。結果我就只能說我在大一做過的一個沒有搭在伺服器上面的下載器,感覺有點丟人。於是,自己馬上下來做了準備一個基於UNIX的網路程式設計的小專案。準備分為兩個理論篇和一個實踐篇。
現在馬上進入我們的第一個理論篇:設計迭代伺服器
在這篇部落格中,你將看到以下內容:
這裡寫圖片描述

好的,好戲開始:

UNIX I/O

一.UNIX的I/O系統是怎麼回事?

計算機專業的都知道,我們的計算機都是基於馮諾伊曼的架構(當然還是哈佛架構匯流排分開)。那麼,對於馮諾伊曼架構的計算機I/O就成了很重要的一環。在我們的UNIX系統的I/O是怎麼樣的呢?對於I/O(輸入、輸出),UNIX系統做了一個非常優美的抽象,UNIX系統將所有的I/O裝置,正如網路、磁碟和終端,都抽象為了我們的檔案。包括我們寫程式中用到的prinf,scanf,sprintf,sscanf都是我們的檔案。
針對於每一個程式,核心都會自動分配出三個檔案:stdin,stdout,stderr(標準輸入、標準輸出、標準錯誤流),檔案描述符分別為0,1,2.
這裡提一下,UNIX是怎麼進行檔案區分的,下面直接來放一張UNIX系統的檔案頭結構體:
這裡寫圖片描述


看起來有點多,我們挑我們常用的幾個來說
1.mode_t st_mode. 說明檔案的具體型別,是可讀的,還是可寫的,還是可執行的。
我們可以利用我們的巨集指令來看檔案的型別

巨集指令 描述
S_ISREG() 這是一個普通檔案嗎?
S_ISDIR() 這是一個目錄檔案嗎?
S_ISSOCK() 這是一個網路套接字嗎?

其他的相關資訊:st_atime、st_mtime、st_ctime可以用來做檔案的mac或者hash來進行傳輸,來保證檔案在傳送的時候沒有被修改,這又涉及到安全方面的知識了,這又是另一個故事了,對資訊保安感興趣的可以看我的這篇博文:

http://blog.csdn.net/github_33873969/article/details/79008664
這裡還是上一段程式碼如何查詢和處理一個檔案的st_mode位吧

#include<unistd.h>
#include<sys/stat.h>

int main(int argc, char **argv)
{
    struct stat stat;
    char *type, *readok;

    stat(argv[1],&stat);
    /* Determine file type */
    if(S_ISREG(stat.st_mode))
        type
=
"regular"; else if(S_ISDIR(stat.st_mode)) type="directory"; else type="other"; /* Check read access */ if((stat.st_mode & S_IRUSR)) readok="yes"; else readok="no"; printf("type: %s, read: %s\n",type,readok); exit(0); }

接下來,我還是想說一下針對每一個程序而言,檔案是如何管理的,對映到作業系統核心檔案又是怎樣的?
還是先上圖:
這裡寫圖片描述
從頭開始:
1.對於每一個程序而言,都會獨立維護一個描述符表,這個描述符表就是我剛剛所說的對於程序而言,區分不同的檔案而準備的(比如:stdin、stdout、stderr)
這裡說明一個開啟對應檔案的函式吧。

#include <sys/types.h>
#inlcude <sys/stat.h>
#include <fcntl.h>
int open(char *filename,int flags,mode_t mode);   //返回:若成功則為新檔案描述符,若出錯為-1.

flags說明我們的程序將以什麼樣的方式訪問這個檔案,O_RDONLY:只讀、O_WRONLY:只寫、O_RDWR:可讀可寫。
接下來說明一下mode,這個引數,這個引數指定了我們對檔案的訪問許可權,我們可以通過呼叫umask函式來設定。
還是直接上表吧,這個表中說明了各種許可權。

巨集指令 描述
S_IRUSR 使用者(擁有者)能夠讀這個檔案
S_IWUSR 使用者(擁有者)能夠寫這個檔案
S_IXUSR 使用者(擁有者)能夠執行這個檔案
S_IRGRP 擁有者所在組的成員能夠讀這個檔案
S_IWGRP 擁有者所在組的成員能夠寫這個檔案
S_IXGRP 擁有者所在組的成員能夠執行這個檔案
S_IROTH 其他人(任何人)能夠讀這個檔案
S_IWOTH 其他人(任何人)能夠寫這個檔案
S_IXOTH 其他人(任何人)能夠執行這個檔案

注意哦,這些檔案許可權,關係到我們的網路程式設計中的動態檔案(可執行的檔案)還是靜態檔案(html、jpg)圖片的傳送有關哦。
2.開啟檔案表
開啟檔案表位於核心中(所有程序共享),儲存著所有的開啟檔案的資訊。儲存著檔案位置和引用計數,關於這個引用計數可是又一定的技術故事的啦,引用計數技術在這裡簡單來說,就是有哪些程序打開了這個檔案,引用了這個問題,一旦程序銷燬後,引用計數就會減一。直到引用計數為0,則該檔案被銷燬。
Reference:https://www.zhihu.com/question/21539353
這種引用計數的方法也被引用在java的垃圾回收以及C++物件中。關於java的垃圾回收,我這裡還是簡單的聊一下吧:
2.1.引用計數。大家都知道java是一門面向物件的語言,針對於每一個物件都有的一個引用計數,誰引用了這個物件,這個物件的引用計數+1。引用結束就-1。直到對應的引用計數為0,說明該物件被銷燬,這樣的垃圾記憶體需要被回收。但是這樣的回收方式,是基於區域性的引用的情況,是區域性最優解,可能產生無法回收的情況,如下面的例子:由於不方便轉載,這個例子還是去這個網站上看比較好:https://www.zhihu.com/question/21539353
2.2.不可達圖回收:如果把每一個物件看到一個結點,相互引用的關係看作是結點與結點相連關係,就可以構成一張圖,那麼那些圖中達不到(不可達)的結點就是垃圾結點(垃圾物件),需要進行回收。所以,我們只要從GC開始對所有結點進行bfs,達不到的結點就是垃圾結點。
題外話說多了,回到我們的正題:
3.v-node表
這個才是我們在硬盤裡面的真正檔案,所有程序共享。所以會出現多個檔案表指向同一個v-node(利用I/O重定向dup2來實現檔案描述符指向同一個,這個之後會馬上 講),如果是父子進行(fork)出來的會共享同一個檔案表,如下圖所示:
這裡寫圖片描述

二.如何編寫健壯的I/O函式

這裡舉一個非常形象的例子來說明我們的普通的I/O函式,read,write有什麼缺陷。假設我們要拷貝1個G的檔案,我們如果用read和write一個個位元組的拷貝,我們將會重複的陷入(trap into)系統呼叫,這會大量消耗我們無用的效能。我們基於這樣的缺陷,設計了一個帶有緩衝區的Rio_read和Rio_write,它的基本設計思路是呼叫rio_read要求讀n個位元組時,讀緩衝區內有rp->rio_cnt個未讀位元組。如果緩衝區為空,那麼會通過呼叫read再填滿它。(原則其實就是多讀資料進來)。
寫也是同理。

這裡還是直接上程式碼吧:

/*
 * rio_writen - robustly write n bytes (unbuffered)
 */
/* $begin rio_writen */
ssize_t rio_writen(int fd, void *usrbuf, size_t n) 
{
    size_t nleft = n;
    ssize_t nwritten;
    char *bufp = usrbuf;

    while (nleft > 0) {
    if ((nwritten = write(fd, bufp, nleft)) <= 0) {
        if (errno == EINTR)  /* Interrupted by sig handler return */
        nwritten = 0;    /* and call write() again */
        else
        return -1;       /* errno set by write() */
    }
    nleft -= nwritten;
    bufp += nwritten;
    }
    return n;
}
/* $end rio_writen */


/* 
 * rio_read - This is a wrapper for the Unix read() function that
 *    transfers min(n, rio_cnt) bytes from an internal buffer to a user
 *    buffer, where n is the number of bytes requested by the user and
 *    rio_cnt is the number of unread bytes in the internal buffer. On
 *    entry, rio_read() refills the internal buffer via a call to
 *    read() if the internal buffer is empty.
 */
/* $begin rio_read */
static ssize_t rio_read(rio_t *rp, char *usrbuf, size_t n)
{
    int cnt;

    while (rp->rio_cnt <= 0) {  /* Refill if buf is empty */
    rp->rio_cnt = read(rp->rio_fd, rp->rio_buf, 
               sizeof(rp->rio_buf));
    if (rp->rio_cnt < 0) {
        if (errno != EINTR) /* Interrupted by sig handler return */
        return -1;
    }
    else if (rp->rio_cnt == 0)  /* EOF */
        return 0;
    else 
        rp->rio_bufptr = rp->rio_buf; /* Reset buffer ptr */
    }

    /* Copy min(n, rp->rio_cnt) bytes from internal buf to user buf */
    cnt = n;          
    if (rp->rio_cnt < n)   
    cnt = rp->rio_cnt;
    memcpy(usrbuf, rp->rio_bufptr, cnt);
    rp->rio_bufptr += cnt;
    rp->rio_cnt -= cnt;
    return cnt;
}
/* $end rio_read */

/*
 * rio_readinitb - Associate a descriptor with a read buffer and reset buffer
 */
/* $begin rio_readinitb */
void rio_readinitb(rio_t *rp, int fd) 
{
    rp->rio_fd = fd;  
    rp->rio_cnt = 0;  
    rp->rio_bufptr = rp->rio_buf;
}
/* $end rio_readinitb */

/*
 * rio_readnb - Robustly read n bytes (buffered)
 */
/* $begin rio_readnb */
ssize_t rio_readnb(rio_t *rp, void *usrbuf, size_t n) 
{
    size_t nleft = n;
    ssize_t nread;
    char *bufp = usrbuf;

    while (nleft > 0) {
    if ((nread = rio_read(rp, bufp, nleft)) < 0) 
            return -1;          /* errno set by read() */ 
    else if (nread == 0)
        break;              /* EOF */
    nleft -= nread;
    bufp += nread;
    }
    return (n - nleft);         /* return >= 0 */
}
/* $end rio_readnb */

/* 
 * rio_readlineb - robustly read a text line (buffered)
 */
/* $begin rio_readlineb */
ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen) 
{
    int n, rc;
    char c, *bufp = usrbuf;

    for (n = 1; n < maxlen; n++) { 
        if ((rc = rio_read(rp, &c, 1)) == 1) {
        *bufp++ = c;
        if (c == '\n') {
                n++;
            break;
            }
    } else if (rc == 0) {
        if (n == 1)
        return 0; /* EOF, no data read */
        else
        break;    /* EOF, some data was read */
    } else
        return -1;    /* Error */
    }
    *bufp = 0;
    return n-1;
}

三.I/O重定向

這個就更簡單了,有了我們上面懂得UNIX系統中對檔案中的抽象。我們利用下面的函式

#include <unitstd.h>
int dup2(int oldfd, int newfd);   //返回:若成功則為非負的描述符,若出錯則為-1.

假設我們呼叫dup2(3,1),即使讓3也指向和檔案表1中的同一個v-node,一是標準輸出,那麼3的輸出會直接被定向到標準輸出。同理,重定向到網路套接字描述符,重定向到標準輸入,那麼檔案3的輸出就變成了網路套接字的輸入或者標準輸入,這就是重定向I/O以及“一切都是檔案”的魅力,剛剛的過程我用下面的圖表示出來:
這裡寫圖片描述

網路程式設計

這部分我將盡量簡化網路例如TCP/IP協議以及HTTP的理論部分,著眼於程式碼以及相關函式部分

一.客戶端與服務端的基本模式

這裡寫圖片描述
這裡我將說明一個最簡單的模型,並且我們的第一個伺服器也是基於這個模式。
1.客戶端傳送情況。2.伺服器去請求資源。((1).可能是磁碟中的靜態資源,html/css/jpg檔案。(2)也可能是動態檔案(需要fork出一個程序去execve該程式)).3.之後就將伺服器的相關資源傳送。4.客戶端進行響應。中間的TCP的可靠傳輸的實現,HTTPS和SSL的加密等內容,可以看我的其他部落格:http://blog.csdn.net/github_33873969/article/details/79422188
這裡就不細說了。

二.UNIX有哪些函式與我們的客戶端與服務端相關

說了這麼多,終於開始調函數了。受首先是我們的客戶端,可以想象我們傳送一個訊息,需要一個對應伺服器主機的IP地址,瞭解到需要傳送服從的協議。程式碼如下:

#include <sys/types.h>
#include <sys/socket.h>

//domain表示使用的網路(常用的是AF_INET),type常為SOCK_STREAM表示因特網連線的一個結點,
int socket(int domain, int type, int protocol);

現在只是連線起一個套接字,但是,還不能用於讀寫。

#include <sys/types.h>
#include <sys/socket.h>
int connect(int socketfd,struct sockaddr *serv_addr,int addslen)

emmmm,這個引數的含義我覺得直接看引數名都能看懂,值得注意的是,當connect在執行過程中,函式處於阻塞狀態,意思就是說其他套接字不能進行連線狀態,直到連線上或者返回報錯資訊,IP在sockaddr這裡結構體中。
我們直接將socket函式和connect函式封裝成一個函式。
如下所示:

/* $begin open_clientfd */
int open_clientfd(char *hostname, int port) 
{
    int clientfd;
    struct hostent *hp;
    struct sockaddr_in serveraddr;

    if ((clientfd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
    return -1; /* Check errno for cause of error */

    /* Fill in the server's IP address and port */
    if ((hp = gethostbyname(hostname)) == NULL)
    return -2; /* Check h_errno for cause of error */
    bzero((char *) &serveraddr, sizeof(serveraddr));
    serveraddr.sin_family = AF_INET;
    bcopy((char *)hp->h_addr_list[0], 
      (char *)&serveraddr.sin_addr.s_addr, hp->h_length);
    serveraddr.sin_port = htons(port);

    /* Establish a connection with the server */
    if (connect(clientfd, (SA *) &serveraddr, sizeof(serveraddr)) < 0)
    return -1;
    return clientfd;
}

對於伺服器

1.第一個仍然是我們的socket函式,這個不多說了,建立一個套接字描述符。
2.bind函式。

#include <sys/socket.h>
int bind(int socketfd,struct sockaddr *my_addr,int addrlen);

即讓作業系統核心將對應的socketfd繫結到我們的my_addr結構題中。
3.listen函式

#include <sys/socket.h>
int listen(int sockfd,int backlog);

這個函式是想主動的套接字轉化為監聽套接字(伺服器專用),這個函式可是有的說呀,這個函式足以和我們的TCP握手協議連線起來,還是先圖:
Reference:https://www.cnblogs.com/chris-cp/p/4022262.html
這裡寫圖片描述
我們都知道TCP有著三次握手和四次揮手協議。但這些協議老實說都在作業系統底層實現了。但是,其中的函式還是會影響到我們的listen函式,我們的listen函式維護著兩個佇列:
3.1.已完成連線佇列,負責排隊已經完成三次握手的客戶端socket。即是已經是ESTABLISHED狀態的連線。
3.2.未完成連線佇列:這個更好理解了,就是還處於三次握手的連線(連線處於SYN_RCVD狀態)。
這裡要注意,listen函式負責三次握手的連線,但是accept函式卻不負責三次握手的連線。這個還要注意一點,accept函式只首發處於已連線狀態的套接字,所以當以完成連線佇列為空的時候,accept會處於阻塞狀態。知道已完成連線佇列中有已經完成握手的套接字存在。
然後已完成連線佇列+未完成連線佇列=我們的引數backlog。
4.accept函式

#include <sys/socket.h>
int accept(int listenfd,struct sockaddr *addr,int *addrlen)

這時候,我們輸入的listenfd監聽描述符就返回出了連線描述符,記住listenfd監聽描述符針對的一個伺服器,而通過accept建立的連線描述符是針對伺服器針對一個客戶端的連線。具體的連線過程如下圖所示:
這裡寫圖片描述
圖已經很清楚噠。
同時我們也將socket和listen函式封裝成一個函式

/* $begin open_listenfd */
int open_listenfd(int port) 
{
    int listenfd, optval=1;
    struct sockaddr_in serveraddr;

    /* Create a socket descriptor */
    if ((listenfd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
    return -1;

    /* Eliminates "Address already in use" error from bind */
    if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, 
           (const void *)&optval , sizeof(int)) < 0)
    return -1;

    /* Listenfd will be an endpoint for all requests to port
       on any IP address for this host */
    bzero((char *) &serveraddr, sizeof(serveraddr));
    serveraddr.sin_family = AF_INET; 
    serveraddr.sin_addr.s_addr = htonl(INADDR_ANY); 
    serveraddr.sin_port = htons((unsigned short)port); 
    if (bind(listenfd, (SA *)&serveraddr, sizeof(serveraddr)) < 0)
    return -1;

    /* Make it a listening socket ready to accept connection requests */
    if (listen(listenfd, LISTENQ) < 0)
    return -1;
    return listenfd;
}

設計我們的第一個迭代伺服器