Linux系統編程---socket編程(全+精)

1. 預備知識
1.1. 網絡字節序
    我們已經知道,內存中的多字節數據相對於內存地址有大端和小端之分,磁盤文件中的多字節數據相對於文件中的偏移地址也有大端小端之分。網絡數據流同樣有大端小端之分,那麼如何定義網絡數據流的地址呢?發送主機通常將發送緩衝區中的數據按內存地址從低到高的順序發出,接收主機把從網絡上接到的字節依次保存在接收緩衝區中,也是按內存地址從低到高的順序保存,因此,網絡數據流的地址應這樣規定:先發出的數據是低地址,後發出的數據是高地址。
    TCP/IP協議規定,網絡數據流應採用大端字節序,即低地址高字節。例如上一節的UDP段格式,地址0-1是16位的源端口號,如果這個端口號是1000(0x3e8),則地址0是0x03,地址1是0xe8,也就是先發0x03,再發0xe8,這16位在發送主機的緩衝區中也應該是低地址存0x03,高地址存0xe8。但是,如果發送主機是小端字節序的,這16位被解釋成0xe803,而不是1000。因此,發送主機把1000填到發送緩衝區之前需要做字節序的轉換。同樣地,接收主機如果是小端字節序的,接到16位的源端口號也要做字節序的轉換。如果主機是大端字節序的,發送和接收都不需要做轉換。同理,32位的IP地址也要考慮網絡字節序和主機字節序的問題。
    爲使網絡程序具有可移植性,使同樣的C代碼在大端和小端計算機上編譯後都能正常運行,可以調用以下庫函數做網絡字節序和主機字節序的轉換。
#include <arpa/inet.h>
 
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
    這些函數名很好記,h表示host,n表示network,l表示32位長整數,s表示16位短整數。例如htonl表示將32位的長整數從主機字節序轉換爲網絡字節序,例如將IP地址轉換後準備發送。如果主機是小端字節序,這些函數將參數做相應的大小端轉換然後返回,如果主機是大端字節序,這些函數不做轉換,將參數原封不動地返回。
1.2. socket地址的數據類型及相關函數
    socket API是一層抽象的網絡編程接口,適用於各種底層網絡協議,如IPv4、IPv6,以及後面要講的UNIX Domain Socket。然而,各種網絡協議的地址格式並不相同,如下圖所示:
    圖 37.1. sockaddr數據結構

sockaddr數據結構

    IPv4和IPv6的地址格式定義在netinet/in.h中,IPv4地址用sockaddr_in結構體表示,包括16位端口號和32位IP地址,IPv6地址用sockaddr_in6結構體表示,包括16位端口號、128位IP地址和一些控制字段。UNIX Domain Socket的地址格式定義在sys/un.h中,用sockaddr_un結構體表示。各種socket地址結構體的開頭都是相同的,前16位表示整個結構體的長度(並不是所有UNIX的實現都有長度字段,如Linux就沒有),後16位表示地址類型。IPv4、IPv6和UNIX Domain Socket的地址類型分別定義爲常數AF_INET、AF_INET6、AF_UNIX。這樣,只要取得某種sockaddr結構體的首地址,不需要知道具體是哪種類型的sockaddr結構體,就可以根據地址類型字段確定結構體中的內容。因此,socket API可以接受各種類型的sockaddr結構體指針做參數,例如bind、accept、connect等函數,這些函數的參數應該設計成void *類型以便接受各種類型的指針,但是sock API的實現早於ANSI C標準化,那時還沒有void *類型,因此這些函數的參數都用struct sockaddr *類型表示,在傳遞參數之前要強制類型轉換一下,例如:
struct sockaddr_in servaddr;
/* initialize servaddr */
bind(listen_fd, (struct sockaddr *)&servaddr, sizeof(servaddr));
    本節只介紹基於IPv4的socket網絡編程,sockaddr_in中的成員struct in_addr sin_addr表示32位的IP地址。但是我們通常用點分十進制的字符串表示IP地址,以下函數可以在字符串表示和in_addr表示之間轉換。
    #include <arpa/inet.h>
 
 字符串轉in_addr的函數:
int inet_aton(const char *strptr, struct in_addr *addrptr);
in_addr_t inet_addr(const char *strptr);
int inet_pton(int family, const char *strptr, void *addrptr);
 
  in_addr轉字符串的函數:
char *inet_ntoa(struct in_addr inaddr);
const char *inet_ntop(int family, const void *addrptr, char *strptr, size_t len);
    其中inet_pton和inet_ntop不僅可以轉換IPv4的in_addr,還可以轉換IPv6的in6_addr,因此函數接口void* addrptr。
2. 基於TCP協議的網絡程序
    下圖是基於TCP協議的客戶端/服務器程序的一般流程:
    圖 37.2. TCP協議通訊流程

TCP協議通訊流程

    服務器調用socket()、bind()、listen()完成初始化後,調用accept()阻塞等待,處於監聽端口的狀態,客戶端調用socket()初始化後,調用connect()發出SYN段並阻塞等待服務器應答,服務器應答一個SYN-ACK段,客戶端收到後從connect()返回,同時應答一個ACK段,服務器收到後從accept()返回。
    數據傳輸的過程:
    建立連接後,TCP協議提供全雙工的通信服務,但是一般的客戶端/服務器程序的流程是由客戶端主動發起請求,服務器被動處理請求,一問一答的方式。因此,服務器從accept()返回後立刻調用read(),讀socket就像讀管道一樣,如果沒有數據到達就阻塞(fym注:交互程序一定要有阻塞與非阻塞的概念)等待,這時客戶端調用write()發送請求給服務器,服務器收到後從read()返回,對客戶端的請求進行處理,在此期間客戶端調用read()阻塞等待服務器的應答,服務器調用write()將處理結果發回給客戶端,再次調用read()阻塞等待下一條請求,客戶端收到後從read()返回,發送下一條請求,如此循環下去。
    如果客戶端沒有更多的請求了,就調用close()關閉連接,就像寫端關閉的管道一樣,服務器的read()返回0,這樣服務器就知道客戶端關閉了連接,也調用close()關閉連接。注意,任何一方調用close()後,連接的兩個傳輸方向都關閉,不能再發送數據了。如果一方調用shutdown()則連接處於半關閉狀態,仍可接收對方發來的數據。
    在學習socket API時要注意應用程序和TCP協議層是如何交互的: *應用程序調用某個socket函數時TCP協議層完成什麼動作,比如調用connect()會發出SYN段 *應用程序如何知道TCP協議層的狀態變化,比如從某個阻塞的socket函數返回就表明TCP協議收到了某些段,再比如read()返回0就表明收到了FIN段
2.1. 最簡單的TCP網絡程序
    下面通過最簡單的客戶端/服務器程序的實例來學習socket API。
    server.c的作用是從客戶端讀字符,然後將每個字符轉換爲大寫並回送給客戶端。
/* server.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
 
#define MAXLINE 80
#define SERV_PORT 8000
 
int main(void)
{
        struct sockaddr_in servaddr, cliaddr;
        socklen_t cliaddr_len;
        int listenfd, connfd;
        char buf[MAXLINE];
        char str[INET_ADDRSTRLEN];
        int i, n;
 
        listenfd = socket(AF_INET, SOCK_STREAM, 0);
 
        bzero(&servaddr, sizeof(servaddr));
        servaddr.sin_family = AF_INET;
        servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
        servaddr.sin_port = htons(SERV_PORT);
   
        bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
 
        listen(listenfd, 20);
 
        printf("Accepting connections .../n");
        while (1) {
               cliaddr_len = sizeof(cliaddr);
               connfd = accept(listenfd,
                               (struct sockaddr *)&cliaddr, &cliaddr_len);
         
               n = read(connfd, buf, MAXLINE);
               printf("received from %s at PORT %d/n",
                      inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)),
                      ntohs(cliaddr.sin_port));
   
               for (i = 0; i < n; i++)
                       buf[i] = toupper(buf[i]);
               write(connfd, buf, n);
               close(connfd);
        }
}
    下面介紹程序中用到的socket API,這些函數都在sys/socket.h中。
           int socket(int family, int type, int protocol);
    socket()打開一個網絡通訊端口,如果成功的話,就像open()一樣返回一個文件描述符,應用程序可以像讀寫文件一樣用read/write在網絡上收發數據,如果socket()調用出錯則返回-1。
     對於IPv4,family參數指定爲AF_INET。對於TCP協議,type參數指定爲SOCK_STREAM,表示面向流的傳輸協議。如果是UDP協議,則type參數指定爲SOCK_DGRAM,表示面向數據報的傳輸協議。protocol參數的介紹從略,指定爲0即可。
           int bind(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen);
    服務器程序所監聽的網絡地址和端口號通常是固定不變的,客戶端程序得知服務器程序的地址和端口號後就可以向服務器發起連接,因此服務器需要調用bind綁定一個固定的網絡地址和端口號。bind()成功返回0,失敗返回-1。
    bind()的作用是將參數sockfd和myaddr綁定在一起,使sockfd這個用於網絡通訊的文件描述符監聽myaddr所描述的地址和端口號。前面講過,struct sockaddr *是一個通用指針類型,myaddr參數實際上可以接受多種協議的sockaddr結構體,而它們的長度各不相同,所以需要第三個參數addrlen指定結構體的長度。我們的程序中對myaddr參數是這樣初始化的:
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);
    首先將整個結構體清零,然後設置地址類型爲AF_INET,網絡地址爲INADDR_ANY,這個宏表示本地的任意IP地址,因爲服務器可能有多個網卡,每個網卡也可能綁定多個IP地址,這樣設置可以在所有的IP地址上監聽,直到與某個客戶端建立了連接時才確定下來到底用哪個IP地址,端口號爲SERV_PORT,我們定義爲8000。
           int listen(int sockfd, int backlog);
    典型的服務器程序可以同時服務於多個客戶端,當有客戶端發起連接時,服務器調用的accept()返回並接受這個連接,如果有大量的客戶端發起連接而服務器來不及處理,尚未accept的客戶端就處於連接等待狀態,listen()聲明sockfd處於監聽狀態,並且最多允許有backlog個客戶端處於連接待狀態,如果接收到更多的連接請求就忽略。listen()成功返回0,失敗返回-1。
int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);
    三方握手完成後,服務器調用accept()接受連接,如果服務器調用accept()時還沒有客戶端的連接請求,就阻塞等待直到有客戶端連接上來。cliaddr是一個傳出參數,accept()返回時傳出客戶端的地址和端口號。addrlen參數是一個傳入傳出參數(value-result argument),傳入的是調用者提供的緩衝區cliaddr的長度以避免緩衝區溢出問題,傳出的是客戶端地址結構體的實際長度(有可能沒有佔滿調用者提供的緩衝區)。如果給cliaddr參數傳NULL,表示不關心客戶端的地址。
    我們的服務器程序結構是這樣的:
while (1) {
        cliaddr_len = sizeof(cliaddr);
        connfd = accept(listenfd,
                       (struct sockaddr *)&cliaddr, &cliaddr_len);
        n = read(connfd, buf, MAXLINE);
        ...
        close(connfd);
}
    整個是一個while死循環,每次循環處理一個客戶端連接。由於cliaddr_len是傳入傳出參數,每次調用accept()之前應該重新賦初值。accept()的參數listenfd是先前的監聽文件描述符,而accept()的返回值是另外一個文件描述符connfd,之後與客戶端之間就通過這個connfd通訊,最後關閉connfd斷開連接,而不關閉listenfd,再次回到循環開頭listenfd仍然用作accept的參數。accept()成功返回一個文件描述符,出錯返回-1。
    client.c的作用是從命令行參數中獲得一個字符串發給服務器,然後接收服務器返回的字符串並打印。
/* client.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
 
#define MAXLINE 80
#define SERV_PORT 8000
 
int main(int argc, char *argv[])
{
        struct sockaddr_in servaddr;
        char buf[MAXLINE];
        int sockfd, n;
        char *str;
   
        if (argc != 2) {
               fputs("usage: ./client message/n", stderr);
               exit(1);
        }
        str = argv[1];
   
        sockfd = socket(AF_INET, SOCK_STREAM, 0);
 
        bzero(&servaddr, sizeof(servaddr));
        servaddr.sin_family = AF_INET;
        inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr);
        servaddr.sin_port = htons(SERV_PORT);
   
        connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
 
        write(sockfd, str, strlen(str));
 
        n = read(sockfd, buf, MAXLINE);
        printf("Response from server:/n");
        write(STDOUT_FILENO, buf, n);
 
        close(sockfd);
        return 0;
}
    由於客戶端不需要固定的端口號,因此不必調用bind(),客戶端的端口號由內核自動分配。注意,客戶端不是不允許調用bind(),只是沒有必要調用bind()固定一個端口號,服務器也不是必須調用bind(),但如果服務器不調用bind(),內核會自動給服務器分配監聽端口,每次啓動服務器時端口號都不一樣,客戶端要連接服務器就會遇到麻煩。
int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen);
    客戶端需要調用connect()連接服務器,connect和bind的參數形式一致,區別在於bind的參數是自己的地址,而connect的參數是對方的地址。connect()成功返回0,出錯返回-1。
    先編譯運行服務器:
$ ./server
 Accepting connections ...
    然後在另一個終端裏用netstat命令查看:
$ netstat -apn|grep 8000
 tcp        0      0 0.0.0.0:8000            0.0.0.0:*           
   LISTEN     8148/server
    可以看到server程序監聽8000端口,IP地址還沒確定下來。現在編譯運行客戶端:
$ ./client abcd
Response from server:
ABCD
    回到server所在的終端,看看server的輸出:
$ ./server
 Accepting connections ...
 received from 127.0.0.1 at PORT 59757
    可見客戶端的端口號是自動分配的。現在把客戶端所連接的服務器IP改爲其它主機的IP,試試兩臺主機的通訊。
    再做一個小實驗,在客戶端的connect()代碼之後插一個while(1);死循環,使客戶端和服務器都處於連接中的狀態,用netstat命令查看:
$ ./server &
[1] 8343
$ Accepting connections ...
./client abcd &
[2] 8344
$ netstat -apn|grep 8000
tcp        0      0 0.0.0.0:8000            0.0.0.0:*               LISTEN     8343/server        
tcp        0      0 127.0.0.1:44406         127.0.0.1:8000          ESTABLISHED8344/client        
tcp        0      0 127.0.0.1:8000          127.0.0.1:44406         ESTABLISHED8343/server
    應用程序中的一個socket文件描述符對應一個socket pair,也就是源地址:源端口號和目的地址:目的端口號,也對應一個TCP連接。
 37.1. clientserversocket狀態
socket文件描述符
源地址:源端口號
目的地址:目的端口號
狀態
server.c中的listenfd
0.0.0.0:8000
0.0.0.0:*
LISTEN
server.c中的connfd
127.0.0.1:8000
127.0.0.1:44406
ESTABLISHED
client.c中的sockfd
127.0.0.1:44406
127.0.0.1:8000
ESTABLISHED
 
2.2. 錯誤處理與讀寫控制
    上面的例子不僅功能簡單,而且簡單到幾乎沒有什麼錯誤處理,我們知道,系統調用不能保證每次都成功,必須進行出錯處理,這樣一方面可以保證程序邏輯正常,另一方面可以迅速得到故障信息。
    爲使錯誤處理的代碼不影響主程序的可讀性,我們把與socket相關的一些系統函數加上錯誤處理代碼包裝成新的函數,做成一個模塊wrap.c:
#include <stdlib.h>
#include <errno.h>
#include <sys/socket.h>
 
void perr_exit(const char *s)
{
        perror(s);
        exit(1);
}
/**************zb *************/ 
int Accept(int fd, struct sockaddr *sa, socklen_t *salenptr)
{
        int n;
 
again:
        if ( (n = accept(fd, sa, salenptr)) < 0) {
               if ((errno == ECONNABORTED) || (errno == EINTR))
                       goto again;
               else
                       perr_exit("accept error");
        }
        return n;
}
/**************mark *************/ 
 
void Bind(int fd, const struct sockaddr *sa, socklen_t salen)
{
        if (bind(fd, sa, salen) < 0)
               perr_exit("bind error");
}
 
