值得收藏的TCP套介面程式設計文章

騰訊雲加社群發表於2018-10-12

歡迎大家前往騰訊雲+社群,獲取更多騰訊海量技術實踐乾貨哦~

本文由jackieluo發表於雲+社群專欄

TCP客戶端-伺服器典型事件

下圖是TCP客戶端與伺服器之間互動的一系列典型事件時間表:

  1. 首先啟動伺服器,等待客戶端連線
  2. 啟動客戶端,連線到伺服器
  3. 客戶端傳送一個請求給伺服器,伺服器處理請求,響應客戶端
  4. 迴圈步驟3
  5. 客戶端給伺服器發一個檔案結束符,關閉客戶端連線
  6. 伺服器也關閉連線

img
基本TCP客戶-伺服器程式的套介面函式

套介面程式設計基本函式

socket 函式

為了執行網路I/O,一個程式(無論是服務端還是客戶端)必須做的第一件事情就是呼叫socket函式。

#include <sys/socket.h> /* basic socket definitions */
int socket(int family, int type, int protocol);/* 返回:非負描述字——成功,-1——出錯 */
複製程式碼
  • family——協議族
解釋
AF_INET IPv4協議
AF_INET6 IPv6協議
AF_LOCAL Unix域協議
AF_ROUTE 路由套介面
AF_KEY 金鑰套介面
  • type——套介面型別
型別 解釋
SOCK_STREAM 位元組流套介面
SOCK_DGRAM 資料包套介面
SOCK_RAW 原始套介面

下面是有效的familytype組合(簡略版):

AF_INET AF_INET6
SOCK_STREAM TCP TCP
SOCK_DGRAM UDP UDP
SOCK_RAW IPv4 IPv6

socket函式返回一個套介面描述字,簡稱套接字(sockfd)。獲取套接字無需指定地址,只需要指定協議族和套介面型別(如上表中的組合)。

connect函式

TCP客戶用connect函式來建立一個與TCP伺服器的連線。

#include <sys/socket.h> /* basic socket definitions */
int connect(int sockfd, const struct sockaddr * servaddr, socklen_t addrlen);/* 返回:0——成功,-1——出錯 */
複製程式碼
  • 引數sockfd便是socket函式返回的套介面描述字。
  • 套介面地址結構servaddr必須包含伺服器的IP地址和埠號。
  • 客戶端不必非要繫結一個埠(呼叫bind函式),核心會選擇源IP和一個臨時埠。
  • connect函式會觸發TCP三次握手。有可能出現下面的錯誤情況:

1.客戶端未收到SYN分節的響應

第一次發出未收到,間隔6s再發一次,再沒收到,隔24秒再發一次,總共等待75s還沒收到則返回錯誤( ETIMEDOUT)。可以用時間日期程式驗證一下:

檢視本地網路資訊:

JACKIELUO-MC0:intro jackieluo$ ifconfig
en0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
	ether f4:0f:24:2a:72:a6
	inet6 fe80::1830:dbd:1b29:2989%en0 prefixlen 64 secured scopeid 0x6
	inet 192.168.0.101 netmask 0xffffff00 broadcast 192.168.0.255
	nd6 options=201<PERFORMNUD,DAD>
	media: autoselect
	status: active
複製程式碼

將程式指向本地地址192.168.0.101(確保時間日期伺服器程式已執行),成功:

JACKIELUO-MC0:intro jackieluo$ ./daytimetcpcli 192.168.0.101
Sat Oct  6 17:06:55 2018
複製程式碼

將程式指向本地子網地址192.168.0.102,其主機ID(102)不存在,等待幾分鐘後超時返回:

JACKIELUO-MC0:intro jackieluo$ ./daytimetcpcli 192.168.0.102
connect error: Operation timed out
複製程式碼

2.收到RST

即伺服器主機在指定埠上沒有等待連線的程式,這稱為“hard error”,客戶端一接收到RST,馬上返回錯誤(ECONNREFUSED)。驗證:

關閉之前本機執行的daytimetcpsrv程式

將程式指向本地地址192.168.0.101

JACKIELUO-MC0:intro jackieluo$ ./daytimetcpcli 192.168.0.101
connect error: Connection refused
複製程式碼

3.發出的SYN在路由器上引發了目的不可達ICMP錯誤

這個錯誤被稱為“soft error”,最終返回EHOSTUNREACH或者ENETUNREACH

bind函式

函式bind為套介面分配一個本地協議地址,包括IP地址和埠號。

#include <sys/socket.h> /* basic socket definitions */
int bind(int sockfd, const struct sockaddr * servaddr, socklen_t addrlen);/* 返回:0——成功,-1——出錯 */
複製程式碼
  • 客戶端可以不呼叫這個函式,由核心選擇一個本地ip的臨時埠就好。
  • 伺服器一般都會呼叫bind函式繫結ip地址和埠,供客戶端呼叫。一個例外是RPC(遠端過程呼叫)伺服器,它由核心為其選擇臨時埠。然後通過RPC埠對映器進行註冊,客戶端與該伺服器連線之前,先通過埠對映器獲取伺服器的埠。
  • 程式可以把一個特定的IP地址捆綁到它的套介面上。對於客戶端,它傳送的請求,源IP地址就是這個地址;對於伺服器,如果繫結了IP地址,則只接受目的地為此IP地址的客戶連線。
  • 如果伺服器不把IP地址繫結到套介面上,那麼核心把客戶端傳送SYN所在分組的目的IP地址作為伺服器的源IP地址。(即伺服器收到SYN的IP)

給函式bind指定用於捆綁的IP地址和/或埠號的結果:

IP地址 結果
0 核心選擇IP地址和埠
非0 核心選擇IP地址,程式指定埠
本地IP地址 0 程式選擇IP地址,核心指定埠
本地IP地址 非0 程式選擇IP地址和埠

listen函式

函式listen僅被TCP伺服器呼叫。

#include <sys/socket.h> /* basic socket definitions */
int listen(int sockfd, int backlog);/* 返回:0——成功,-1——出錯 */
複製程式碼

呼叫函式socket函式建立的套介面,預設是主動方,下一步應是呼叫connectCLOSED的下一個狀態是SYN_SENT(見TCP狀態轉換圖)。而函式listen將套介面轉換成被動方,告訴核心,應接受指向此套介面的連線請求,CLOSED狀態變成LISTEN

函式listen的第二個引數backlog表示核心為此套介面排隊的最大連線數。對於給定的監聽套介面,核心會維護兩個佇列:

  1. 未完成連線佇列(incomplete connection queue) SYN分節已由客戶發出,到達伺服器,正在進行TCP的三路握手。此時這些套介面處於SYN_RCVD狀態。

  2. 已完成連線佇列(completed connection queue) SYN分節已由客戶發出,到達伺服器,並且已完成三路握手。此時這些套介面處於ESTABLISHED狀態。

  3. 當來自客戶的SYN到達時,TCP在未完成連線佇列中建立一個新條目,直到三路握手中,第三個分節(客戶對服務SYN的ACK)到達,這個條目移到已完成連線佇列的隊尾。

  4. 當程式呼叫accept函式時,已完成連線佇列的頭部條目返回給程式。

  5. 兩個佇列之和不能超過backlog

  6. 當一個客戶SYN到達時,若這兩個佇列都是滿的,TCP就忽略此分節,且不傳送RST。客戶TCP將重發SYN,期望不久就能在佇列中找到空閒位置。

    img
    TCP為監聽套介面維護的兩個佇列

accept函式

函式accept由TCP伺服器呼叫,從已完成連線佇列頭部返回下一個已完成連線,若該佇列為空,則程式睡眠(假定套介面為預設的阻塞方式)。

#include <sys/socket.h> /* basic socket definitions */
int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);/* 返回:非負描述字——成功,-1——出錯 */
複製程式碼

函式accept的第一個引數和返回值都是套介面描述字。其中,

  1. 第一個引數,稱為監聽套介面描述字,即由函式socket返回,也用於bindlisten的第一個引數。
  2. 返回值,稱為已連線套介面描述字。

通常一個伺服器,只生成一個監聽套介面描述字,直到其關閉。而核心為每個被接受的客戶連線,建立一個已連線套介面,當客戶連線完成時,關閉該已連線套介面。

注意到intro/daytimetcpsrv.c中,後兩個引數傳的都是空指標,這是因為我們不關注客戶的身份,無需知道客戶的協議地址。

connfd = Accept(listenfd, (SA *) NULL, NULL);
複製程式碼

稍作修改,不再傳入空指標,見intro/daytimetcpsrv1.c

socklen_t len;
struct sockaddr_in servaddr, cliaddr;
...
connfd = Accept(listenfd, (SA *) &cliaddr, &len);
printf("connection from %s, port %d\n",
    Inet_ntop(AF_INET, &cliaddr.sin_addr, buff, sizeof(buff)),
    ntohs(cliaddr.sin_port));
複製程式碼

kill掉之前的daytimetcpsrv程式:

$ sudo lsof -i -P | grep -i "listen"
daytimetc 80986           root    3u  IPv4 0xae12d925e4528793      0t0    TCP *:13 (LISTEN)
$ sudo kill -9 80986
複製程式碼

編譯執行新的服務端程式:

$ make daytimetcpsrv1.c daytimetcpsrv1
$ ./daytimetcpsrv1
複製程式碼

重複執行客戶端程式,發幾個請求:

$ ./daytimetcpcli 127.0.0.1
Wed Sep 26 14:11:20 2018
$ ./daytimetcpcli 127.0.0.1
Wed Sep 26 14:17:06 2018
複製程式碼

檢視服務端列印:

connection from 127.0.0.1, port 58201
connection from 127.0.0.1, port 58342
複製程式碼

注意到,由於客戶端程式沒有呼叫bind函式,核心為它的協議地址選擇了源ip作為IP地址,臨時埠號也發生了變化。

fork和exec函式

#include <unistd.h>
pid_t fork(void);/* 返回:在子程式中為0,在父程式中為子程式ID,-1——出錯 */
複製程式碼

fork函式呼叫一次,卻返回兩次。

  1. 在呼叫它的程式(即父程式),它返回一次,返回值是派生出來的子程式的程式ID。 父程式可能有很多子程式,必須通過返回值跟蹤記錄子程式ID。
  2. 在子程式,它還返回一次,返回值為0。 子程式只有一個父程式,總可以通過getppid來得到父程式的ID

通過返回值可以判斷當前程式是子程式還是父程式。

父程式在呼叫fork之前開啟的所有描述字在函式fork返回後都是共享的。網路伺服器會利用這一特性:

  1. 父程式呼叫accept
  2. 父程式呼叫fork,已連線套介面就在父程式與子程式間共享。(一般來說就是子程式讀、寫已連線套介面,而父程式關閉已連線套介面)。

fork有兩個典型應用:

  1. 一個程式為自己派生一個拷貝,併發執行任務,這也是典型的併發網路伺服器模型。
  2. 一個程式想執行其他的程式,於是呼叫fork生成一個拷貝,利用子程式呼叫exec來執行新的程式。典型應用是shell。

以檔案形式儲存在硬碟上的可執行程式若要被執行,需要由一個現有程式呼叫exec函式。我們將呼叫exec的程式稱為呼叫程式,新程式的程式ID並不改變,仍處於當前程式。

小結

客戶和伺服器,從呼叫socket開始,返回一個套介面描述字。客戶呼叫connect,伺服器呼叫bindlistenaccept。最後套介面由close關閉。

多數TCP伺服器是呼叫fork來實現併發處理多客戶請求的。多數UDP伺服器則是迭代的。

相關閱讀

系統重啟後nginx reload不生效原因分析

SRS開源直播服務 - StateThreads微執行緒框架學習

高效能網路程式設計3----TCP訊息的接收

【每日課程推薦】機器學習實戰!快速入門線上廣告業務及CTR相應知識

此文已由作者授權騰訊雲+社群釋出,更多原文請點選

搜尋關注公眾號「雲加社群」,第一時間獲取技術乾貨,關注後回覆1024 送你一份技術課程大禮包!

海量技術實踐經驗,盡在雲加社群

相關文章