在 TCP/IP 協議中,"IP地址 + TCP或UDP埠號" 可以唯一標識網路通訊中的一個程式,"IP地址+埠號" 就稱為 socket。本文以一個簡單的 TCP 協議為例,介紹如何建立基於 TCP 協議的網路程式。
TCP 協議通訊流程
下圖描述了 TCP 協議的通訊流程(此圖來自網際網路):
下圖則描述 TCP 建立連線的過程(此圖來自網際網路):
伺服器呼叫 socket()、bind()、listen() 函式完成初始化後,呼叫 accept() 阻塞等待,處於監聽埠的狀態,客戶端呼叫 socket() 初始化後,呼叫 connect() 發出 SYN 段並阻塞等待伺服器應答,伺服器應答一個SYN-ACK 段,客戶端收到後從 connect() 返回,同時應答一個 ACK 段,伺服器收到後從 accept() 返回。
TCP 連線建立後資料傳輸的過程:
建立連線後,TCP 協議提供全雙工的通訊服務,但是一般的客戶端/伺服器程式的流程是由客戶端主動發起請求,伺服器被動處理請求,一問一答的方式。因此,伺服器從 accept() 返回後立刻呼叫 read(),讀 socket 就像讀管道一樣,如果沒有資料到達就阻塞等待,這時客戶端呼叫 write() 傳送請求給伺服器,伺服器收到後從 read() 返回,對客戶端的請求進行處理,在此期間客戶端呼叫 read() 阻塞等待伺服器的應答,伺服器呼叫 write() 將處理結果發回給客戶端,再次呼叫 read() 阻塞等待下一條請求,客戶端收到後從 read() 返回,傳送下一條請求,如此迴圈下去。
下圖描述了關閉 TCP 連線的過程:
如果客戶端沒有更多的請求了,就呼叫 close() 關閉連線,就像寫端關閉的管道一樣,伺服器的 read() 返回 0,這樣伺服器就知道客戶端關閉了連線,也呼叫 close() 關閉連線。注意,任何一方呼叫 close() 後,連線的兩個傳輸方向都關閉,不能再傳送資料了。如果一方呼叫 shutdown() 則連線處於半關閉狀態,仍可接收對方發來的資料。
在學習 socket 程式設計時要注意應用程式和 TCP 協議層是如何互動的:
- 應用程式呼叫某個 socket 函式時 TCP 協議層完成什麼動作,比如呼叫 connect() 會發出 SYN 段
- 應用程式如何知道 TCP 協議層的狀態變化,比如從某個阻塞的 socket 函式返回就表明 TCP 協議收到了某些段,再比如 read() 返回 0 就表明收到了 FIN 段
下面通過一個簡單的 TCP 網路程式來理解相關概念。程式分為伺服器端和客戶端兩部分,它們之間通過 socket 進行通訊。
伺服器端程式
下面是一個非常簡單的伺服器端程式,它從客戶端讀字元,然後將每個字元轉換為大寫並回送給客戶端:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.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; // socket() 開啟一個網路通訊埠,如果成功的話, // 就像 open() 一樣返回一個檔案描述符, // 應用程式可以像讀寫檔案一樣用 read/write 在網路上收發資料。 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 和 servaddr 繫結在一起, // 使 listenfd 這個用於網路通訊的檔案描述符監聽 servaddr 所描述的地址和埠號。 bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)); // listen() 宣告 listenfd 處於監聽狀態, // 並且最多允許有 20 個客戶端處於連線待狀態,如果接收到更多的連線請求就忽略。 listen(listenfd, 20); printf("Accepting connections ...\n"); while (1) { cliaddr_len = sizeof(cliaddr); // 典型的伺服器程式可以同時服務於多個客戶端, // 當有客戶端發起連線時,伺服器呼叫的 accept() 返回並接受這個連線, // 如果有大量的客戶端發起連線而伺服器來不及處理,尚未 accept 的客戶端就處於連線等待狀態。 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); } }
把上面的程式碼儲存到檔案 server.c 檔案中,並執行下面的命令編譯:
$ gcc server.c -o server
然後執行編譯出來的 server 程式:
$ ./server
此時我們可以通過 ss 命令來檢視主機上的埠監聽情況:
如上圖所示,server 程式已經開始監聽主機的 8000 埠了。
下面讓我們介紹一下這段程式中用到的 socket 相關的 API。
int socket(int family, int type, int protocol);
socket() 開啟一個網路通訊埠,如果成功的話,就像 open() 一樣返回一個檔案描述符,應用程式可以像讀寫檔案一樣用 read/write 在網路上收發資料。對於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() 的作用是將引數 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);
listen() 宣告 sockfd 處於監聽狀態,並且最多允許有 backlog 個客戶端處於連線待狀態,如果接收到更多的連線請求就忽略。
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 的引數。
客戶端程式
下面是客戶端程式,它從命令列引數中獲得一個字串發給伺服器,然後接收伺服器返回的字串並列印:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <netinet/in.h> #include <arpa/inet.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); // 由於客戶端不需要固定的埠號,因此不必呼叫 bind(),客戶端的埠號由核心自動分配。 // 注意,客戶端不是不允許呼叫 bind(),只是沒有必要呼叫 bind() 固定一個埠號, // 伺服器也不是必須呼叫 bind(),但如果伺服器不呼叫 bind(),核心會自動給伺服器分配監聽埠, // 每次啟動伺服器時埠號都不一樣,客戶端要連線伺服器就會遇到麻煩。 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); printf("\n"); close(sockfd); return 0; }
把上面的程式碼儲存到檔案 client.c 檔案中,並執行下面的命令編譯:
$ gcc client.c -o client
然後執行編譯出來的 client 程式:
$ ./client hello
此時伺服器端會收到請求並返回轉換為大寫的字串,並輸出相應的資訊:
而客戶端在傳送請求後會收到轉換過的字串:
在客戶端的程式碼中有兩點需要注意:
1. 由於客戶端不需要固定的埠號,因此不必呼叫 bind(),客戶端的埠號由核心自動分配。
2. 客戶端需要呼叫 connect() 連線伺服器,connect 和 bind 的引數形式一致,區別在於 bind 的引數是自己的地址,而 connect 的引數是對方的地址。
至此我們已經使用 socket 技術完成了一個最簡單的客戶端伺服器程式,雖然離實際應用還非常遙遠,但就學習而言已經足夠了。
提升伺服器端的響應能力
雖然我們的伺服器程式可以響應客戶端的請求,但是這樣的效率太低了。一般情況下伺服器程式需要能夠同時處理多個客戶端的請求。可以通過 fork 系統呼叫建立子程式來處理每個請求,下面是大體的實現思路:
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) { // 在子程式中處理客戶端的請求。 close(listenfd); while (1) { read(connfd, ...); ... write(connfd, ...); } close(connfd); exit(0); } else { close(connfd); } }
此時父程式的任務就是不斷的建立子程式,而由子程式去響應客戶端的具體請求。通過這種方式,可以極大的提升伺服器端的響應能力。
總結
本文通過一個簡單的建基於 TCP 協議的網路程式介紹了 linux socket 程式設計中的基本概念。通過它我們可以瞭解到 socket 程式工作的基本原理,以及一些解決效能問題的思路。
參考:
基於TCP協議的網路程式