void Connect(int fd, const struct sockaddr *sa, socklen_t salen)
{
        if (connect(fd, sa, salen) < 0)
               perr_exit("connect error");
}
 
void Listen(int fd, int backlog)
{
        if (listen(fd, backlog) < 0)
               perr_exit("listen error");
}
 
int Socket(int family, int type, int protocol)
{
        int n;
 
        if ( (n = socket(family, type, protocol)) < 0)
               perr_exit("socket error");
        return n;
}
 
/**************zb *************/ 
ssize_t Read(int fd, void *ptr, size_t nbytes)
{
        ssize_t n;
 
again:
        if ( (n = read(fd, ptr, nbytes)) == -1) {
               if (errno == EINTR)
                       goto again;
               else
                       return -1;
        }
        return n;
}
 
ssize_t Write(int fd, const void *ptr, size_t nbytes)
{
        ssize_t n;
 
again:
        if ( (n = write(fd, ptr, nbytes)) == -1) {
               if (errno == EINTR)
                       goto again;
               else
                       return -1;
        }
        return n;
}
/**************mark *************/ 
void Close(int fd)
{
        if (close(fd) == -1)
               perr_exit("close error");
}
    慢系統調用accept、read和write被信號中斷時應該重試。connect雖然也會阻塞,但是被信號中斷時不能立刻重試。對於accept,如果errno是ECONNABORTED,也應該重試。詳細解釋見參考資料。
    TCP協議是面向流的,read和write調用的返回值往往小於參數指定的字節數。對於read調用,如果接收緩衝區中有20字節,請求讀100個字節,就會返回20。對於write調用,如果請求寫100個字節,而發送緩衝區中只有20個字節的空閒位置,那麼write會阻塞,直到把100個字節全部交給發送緩衝區才返回,但如果socket文件描述符有O_NONBLOCK標誌,則write不阻塞,直接返回20。爲避免這些情況干擾主程序的邏輯,確保讀寫我們所請求的字節數,我們實現了兩個包裝函數readn和writen,也放在wrap.c中:
/**************zb *************/ 
ssize_t Readn(int fd, void *vptr, size_t n)
{
        size_t  nleft;
        ssize_t nread;
        char   *ptr;
 
        ptr = vptr;
        nleft = n;
        while (nleft > 0) {
               if ( (nread = read(fd, ptr, nleft)) < 0) {
                       if (errno == EINTR)
                               nread = 0;
                       else
                               return -1;
               } else if (nread == 0)
                       break;
 
               nleft -= nread;
               ptr += nread;
        }
        return n - nleft;
}
 
ssize_t Writen(int fd, const void *vptr, size_t n)
{
        size_t nleft;
        ssize_t nwritten;
        const char *ptr;
 
        ptr = vptr;
        nleft = n;
        while (nleft > 0) {
               if ( (nwritten = write(fd, ptr, nleft)) <= 0) {
                       if (nwritten < 0 && errno == EINTR)
                               nwritten = 0;
                       else
                               return -1;
               }
 
               nleft -= nwritten;
               ptr += nwritten;
        }
        return n;
}
/**************mark *************/ 
    如果應用層協議的各字段長度固定,用readn來讀是非常方便的。例如設計一種客戶端上傳文件的協議,規定前12字節表示文件名,超過12字節的文件名截斷,不足12字節的文件名用'/0'補齊,從第13字節開始是文件內容,上傳完所有文件內容後關閉連接,服務器可以先調用readn讀12個字節,根據文件名創建文件,然後在一個循環中調用read讀文件內容並存盤,循環結束的條件是read返回0。
    字段長度固定的協議往往不夠靈活,難以適應新的變化。比如,以前DOS的文件名是8字節主文件名加“.”加3字節擴展名,不超過12字節,但是現代操作系統的文件名可以長得多,12字節就不夠用了。那麼制定一個新版本的協議規定文件名字段爲256字節怎麼樣?這樣又造成很大的浪費,因爲大多數文件名都很短,需要用大量的'/0'補齊256字節,而且新版本的協議和老版本的程序無法兼容,如果已經有很多人在用老版本的程序了,會造成遵循新協議的程序與老版本程序的互操作性(Interoperability)問題。如果新版本的協議要添加新的字段,比如規定前12字節是文件名,從13到16字節是文件類型說明,從第17字節開始纔是文件內容,同樣會造成和老版本的程序無法兼容的問題。
    現在重新看看上一節的TFTP協議是如何避免上述問題的:TFTP協議的各字段是可變長的,以'/0'爲分隔符,文件名可以任意長,再看blksize等幾個選項字段,TFTP協議並沒有規定從第m字節到第n字節是blksize的值,而是把選項的描述信息“blksize”與它的值“512”一起做成一個可變長的字段,這樣,以後添加新的選項仍然可以和老版本的程序兼容(老版本的程序只要忽略不認識的選項就行了)。
    因此,常見的應用層協議都是帶有可變長字段的,字段之間的分隔符用換行的比用'/0'的更常見,例如本節後面要介紹的HTTP協議。可變長字段的協議用readn來讀就很不方便了,爲此我們實現一個類似於fgets的readline函數,也放在wrap.c中:
/**************zb *************/ 
static ssize_t my_read(int fd, char *ptr)
{
        static int read_cnt;
        static char *read_ptr;
        static char read_buf[100];
 
        if (read_cnt <= 0) {
        again:
               if ( (read_cnt = read(fd, read_buf, sizeof(read_buf))) < 0) {
                       if (errno == EINTR)
                               goto again;
                       return -1;
               } else if (read_cnt == 0)
                       return 0;
               read_ptr = read_buf;
        }
        read_cnt--;
        *ptr = *read_ptr++;
        return 1;
}
 
