本文分享自華為雲社群《網路通訊的神奇之旅:解密Linux TCP網路協定棧的工作原理》,作者: Lion Long 。
TCP,全稱傳輸控制協定(Transmission Control Protocol),是一種面向連線的、可靠的、基於位元組流的傳輸層通訊協定。
#include <sys/types.h> /* See NOTES */ #include <sys/socket.h> // 1 int socket(int domain, int type, int protocol); // 2 int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); // 3 int listen(int sockfd, int backlog); // 4 ssize_t recv(int sockfd, void *buf, size_t len, int flags); // 5 int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); // 6 ssize_t send(int sockfd, const void *buf, size_t len, int flags); // 7 int close(int fd); // 8 int shutdown(int sockfd, int how);
#include <sys/types.h> /* See NOTES */ #include <sys/socket.h> // 1 int socket(int domain, int type, int protocol); // 2 int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); // 3 int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen); // 4 ssize_t send(int sockfd, const void *buf, size_t len, int flags); // 5 ssize_t recv(int sockfd, void *buf, size_t len, int flags); // 6 int close(int fd); // 7 int shutdown(int sockfd, int how);
(1)int socket(int domain, int type, int protocol)
在檔案系統中分配一個fd,並建立TCB資料結構。
(2)int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen)
為TCP的socket繫結本地IP地址和埠。
(3)int listen(int sockfd, int backlog)
將TCP置於LISTEN狀態。
(4)int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen)
從全連線佇列中取出一個節點,並分配一個fd。
(5)ssize_t recv(int sockfd, void *buf, size_t len, int flags)
在對應fd中,從讀緩衝區中拷貝出資料。
(6)ssize_t send(int sockfd, const void *buf, size_t len, int flags)
把fd對應的TCB資料拷貝到寫緩衝區中。
(7)int close(int fd)
準備一個FIN包,放到寫緩衝區,是否fd。
(8)int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen)
準備一個SYN包,交給協定棧傳送出去,等待三次握手完成後才返回。
TCP連線的建立主要依靠socket()、bind()、listen()、connect()、accept()這幾個函數。
示意圖:
三次握手在kernel協定棧中進行,那麼三次握手是在哪幾個函數中傳送的呢?
第一次,由connect()函數觸發 發起握手,也就是傳送syn包到伺服器端;
第二次,在listen()之後accept()之前,伺服器接收到syn包後傳送syn&&ack包到使用者端;
第三次,使用者端傳送ack包到伺服器端完成連線的建立。
TCP報頭:
0 |1 |2 |3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-------------------------------+-------------------------------+ | Source Port | Destination Port | +---------------------------------------------------------------+ | Sequence Number | +---------------------------------------------------------------+ | Acknowledgment Number | +-------+-----------+-+-+-+-+-+-+-------------------------------+ | Header| Reserve |U|A|P|R|S|F| Window | | Length| |R|C|S|S|Y|I| | | | |G|K|H|T|N|N| | +-------------------------------+-------------------------------+ | Checksum | Urgent Pointer | +---------------------------------------------------------------+ | Option | +---------------------------------------------------------------+ | Data | | ... | +---------------------------------------------------------------+
從報文中可以看出,SYN包最重要的是將SYN位設為1,設定Sequence Number;ACK包最重要的是將ACK位設為1,設定Acknowledgment Number。
半連線佇列和全連線佇列:
在三次握手中,Linux kener 協定棧會維護兩個佇列:半連線佇列和全連線佇列。
半連線佇列(也叫SYN佇列): 半連線佇列在第一握手中,當用戶端傳送SYN包到伺服器端時,伺服器端的半連線佇列會加入一個節點,表示此連線處於半連線狀態。
全連線佇列(也叫ACCEPT佇列): 全連線佇列在第三握手中,當用戶端傳送ACK包到伺服器端時,伺服器端會檢查半連線佇列中是否存在此連線節點(通過五元組進行查詢),如果存在就將此連線節點加入全連線佇列中;否則將拋棄此連線。
accpt()函數在三次握手完成後,從全連線佇列中取出連線節點,為節點分配socket fd,返回到使用者態。
那麼,accept()函數如何知道全連線佇列中有節點呢?
當三次握手完成後,全連線佇列建立節點的同時會釋放一個有連線接入的訊號(single或號誌),這個訊號決定了accept()函數是否可以從全連線佇列中取節點;也決定epoll等IO多路複用器能不能檢查這個連線fd是否可讀。
在阻塞模式下,accept()函數一直等待訊號,直到全連線佇列中有節點才返回。
在非阻塞模式下,全連線佇列為空accept()函數就返回-1,否則返回socket fd。
在listen()函數有,有一個backlog引數,這個參數列示的是全連線佇列的大小還是半連線佇列的大小呢?
隨著TCP協定的不斷迭代,backlog引數在不同的版本中代表的含義也不相同;它可以是半連線佇列大小,也可以是全連線佇列大小,也可以是半連線佇列+全連線佇列的大小總和。不過,效果不會有太大差異。目前版本中主要表示全連線佇列的大小。
DDOS攻擊:
根據三次握手原理,產生一種對伺服器的攻擊方式:DDOS攻擊。所謂DDOS攻擊,就是使用者端偽造一些不存在的IP,一直傳送SYN包,使伺服器的半連線佇列不斷增大,當半連線佇列的大小達到極限時,造成網路阻塞就會導致伺服器無法再接受連線,從而使伺服器奔潰。
TCP狀態轉換圖:
(1)從狀態轉換圖看出,LISTEN狀態可以通過傳送SYN和資料轉換到SYN_SEND狀態;也就是LISTEN狀態可以傳送資料。
(2)SYN_SEND狀態可以收到SYN,並行送SYN和ACK轉換到SYN_RECV狀態;也就是兩個裝置可以互發SYN包,建立連線。
TCP傳輸資料主要依靠send()和recv()兩個函數。
使用send()函數傳送資料時,返回正數不一定代表傳送成功。因為send()函數僅僅只是將資料拷貝到協定棧的寫緩衝區,由協定棧傳送;傳送過程中會經過N個閘道器,可能存在丟包或鏈路斷開導致未能傳送到目的地。如果要知道資料是否傳送成功,需要加上確認機制(ACK)。
為了保證資料能正確分發,TCP使用一種TCB(傳輸控制塊)的資料結構,把傳送給不同裝置的資料封裝起來。這個TCB會存在整個TCP週期,知道斷開連線。
一個TCB資料塊包含資料傳送雙方對應的socket資訊以及擁有存放資料的緩衝區。建立連線連線傳送資料之前,通訊雙方必須做一個準備工作:分配記憶體建立TCB資料塊。當雙方準備好自己的socket和TCB資料結構後,就可以進入「三次握手」建立連線。
TCP分包就是要傳輸的資料很大,超出傳送快取區剩餘空間,將會進行分包;待傳送的資料大於最大報文長度,TCP在傳輸前將進行分包。
分包在應用程式的處理一般是傳送回圈send(),接收方迴圈recv()。
TCP粘包就是傳送方傳送的若干封包到接收方接收時粘成一個包,從接收緩衝區看就是後封包的頭緊接著前封包的尾。
常見解決方案:
(1)(推薦)應用層協定頭前面新增包長度。分兩次接收資料;第一次先接收包的長度,然後根據包的長度一次性讀取或迴圈讀取資料。
例如:
// ... ssize count=0; ssize size=0; while(count<tcpHdr->length) { size=recv(fd,buffer,buffersize,0); count+=size; } // ...
(2)為每個包新增分隔符。在資料末尾新增分隔符,這會導致解資料可能需要有合包操作;因為分割封包後,需要記錄後一個封包,用於與該包後面部分資料進行合併。
斷開連線是比建立連線和傳輸資料還複雜的一個過程,斷開連線主要分為主動關閉和被動關閉兩種。
四次揮手示意圖:
需要注意的是,呼叫close()不是立即完成斷開,而是關閉了資料傳輸,進入了四次揮手階段,TCB資料結構還沒有釋放。四次揮手結束才真正把TCB釋放。
根據四次揮手流程,可以思考一些問題:
(1)傳輸資料過程中,網線斷了之後立刻連線,TCP如何知道?
網線掉線網路卡會停止供電,再次連線後網路卡恢復供電,網路卡服務重啟,網路連線重連。應用程式設計通過心跳包檢測。
(2)伺服器如何知道使用者端是否宕機?
一樣需要通過心跳包機制來檢測。
(3)伺服器如何甄別網路阻塞和宕機?
伺服器傳送心跳包時,不僅僅發一次,而是要傳送多次的;如果是網路阻塞,那麼在一定時間內一定有回覆資訊;如果是宕機,無論多長時間都沒有使用者端的回覆。
(4)如果出現大量的CLOSING狀態,如何處理?
出現大量CLOSING狀態,基本上業務上要處理的邏輯過多,導致一直在CLOSING狀態;可以使用非同步,將網路層和業務層分離,單獨處理。
(5)四次揮手中,為什麼存在TIME_WAIT狀態?
防止沒有LAST_ACK或LAST_ACK丟失,導致一直重發已經不存在的socket。
需要掌握TCP三次握手和四次揮手的過程,熟悉TCP狀態轉換。清楚什麼是SYN包和ACK包。
(1)三次握手是 由使用者端發起SYN,伺服器端收到SYN後傳送SYN和ACK,使用者端回覆ACK;完成連線的建立。
(2)斷開連線主要有主動斷開和被動斷開。
(3)四次揮手是 由發起方呼叫close(),同時傳送FIN包;接收端接收到FIN包返回ACK包,接收端傳送FIN包;發起方接收到FIN包返回ACK包;完成斷開。
(4)理解TCP的狀態轉換圖。LISTEN狀態到SYN_RCVD狀態和SYN_SEND狀態,如何進入ESTABLISHED狀態;四次揮手FIN_WAIT_1、FIN_WAIT_2、TIME_WAIT、CLOSING直接的轉換,CLOSE_WAIT和LAST_ACK的處理等。
(5)理解API的底層原理,以及全連線佇列和半連線佇列。
(6)TCP的分包場景以及TCP粘包的處理方式。
TCP通訊完整過程: