理解TCP和UDP
根據數據傳輸方式的不同,基於網絡協議的套接字一般分為TCP套接字和UDP套接字。因為TCP套接字是面向連接的,因此又稱為基於流(stream)的套接字。TCP是Transmission Control Protocol(傳輸控制協議)的簡寫,意為“對數據傳輸過程的控制”。因此,學習控制方法及范圍有助於正確理解TCP套接字
TCP/IP協議棧
講解TCP前先介紹TCP所屬的TCP/IP協議棧(Stack,層),如圖1-1所示:
圖1-1 TCP/IP協議棧
從圖1-1可以看出,TCP/IP協議棧共分為四層,可以理解為數據收發分成了四個層次化過程。也就是說,面對“基於互聯網的有效數據傳輸”的命題,並非通過一個龐大的協議解決問題,而是通過層次化方案——TCP/IP協議棧解決,通過TCP套接字收發數據需要借助四層,如圖1-2所示:
圖1-2 TCP協議棧
反之,通過UDP套接字收發數據時,利用圖1-2的四層協議棧來完成:
圖1-3 UDP協議棧
各層可能通過操作系統等軟件實現,也可能通過類似NIC的硬件設備實現
TCP/IP協議的誕生背景
“通過因特網完成有效數據傳輸”這個課題讓許多專家聚集到一起,不同人負責不同模塊,如:硬件、系統、路由。為什么要這樣做呢?因為編寫軟件前需要構建硬件系統,在此基礎上需要通過軟件實現各種算法,所以才需要眾多領域的專家進行討論,以形成各種規定。把“通過因特網完成有效數據傳輸”問題按照不同領域划分成小問題后,出現了多種協議,它們通過層級結構建立緊密聯系
把協議分成多個層次具有哪些優點?協議設計更容易?這是優點之一,但更重要的原因是:為了通過標准化操作設計開放式系統。標准本身就在於對外公開,引導更多人遵循。以多個標准為依據所設計的系統稱為開放式系統,我們現在學習的TCP/IP協議棧也屬於其中之一。那么開放式系統具有哪些優點呢?比方:路由器用來完成IP層交互任務,某公司原先使用A路由器,可將其替換成B路由器,即便A、B這兩種路由器並非同一產商也可以順利替換,因為所有的路由器生產產商都會按照IP層標准制造
再舉個例子,大家的計算機一般都裝有網卡(網絡接口卡),即便沒安裝也沒關系,網卡很容易買到,因為所有的網卡制造商都會遵守鏈路層的協議標准,這就是開放式系統的優點
鏈路層
接下來逐層了解TCP/IP協議棧,先講鏈路層。鏈路層是物理鏈接領域標准化的結果,也是最基本的領域,專門定義LAN、WAN、MAN等網絡標准。若兩台主機通過網絡進行數據進行交換,則需要圖1-4所示的物理連接,鏈路層就負責這些標准:
圖1-4 網絡連接結構
IP層
准備好物理連接后就要傳輸數據,為了在復雜的網絡中傳輸數據,首先需要考慮路徑的選擇。向目標傳輸數據需要經過哪條路徑?解決此問題就是IP層,該層使用的協議就是IP。IP本身是面向消息的、不可靠的協議。每次傳輸數據時會幫我們選擇路徑,但每次傳輸時的路徑並不一致。如果傳輸中發生路徑錯誤,則選擇其他路徑;但如果發生數據丟失或損壞,則無法解決。換言之,IP協議無法應對數據錯誤
TCP/UDP層
IP層解決數據傳輸中的路徑選擇問題,只需照此路徑傳輸數據即可。TCP和UDP層以IP層提供的路徑信息為基礎完成實際的數據傳輸,故該層又稱傳輸層。UDP比TCP簡單,我們后面還會在討論,現在只解釋TCP。TCP可以保證可靠的數據傳輸,但它發送數據時以IP層為基礎,IP層是面向消息的,是不可靠的,那TCP又是如何保證消息的可靠傳輸呢?
IP層只關注一個數據包(數據傳輸的基本單位)的傳輸過程。因此,即使傳輸多個數據包,每個數據包也是由IP層實際傳輸的,也就是說傳輸順序及傳輸本身都是不可靠的。若只利用IP層傳輸數據,則有可能后發送的數據包比早發生的數據包先到達目標主機。另外,傳輸的數據包A、B、C中可能只收到A和C,B可能丟失或接收到時已損壞。但若添加TCP協議則會按照如圖1-5的方式進行數據傳輸:
圖1-5 傳輸控制協議
我們可以看到,當主機A發送1號數據包給主機B時,必須等到主機B確認1號數據包接收成功,才會接着發送2號數據包,如果主機A發送1號數據包卻遲遲收不到主機B回復的接收成功,則會認為是超時,並重新發送一個1號數據包
實現基於TCP的服務端/客戶端
圖1-6給出了TCP服務器端默認的函數調用順序,大部分TCP服務器端都按照該順序調用
圖1-6 TCP服務端函數調用順序
調用socket函數創建套接字,聲明並初始化地址信息結構體變量,調用bind函數向套接字分配地址。這兩個階段之前都討論過了,下面講解之后的幾個過程
進入等待連接請求狀態
我們已調用bind函數給套接字分配了地址,接下來就要通過調用listen函數進入等待連接請求狀態。只有調用了listen函數,服務端套接字才能進入可接收連接的狀態,換言之,這時,客戶端才能調用connect函數(若提前調用則會發生錯誤)
#include <sys/socket.h> int listen(int sockfd, int backlog);//成功時返回0,失敗時返回-1
- sock:希望進入等待連接請求狀態的套接字文件描述符,傳遞的描述符套接字參數成為服務端套接字(監聽套接字)
- backlog:連接請求等待隊列(Queue)的長度,若為5,則隊列長度為5,表示最多使5個連接請求進入隊列
先解釋一下等待連接請求狀態的含義和連接請求等待隊列。“服務器端處於等待連接請求狀態”是指,客戶端請求連接時,服務器端受理連接前一直處於等待狀態,當有多個客戶端一起發送連接請求時,服務器端套接字只能處理一個連接請求,而其他的連接請求,只能暫時放在請求隊列,即listen函數的第二個參數
受理客戶端連接請求
調用listen函數后,若有新的連接請求,則應按序受理。受理請求意味着進入可接收數據的狀態,這里進入這種狀態的所需部件當然還是套接字,可能有人會想使用服務器端套接字,但服務器端套接字已經用於監聽,如果將其用於與客戶端交換數據,那么誰來監聽客戶端的連接請求呢?因此需要另外一個套接字,但沒必要親自創建,accept函數將自動創建套接字,並連接到發起請求的客戶端
#include <sys/socket.h> int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);//成功時返回創建的套接字文件描述符,失敗時返回-1
- sock:服務器套接字的文件描述符
- addr:保存發起連接請求的客戶端地址信息的變量地址值,調用函數后向傳遞來的地址變量參數填充客戶端地址信息
- addrlen:第二個參數addr結構體的長度,但是存有長度的變量地址。函數調用完成后,該變量即被填入客戶端地址長度
accept函數受理連接請求等待隊列中待處理的客戶端連接請求,函數調用成功時,accept函數內部將產生用於數據I/O的套接字,並返回其文件描述符。需要強調的是,套接字是自動創建的,並自動與發起連接請求的客戶端建立連接
這里,我們重新回顧TCP/IP網絡編程之網絡編程和套接字這一章中的hello_server.c
hello_server.c
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> void error_handling(char *message); int main(int argc, char *argv[]) { int serv_sock; int clnt_sock; struct sockaddr_in serv_addr; struct sockaddr_in clnt_addr; socklen_t clnt_addr_size; char message[] = "Hello world!"; if (argc != 2) { printf("Usage: %s <port>\n", argv[0]); exit(1); } serv_sock = socket(AF_INET, SOCK_STREAM, 0); if (serv_sock == -1) error_handling("sock() error"); memset(&serv_addr, 0, sizeof(serv_addr)); serv_addr.sin_family = AF_INET; serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); serv_addr.sin_port = htons(atoi(argv[1])); if (bind(serv_sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1) error_handling("bind() error"); if (listen(serv_sock, 5) == -1) error_handling("listen() error"); clnt_addr_size = sizeof(clnt_addr); clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_addr, &clnt_addr_size); if (clnt_sock == -1) error_handling("accept() error"); write(clnt_sock, message, sizeof(message)); close(clnt_sock); close(serv_sock); return 0; } void error_handling(char *message) { fputs(message, stderr); fputc('\n', stderr); exit(1); }
- 第27行:服務器端實現過程中先要創建套接字,但此時的套接字尚未是真正的服務器端套接字
- 第31~37行:為了完成套接字地址分配,初始化結構體變量並調用bind函數
- 第39行:調用accept函數從隊列的頂部取出一個連接請求與客戶端建立連接,並返回創建的套接字文件描述符。另外,調用accept函數時若等待隊列為空,則accept函數不會返回,直到隊列中出現新的客戶端連接
- 第47~49行:調用write函數向客戶端傳輸數據,調用close函數關閉連接
TCP客戶端的默認函數調用順序
接下來講解客戶端的實現順序,我們前面說過,客戶端的套接字實現比服務器端要簡單的多,因為創建套接字和請求連接就是客戶端的全部內容,如圖1-7:
圖1-7 TCP客戶端函數調用順序
與服務器端相比,區別就在於“請求連接”,它是創建客戶端套接字后向服務器端發起的連接請求。服務器端調用listen函數后創建連接請求等待隊列,之后客戶端即可請求連接。那如何發起連接請求呢?通過connect函數完成:
#include <sys/socket.h> int connect(int sock_fd, struct sockaddr *serv_addr, socklen_t addrlen);//成功時返回0,失敗時返回-1
- sock:客戶端套接字文件描述符
- serv_addr:保存目標服務器端地址信息的變量地址值
- addrlen:以字節為單位傳遞已傳遞給第二個結構體參數serv_addr的地址變量長度
客戶端調用connect函數后,發生以下情況之一才會返回:
- 服務器端接收連接請求
- 發生斷網等異常情況而中斷連接請求
需要注意,所謂的“接收連接”並不意味着服務器端調用accept函數,其實是服務器端把連接請求信息記錄到等待隊列,因此connect函數返回后並不立即進行數據交換
這里,我們再回顧之前的hello_client.c
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> void error_handling(char *message); int main(int argc, char *argv[]) { int sock; struct sockaddr_in serv_addr; char message[30]; int str_len; if (argc != 3) { printf("Usage: %s <IP> <port>\n", argv[0]); exit(1); } sock = socket(AF_INET, SOCK_STREAM, 0); if (sock == -1) error_handling("sock() error"); memset(&serv_addr, 0, sizeof(serv_addr)); serv_addr.sin_family = AF_INET; serv_addr.sin_addr.s_addr = inet_addr(argv[1]); serv_addr.sin_port = htons(atoi(argv[2])); if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1) error_handling("connect() error!"); str_len = read(sock, message, sizeof(message) - 1); if (str_len == -1) error_handling("read() error!"); printf("Message from server: %s\n", message); close(sock); return 0; } void error_handling(char *message) { fputs(message, stderr); fputc('\n', stderr); exit(1); }
- 第23行:創建准備連接服務器端的套接字,此時創建的是TCP套接字
- 第27~30行:結構體變量serv_addr中初始化IP和端口信息。初始化值為目標服務器端套接字的IP和端口信息
- 第32行:調用connect函數向服務器端發送連接請求
- 第35行:完成連接后,接收服務器端傳輸的數據
- 第40行:接收數據后調用close函數關閉套接字,結束與服務器端的連接
基於TCP的服務器端/客戶端函數調用關系
前面講解了TCP服務器端/客戶端的實現順序,實際上二者並非相互獨立,讓我們畫一下它們之間的交互過程,如圖1-8所示
圖1-8 函數調用關系
圖1-8的總體流程如下:服務器端創建套接字后聯系調用bind、listen函數進入等待狀態,客戶端通過調用connect函數發起連接請求,需要注意的是,客戶端只能等到服務器端調用listen函數后才能調用connect函數。同時要清楚,客戶端調用connect前,服務器端可能先調用了accept函數。當然,此時服務器端在調用accept函數時進入了阻塞狀態,直到客戶端調用connect函數為止
實現迭代服務器端/客戶端
現在,讓我們來編寫一個回聲服務器端/客戶端,所謂回聲,就是服務器端將客戶端傳輸的字符串數據原封不動地回傳給客戶端,不過在此之前,需要解釋一下何為迭代服務器端。之前我們所看到的Hello world服務器端處理完一個客戶端連接請求則退出程序,連接請求等待隊列是實際上沒太大意義,這並非我們所需的服務器端,設置好等待隊列后,應向所有客戶端提供服務,如果在受理完一個客戶端請求連接后,還需要再受理其他的請求連接,改怎么擴展代碼?最簡單的辦法就是通過循環語句返回調動accept函數,如圖1-9
圖1-9 迭代服務器端的函數調用順序
圖1-9可以看出,調用accept函數后,緊接着調用I/O相關的read、write函數,然后調用close函數。這並非針對服務器端套接字,而是針對accept函數調用時所創建的套接字。調用close函數就意味着結束了針對某一客戶端的服務,此時如果還想服務於其他客戶端,就要重新調用accept函數。目前,我們的服務器端套接字同一時刻只能服務於一個客戶端連接,將來學完進程和線程后,就可以編寫同時服務多個客戶端的服務器端了
迭代回聲服務器端/客戶端
即時服務器端以迭代方式運轉,客戶端代碼亦無太大區別,接下來創建迭代回聲服務器端及與之配套的回聲客戶端,首先整理一下程序的基本運行方式:
- 服務器端在同一時刻只與一個客戶端相連,並提供回聲服務
- 服務器端依次向五個客戶端提供服務並退出
- 客戶端接收用戶輸入的字符串並發送到服務器端
- 服務器端將接收到的字符串回傳給客戶端,即“回聲”
- 服務器端與客戶端之間的字符串回聲一直執行到客戶端輸入Q為止
echo_server.c
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> #define BUF_SIZE 1024 void error_handling(char *message); int main(int argc, char *argv[]) { int serv_sock, clnt_sock; char messag[1024]; int str_len, i; struct sockaddr_in serv_adr, clnt_adr; socklen_t clnt_adr_sz; if (argc != 2) { printf("Usage:%s<port>\n", argv[0]); exit(1); } serv_sock = socket(PF_INET, SOCK_STREAM, 0); if (serv_sock == -1) error_handling("socket()error"); memset(&serv_adr, 0, sizeof(serv_adr)); serv_adr.sin_family = AF_INET; serv_adr.sin_addr.s_addr = htonl(INADDR_ANY); serv_adr.sin_port = htons(atoi(argv[1])); if (bind(serv_sock, (struct sockaddr *)&serv_adr, sizeof(serv_adr)) == -1) error_handling("bind()error"); if (listen(serv_sock, 5) == -1) error_handling("listen()error"); clnt_adr_sz = sizeof(clnt_adr); for (i = 0; i < 5; i++) { clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_adr, &clnt_adr_sz); if (clnt_sock == -1) error_handling("accept()error"); else printf("Connected client %d \n", i + 1); while ((str_len = read(clnt_sock, messag, 1024)) != 0) write(clnt_sock, messag, str_len); close(clnt_sock); } close(serv_sock); return 0; } void error_handling(char *message) { fputs(message, stderr); fputc('\n', stderr); exit(1); }
- 第37~40行:為處理5個客戶端連接而添加的循環語句。共調用五次accept函數,依次向五個客戶端提供服務
- 第44、45行:實際完后回聲服務的代碼,原封不動地傳輸讀取的字符串
- 第46行:針對連接客戶端的套接字調用close函數,向連接的相應套接字發送EOF。換言之,客戶端套接字若調用close函數,則第44行的循環條件變為false,因此執行第46行代碼
- 第48行:向5個客戶端提供服務后關閉服務器端套接字並終止程序
echo_client.c
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> void error_handling(char *message); int main(int argc, char *argv[]) { int sock; char message[1024]; int str_len; struct sockaddr_in serv_adr; if (argc != 3) { printf("Usage:%s<IP><port>\n", argv[0]); exit(1); } sock = socket(PF_INET, SOCK_STREAM, 0); if (sock == -1) error_handling("socket()error"); memset(&serv_adr, 0, sizeof(serv_adr)); serv_adr.sin_family = AF_INET; serv_adr.sin_addr.s_addr = inet_addr(argv[1]); serv_adr.sin_port = htons(atoi(argv[2])); if (connect(sock, (struct sockaddr *)&serv_adr, sizeof(serv_adr)) == -1) error_handling("connect()error"); else puts("Connected.........."); while (1) { fputs("Input message(Q to quit):", stdout); fgets(message, 1024, stdin); if (!strcmp(message, "q\n") || !strcmp(message, "Q\n")) break; write(sock, message, strlen(message)); str_len = read(sock, message, 1024 - 1); message[str_len] = 0; printf("Message from server:%s", message); } close(sock); return 0; } void error_handling(char *message) { fputs(message, stderr); fputc('\n', stderr); exit(1); }
- 第27行:調用connect函數。若調用該函數引起的連接請求被注冊到服務器端等待隊列,則connect函數將完成正常調用。因此,即使通過第30行代碼輸出了連接提示字符串,如果服務器尚未調用accept函數,也不會真正建立服務關系
- 第42行:調用close函數向相應套接字發送EOF(EOF即意味着中斷連接)
編譯echo_server.c並運行,服務器端套接字將等待客戶端連接請求
# gcc echo_server.c -o echo_server # ./echo_server 8500
編譯echo_client.c並分三次運行
# gcc echo_client.c -o echo_client # ./echo_client 127.0.0.1 8500 Connected.......... Input message(Q to quit):Hello Message from server:Hello Input message(Q to quit):world Message from server:world Input message(Q to quit):Q # ./echo_client 127.0.0.1 8500 Connected.......... Input message(Q to quit):Java Message from server:Java Input message(Q to quit):Python Message from server:Python Input message(Q to quit):Golang Message from server:Golang Input message(Q to quit):Q # ./echo_client 127.0.0.1 8500 Connected.......... Input message(Q to quit):Spring Message from server:Spring Input message(Q to quit):Flask Message from server:Flask Input message(Q to quit):Gin Message from server:Gin Input message(Q to quit):Q
最后可看到服務器端套接字程序打印如下:
# ./echo_server 8500 Connected client 1 Connected client 2 Connected client 3
可以看到,服務器端套接字共處理了3次客戶端連接請求
回聲客戶端存在的問題
下面是echo_client.c的代碼
write(sock, message, strlen(message)); str_len = read(sock, message, 1024 - 1); message[str_len] = 0; printf("Message from server:%s", message);
以上的代碼有個錯誤假設:每次調用read、write函數時都會以字符串為單位執行實際的I/O操作。但是別忘了,TCP不存在數據邊界。因此,多次調用write函數傳遞字符串有可能一次性傳遞到服務端,此時,客戶端有可能從服務端收到多個字符串,這不是我們希望看到的結果
還要考慮另外一種情況:字符串太長,需要分兩次數據包發送,客戶端有可能在尚未收到全部數據包時就調用read函數。這些都是TCP特性的問題,我們將在下一章給出解決的辦法