ssize_t Readline(int fd, void *vptr, size_t maxlen)
{
        ssize_t n, rc;
        char    c, *ptr;
 
        ptr = vptr;
        for (n = 1; n < maxlen; n++) {
               if ( (rc = my_read(fd, &c)) == 1) {
                       *ptr++ = c;
                       if (c  == '/n')
                               break;
               } else if (rc == 0) {
                       *ptr = 0;
                       return n - 1;
               } else
                       return -1;
        }
        *ptr  = 0;
        return n;
}
/**************mark *************/ 
習題
1、請讀者自己寫出wrap.c的頭文件wrap.h,後面的網絡程序代碼都要用到這個頭文件。
2、修改server.c和client.c,添加錯誤處理。
2.3. client改爲交互式輸入
    目前實現的client每次運行只能從命令行讀取一個字符串發給服務器,再從服務器收回來,現在我們把它改成交互式的,不斷從終端接受用戶輸入並和server交互。
/* client.c */
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <netinet/in.h>
#include "wrap.h"
 
#define MAXLINE 80
#define SERV_PORT 8000
 
int main(int argc, char *argv[])
{
        struct sockaddr_in servaddr;
        char buf[MAXLINE];
        int sockfd, n;
   
        sockfd = Socket(AF_INET, SOCK_STREAM, 0);
 
        bzero(&servaddr, sizeof(servaddr));
        servaddr.sin_family = AF_INET;
        inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr);
        servaddr.sin_port = htons(SERV_PORT);
   
        Connect(sockfd, (struct sockaddr *)&servaddr,
 sizeof(servaddr));
 
        while (fgets(buf, MAXLINE, stdin) != NULL) {
               Write(sockfd, buf, strlen(buf));
               n = Read(sockfd, buf, MAXLINE);
               if (n == 0)
                       printf("the other side has been closed./n"); /*zb: 應該在爲0時,退出循環*/ 
               else
                       Write(STDOUT_FILENO, buf, n);
        }
 
        Close(sockfd);
        return 0;
}
    編譯並運行server和client,看看是否達到了你預想的結果。
$ ./client
haha1
HAHA1
haha2
the other side has been closed.
haha3
$
    這時server仍在運行,但是client的運行結果並不正確。原因是什麼呢?仔細查看server.c可以發現,server對每個請求只處理一次,應答後就關閉連接,client不能繼續使用這個連接發送數據。但是client下次循環時又調用write發數據給server,write調用只負責把數據交給TCP發送緩衝區就可以成功返回了,所以不會出錯,而server收到數據後應答一個RST段,client收到RST段後無法立刻通知應用層,只把這個狀態保存在TCP協議層。client下次循環又調用write發數據給server,由於TCP協議層已經處於RST狀態了,因此不會將數據發出,而是發一個SIGPIPE信號給應用層,SIGPIPE信號的缺省處理動作是終止程序,所以看到上面的現象。
    爲了避免client異常退出,上面的代碼應該在判斷對方關閉了連接後break出循環,而不是繼續write。另外,有時候代碼中需要連續多次調用write,可能還來不及調用read得知對方已關閉了連接就被SIGPIPE信號終止掉了,這就需要在初始化時調用sigaction處理SIGPIPE信號,如果SIGPIPE信號沒有導致進程異常退出,write返回-1並且errno爲EPIPE。
    另外,我們需要修改server,使它可以多次處理同一客戶端的請求。
/* server.c */
#include <stdio.h>
#include <string.h>
#include <netinet/in.h>
#include "wrap.h"
 
#define MAXLINE 80
#define SERV_PORT 8000
 
int main(void)
{
        struct sockaddr_in servaddr, cliaddr;
        socklen_t cliaddr_len;
        int listenfd, connfd;
        char buf[MAXLINE];
        char str[INET_ADDRSTRLEN];
        int i, n;
 
        listenfd = Socket(AF_INET, SOCK_STREAM, 0);
 
        bzero(&servaddr, sizeof(servaddr));
        servaddr.sin_family = AF_INET;
        servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
        servaddr.sin_port = htons(SERV_PORT);
   
        Bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
 
        Listen(listenfd, 20);
 
        printf("Accepting connections .../n");
        while (1) {
               cliaddr_len = sizeof(cliaddr);
               connfd = Accept(listenfd,
                               (struct sockaddr *)&cliaddr, &cliaddr_len);
               while (1) {  /**************zb mark  只能處理一個客戶端*************/ 
                       n = Read(connfd, buf, MAXLINE);
                       if (n == 0) {
                               printf("the other side has been closed./n");
                               break;
                       }
                       printf("received from %s at PORT %d/n",
                              inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)),
                              ntohs(cliaddr.sin_port));
   
                       for (i = 0; i < n; i++)
                               buf[i] = toupper(buf[i]);
                       Write(connfd, buf, n);
               }
               Close(connfd);
        }
}
    經過上面的修改後,客戶端和服務器可以進行多次交互了。我們知道,服務器通常是要同時服務多個客戶端的,運行上面的server和client之後,再開一個終端運行client試試,新的client能得到服務嗎?想想爲什麼。
2.4. 使用fork併發處理多個client的請求
    怎麼解決這個問題?網絡服務器通常用fork來同時服務多個客戶端,父進程專門負責監聽端口,每次accept一個新的客戶端連接就fork出一個子進程專門服務這個客戶端。但是子進程退出時會產生殭屍進程,父進程要注意處理SIGCHLD信號和調用wait清理殭屍進程。
    以下給出代碼框架,完整的代碼請讀者自己完成。
listenfd = socket(...);
bind(listenfd, ...);
listen(listenfd, ...);
while (1) {
        connfd = accept(listenfd, ...);
        n = fork();
        if (n == -1) {
               perror("call to fork");
               exit(1);
        } else if (n == 0) {
        /**************zb mark  fork + while *************/ 
               close(listenfd);
               while (1) {
                       read(connfd, ...);
                       ...
                       write(connfd, ...);
               }
               close(connfd);
               exit(0);
        } else
               close(connfd);
}
2.5. setsockopt
    現在做一個測試,首先啓動server,然後啓動client,然後用Ctrl-C使server終止,這時馬上再運行server,結果是:
$ ./server
 bind error: Address already in use
    這是因爲,雖然server的應用程序終止了,但TCP協議層的連接並沒有完全斷開,因此不能再次監聽同樣的server端口。我們用netstat命令查看一下:
$ netstat -apn |grep 8000
 tcp        1      0 127.0.0.1:33498         127.0.0.1:8000          CLOSE_WAIT 10830/client       
 tcp        0      0 127.0.0.1:8000          127.0.0.1:33498         FIN_WAIT2  -
    server終止時,socket描述符會自動關閉併發FIN段給client,client收到FIN後處於CLOSE_WAIT狀態,但是client並沒有終止,也沒有關閉socket描述符,因此不會發FIN給server,因此server的TCP連接處於FIN_WAIT2狀態。
    現在用Ctrl-C把client也終止掉,再觀察現象:
$ netstat -apn |grep 8000
 tcp        0      0 127.0.0.1:8000          127.0.0.1:44685         TIME_WAIT  -
 $ ./server
 bind error: Address already in use
&am, p;nb, sp;   client終止時自動關閉socket描述符,server的TCP連接收到client發的FIN段後處於TIME_WAIT狀態。TCP協議規定,主動關閉連接的一方要處於TIME_WAIT狀態,等待兩個MSL(maximum segment lifetime)的時間後才能回到CLOSED狀態,因爲我們先Ctrl-C終止了server,所以server是主動關閉連接的一方,在TIME_WAIT期間仍然不能再次監聽同樣的server端口。MSL在RFC1122中規定爲兩分鐘,但是各操作系統的實現不同,在Linux上一般經過半分鐘後就可以再次啓動server了。至於爲什麼要規定TIME_WAIT的時間請讀者參考UNP 2.7節。
    在server的TCP連接沒有完全斷開之前不允許重新監聽是不合理的,因爲,TCP連接沒有完全斷開指的是connfd(127.0.0.1:8000)沒有完全斷開,而我們重新監聽的是listenfd(0.0.0.0:8000),雖然是佔用同一個端口,但IP地址不同,connfd對應的是與某個客戶端通訊的一個具體的IP地址,而listenfd對應的是wildcard address。解決這個問題的方法是使用setsockopt()設置socket描述符的選項SO_REUSEADDR爲1,表示允許創建端口號相同但IP地址不同的多個socket描述符。在server代碼的socket()和bind()調用之間插入如下代碼:
int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    有關setsockopt可以設置的其它選項請參考UNP第7章。
2.6. 使用select
    select是網絡程序中很常用的一個系統調用,它可以同時監聽多個阻塞的文件描述符(例如多個網絡連接),哪個有數據到達就處理哪個,這樣,不需要fork和多進程就可以實現併發服務的server。
/* server.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <netinet/in.h>
#include "wrap.h"
 
#define MAXLINE 80
#define SERV_PORT 8000
 
int main(int argc, char **argv)
{
        int i, maxi, maxfd, listenfd, connfd, sockfd;
        int nready, client[FD_SETSIZE];
        ssize_t n;
        fd_set rset, allset;
        char buf[MAXLINE];
        char str[INET_ADDRSTRLEN];
        socklen_t cliaddr_len;
        struct sockaddr_in     cliaddr, servaddr;
 
        listenfd = Socket(AF_INET, SOCK_STREAM, 0);
 
        bzero(&servaddr, sizeof(servaddr));
        servaddr.sin_family      = AF_INET;
        servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
        servaddr.sin_port        = htons(SERV_PORT);
 
        Bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
 
        Listen(listenfd, 20);
       
       /**************zb *************/ 
        maxfd = listenfd;              /* initialize */
        maxi = -1;                     /* index into client[] array */
        for (i = 0; i < FD_SETSIZE; i++)
               client[i] = -1; /* -1 indicates available entry */
        FD_ZERO(&allset);
        FD_SET(listenfd, &allset);
 
        for ( ; ; ) {
               rset = allset; /* structure assignment */
               nready = select(maxfd+1, &rset, NULL, NULL, NULL);
               if (nready < 0)
                       perr_exit("select error");
 
               if (FD_ISSET(listenfd, &rset)) { /* new client connection */
                       cliaddr_len = sizeof(cliaddr);
                       connfd = Accept(listenfd, (struct sockaddr *)&cliaddr, &cliaddr_len);
 
                       printf("received from %s at PORT %d/n",
                              inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)),
                              ntohs(cliaddr.sin_port));
 
                       for (i = 0; i < FD_SETSIZE; i++)
                               if (client[i] < 0) {
                                      client[i] = connfd; /* save descriptor */
                                      break;
                               }
                       if (i == FD_SETSIZE) {
                               fputs("too many clients/n", stderr);
                               exit(1);
                       }
 
                       FD_SET(connfd, &allset);       /* add new descriptor to set */
                       if (connfd > maxfd)
                               maxfd = connfd; /* for select */
                       if (i > maxi)
                               maxi = i;      /* max index in client[] array */
 
                       if (--nready == 0)
                               continue;      /* no more readable descriptors */
               }
 
               for (i = 0; i <= maxi; i++) {  /* check all clients for data */
                       if ( (sockfd = client[i]) < 0)
                               continue;
                       if (FD_ISSET(sockfd, &rset)) {
                               if ( (n = Read(sockfd, buf, MAXLINE)) == 0) {
                                      /* connection closed by client */
                                      Close(sockfd);
                                      FD_CLR(sockfd, &allset);
                                      client[i] = -1;
                               } else {
                                      int j;
                                      for (j = 0; j < n; j++)
                                              buf[j] = toupper(buf[j]);
                                      Write(sockfd, buf, n);
                               }
 
                               if (--nready == 0)
                                      break;  /* no more readable descriptors */
                       }
               }
        }
      /**************mark *************/ 
}
3. 基於UDP協議的網絡程序
    下圖是典型的UDP客戶端/服務器通訊過程(該圖出自[UNPv13e])。
 37.3. UDP通訊流程

UDP通訊流程

    以下是簡單的UDP服務器和客戶端程序。
/* server.c */
#include <stdio.h>
#include <string.h>
#include <netinet/in.h>
#include "wrap.h"
 
#define MAXLINE 80
#define SERV_PORT 8000
 
int main(void)
{
        struct sockaddr_in servaddr, cliaddr;
        socklen_t cliaddr_len;
        int sockfd;
        char buf[MAXLINE];
        char str[INET_ADDRSTRLEN];
        int i, n;
 
        sockfd = Socket(AF_INET, SOCK_DGRAM, 0);
 
        bzero(&servaddr, sizeof(servaddr));
        servaddr.sin_family = AF_INET;
        servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
        servaddr.sin_port = htons(SERV_PORT);
   
        Bind(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
 
        printf("Accepting connections .../n");
        while (1) {
               cliaddr_len = sizeof(cliaddr);
               n = recvfrom(sockfd, buf, MAXLINE, 0, (struct sockaddr *)&cliaddr, &cliaddr_len);
               if (n == -1)
                       perr_exit("recvfrom error");
               printf("received from %s at PORT %d/n",
                      inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)),
                       ntohs(cliaddr.sin_port));
   
               for (i = 0; i < n; i++)
                       buf[i] = toupper(buf[i]);
               n = sendto(sockfd, buf, n, 0, (struct sockaddr *)&cliaddr, sizeof(cliaddr));
               if (n == -1)
                       perr_exit("sendto error");
        }
}
/* client.c */
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <netinet/in.h>
#include "wrap.h"
 
#define MAXLINE 80
#define SERV_PORT 8000
 
int main(int argc, char *argv[])
{
        struct sockaddr_in servaddr;
        int sockfd, n;
        char buf[MAXLINE];
        char str[INET_ADDRSTRLEN];
        socklen_t servaddr_len;
   
        sockfd = Socket(AF_INET, SOCK_DGRAM, 0);
 
        bzero(&servaddr, sizeof(servaddr));
        servaddr.sin_family = AF_INET;
        inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr);
        servaddr.sin_port = htons(SERV_PORT);
   
        while (fgets(buf, MAXLINE, stdin) != NULL) {
               n = sendto(sockfd, buf, strlen(buf), 0, (struct sockaddr *)&servaddr, sizeof(servaddr));
               if (n == -1)
                       perr_exit("sendto error");
 
               n = recvfrom(sockfd, buf, MAXLINE, 0, NULL, 0);
               if (n == -1)
                       perr_exit("recvfrom error");
         
               Write(STDOUT_FILENO, buf, n);
        }
 
        Close(sockfd);
        return 0;
}
    由於UDP不需要維護連接,程序邏輯簡單了很多,但是UDP協議是不可靠的,實際上有很多保證通訊可靠性的機制需要在應用
層實現。
   編譯運行server,在兩個終端裏各開一個client與server交互,看看server是否具有併發服務的能力。用Ctrl+C關閉
server,然後再運行server,看此時client還能否和server聯繫上。和前面TCP程序的運行結果相比較,體會無連接的含義。
由於UDP不需要維護連接,程序邏輯簡單了很多,但是UDP協議是不可靠的,實際上有很多保證通訊可靠性的機制需要在應用
層實現。
5. 練習:實現簡單的Web服務器
   實現一個簡單的Web服務器myhttpd。服務器程序啓動時要讀取配置文件/etc/myhttpd.conf,其中需要指定服務器監聽的
端口號和服務目錄,例如:
Port=80
Directory=/var/www
    注意,1024以下的端口號需要超級用戶才能開啓服務。如果你的系統中已經安裝了某種Web服務器(例如Apache),應該
爲myhttpd選擇一個不同的端口號。當瀏覽器向服務器請求文件時,服務器就從服務目錄(例如/var/www)中找出這個文
件,加上HTTP協議頭一起發給瀏覽器。但是,如果瀏覽器請求的文件是可執行的則稱爲CGI程序,服務器並不是將這個文件
發給瀏覽器,而是在服務器端執行這個程序,將它的標準輸出發給瀏覽器,服務器不發送完整的HTTP協議頭,CGI程序自己
負責輸出一部分HTTP協議頭。
5.1. 基本HTTP協議
   打開瀏覽器,輸入服務器IP,例如 http://192.168.0.3 ,如果端口號不是80,例如是8000,則輸入 http://192.168.0.3:8000 。
這時瀏覽器向服務器發送的HTTP協議頭如下:
GET / HTTP/1.1
Host: 192.168.0.3:8000
User-Agent: Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.8.1.6) Gecko/20061201 Firefox/2.0.0.6 (Ubuntu-feisty)
Accept: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Keep-Alive: 300
Connection: keep-alive
 
    注意,其中每一行的末尾都是回車加換行(C語言的"/r/n"),第一行是GET請求和協議版本,其餘幾行選項字段我們不討論,
HTTP協議頭的最後有一個空行,也是回車加換行。
   我們實現的Web服務器只要能正確解析第一行就行了,這是一個GET請求,請求的是服務目錄的根目錄/(在本例中實際上
是/var/www),Web服務器應該把該目錄下的索引頁(默認是index.html)發給瀏覽器,也就是把/var/www/index.html
發給瀏覽器。假如該文件的內容如下(HTML文件沒必要以"/r/n"換行,以"/n"換行就可以了):
<html>
<head><title>Test Page</title></head>
<body>
        <p>Test OK</p>
        <img src='mypic.jpg'>
</body>
</html>
    顯示一行字和一幅圖片,圖片的相對路徑(相對當前的index.html文件的路徑)是mypic.jpg,
也就是/var/www/mypic.jpg,
   如果用絕對路徑表示應該是:
             <img src='/mypic.jpg'>
    服務器應按如下格式應答瀏覽器:
             HTTP/1.1 200 OK
             Content-Type: text/html
 
             <html>
             <head><title>Test Page</title></head>
             <body>
               <p>Test OK</p>
               <img src='mypic.jpg'>
             </body>
             </html>
    服務器應答的HTTP頭也是每行末尾以回車加換行結束,最後跟一個空行的回車加換行。
   HTTP頭的第一行是協議版本和應答碼,200表示成功,後面的消息OK其實可以隨意寫,瀏覽器
是不關心的,主要是爲了調試時給開發人員看的。雖然網絡協議最終是程序與程序之間的對話,但
是在開發過程中卻是人與程序之間的對話,一個設計透明的網絡協議可以提供很多直觀的信息給開
發人員,因此,很多應用層網絡協議,如HTTP、FTP、SMTP、POP3等都是基於文本的協議,爲
的是透明性(transparency)。
   HTTP頭的第二行表示即將發送的文件的類型(稱爲MIME類型),這裏是text/html,純文本文
件是text/plain,圖片則是image/jpg、image/png等。
   然後就發送文件的內容,發送完畢之後主動關閉連接,這樣瀏覽器就知道文件發送完了。這一點
比較特殊:通常網絡通信都是客戶端主動發起連接,主動發起請求,主動關閉連接,服務器只是被
動地處理各種情況,而HTTP協議規定服務器主動關閉連接(有些Web服務器可以配置成Keep-Alive
的,我們不討論這種情況)。
   瀏覽器收到index.html之後,發現其中有一個圖片文件,就會再發一個GET請求(HTTP協議頭其
餘部分略):
              GET /mypic.jpg HTTP/1.1
     一個較大的網頁中可能有很多圖片,瀏覽器可能在下載網頁的同時就開很多線程下載圖片,因此,
'''服務器即使對同一個客戶端也需要提供並行服務的能力'''。服務器收到這個請求應該把圖片發過去然
後關閉連接:
              HTTP/1.1 200 OK
              Content-Type: image/jpg
  (這裏是mypic.jpg的二進制數據)
   這時瀏覽器就應該顯示出完整的網頁了。
   如果瀏覽器請求的文件在服務器上找不到,要應答一個404錯誤頁面,例如:
HTTP/1.1 404 Not Found
Content-Type: text/html
 
<html><body>request file not found</body></html>
5.2. 執行CGI程序
   如果瀏覽器請求的是一個可執行文件(不管是什麼樣的可執行文件,即使是shell腳本也一樣),
那麼服務器並不把這個文件本身發給瀏覽器,而是把它的執行結果標準輸出發
給瀏覽器例如一個shell腳本/var/www/myscript.sh(注意一定要加可執行權限):
#!/bin/sh
echo "Content-Type: text/html"
echo
echo "<html><body>Hello world!</body></html>"
    這樣瀏覽器收到的是:
HTTP/1.1 200 OK
Content-Type: text/html
 
<html><body>Hello world!</body></html>
    總結一下服務器的處理步驟:
      1.  解析瀏覽器的請求,在服務目錄中查找相應的文件,如果找不到該文件就返回404錯誤頁面
      2.  如果找到了瀏覽器請求的文件,用stat(2)檢查它是否可執行
      3.  如果該文件可執行:
          a.  發送HTTP/1.1 200 OK給客戶端
          b.  fork(2),然後用dup2(2)重定向子進程的標準輸出到客戶端socket
          c.  在子進程中exec(3)該CGI程序
          d.  關閉連接
      4.  如果該文件不可執行:
          a.  發送HTTP/1.1 200 OK給客戶端
          b.  如果是一個圖片文件,根據圖片的擴展名發送相應的Content-Type給客戶端
          c.   如果不是圖片文件,這裏我們簡化處理,都當作Content-Type: text/html
          d.  簡單的HTTP協議頭有這兩行就足夠了,再發一個空行表示結束
          e.  讀取文件的內容發送到客戶端
          f.   關閉連接
發佈了30 篇原創文章 · 獲贊 21 · 訪問量 45萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章