Linux 高性能網絡編程 TCP 底層的收發過程

談完上一篇《Linux 高性能網絡編程十談 | 網絡篇》,我們繼續探索高性能網絡編程,但是我覺得在談系統 API 之前可以先講一些 Linux 底層的收發包過程,如下這是一個簡單的 socket 編程代碼:

第一部分:如何建立連接

從上一篇文章我們介紹了網絡協議,我們知道 TCP/IP 協議族劃分了應用層、TCP 傳輸層、IP 網絡層、鏈路層(以太層驅動)。

如上圖看應用層,通常在網絡編程中我們需要調用accept的 API 建立 TCP 連接,那 TCP 如何做的呢?

從上圖的流程可以看到:
(1)client 端發起 TCP 握手,發送 syn 包;
(2)內核收到包以後先將當前連接的信息插入到網絡的 SYN 隊列;
(3)插入成功後會返回握手確認(SYN+ACK);
(4)client 端如果繼續完成 TCP 握手,回覆 ACK 確認;
(5)內核會將 TCP 握手完成的包,先將對應的連接信息從 SYN 隊列取出;
(6)將連接信息丟入到 ACCEPT 隊列;
(7)應用層 sever 通過系統調用accept就能拿到這個連接,整個網絡套接字連接完成;

那基於這個圖,我想問問讀者這裏會有什麼問題麼?
細心的讀者應該可以看出:
1、這裏有兩個隊列,必然會有滿的情況,那如果遇到這種情況內核是怎麼處理的呢? 

(1)如果 SYN 隊列滿了,內核就會丟棄連接;
(2)如果 ACCEPT 隊列滿了,那內核不會繼續將 SYN 隊列的連接丟到 ACCEPT 隊列,如果 SYN 隊列足夠大,client 端後續收發包就會超時;
(3)如果 SYN 隊列滿了,就會和(1)一樣丟棄連接;

2、如何控制 SYN 隊列和 ACCEPT 隊列的大小?

(1)內核 2.2 版本之前通過listen的 backlog 可以設置 SYN 隊列(半連接狀態 SYN_REVD)和 ACCEPT 隊列(完全連接狀態 ESTABLISHED)的上限;
(2)內核 2.2 版本以後 backlog 只是表示 ACCEPT 隊列上限,SYN 隊列的上限可以通過/proc/sys/net/ipv4/tcp_max_syn_backlog設置;

3、server 端通過accept一直等,豈不是會卡住收包的線程?

在 linux 網絡編程中我們都會追求高性能,accept如果卡住接收線程,性能會上不去,所以socket編程中就會有阻塞和非阻塞模式。
(1)阻塞模式下的accept就會卡住,當前線程什麼事情都幹不了;
(2)非阻塞模式下,可以通過輪詢accept去處理其他的事情,如果返回 EAGAIN,就是 ACCEPT 隊列爲空,如果返回連接信息,就是可以處理當前連接;

第二部分:接收數據

(1)當網卡接收到報文並判斷爲 TCP 協議後,將會調用到內核的tcp_v4_rcv方法,如果數據按順序收到S1數據包,則直接插入 receive 隊列中;
(2)當收到了S3數據包,在第 1 步結束後,應該收到S2序號,但是報文是亂序進來的,則將S3插入 out_of_order 隊列(這個隊列存儲亂序報文);
(3)接下來收到S2數據包,如第 1 步直接進入 receive 隊列,由於此時 out_of_order 隊列不像第 1 步是空的,所以引發了接來的第 4 步;
(4)每次向 receive 隊列插入報文時都會檢查 out_of_order 隊列,如果遇到期待的序號S3,則從 out_of_order 隊列摘除,寫入到 receive 隊列;
(5)現在應用程序開始調用recv方法;
(6)經過層層封裝調用,接收 TCP 消息最終會走到tcp_recvmsg方法;
(7)現在需要拷貝數據從內核態到用戶態,如果 receive 隊列爲空,會先檢查 SO_RCVLOWAT 這個閥值(0 表示收到指定的數據返回,1 表示只要讀取到數據就返回,系統默認是 1),如果已經拷貝的字節數到現在還小於它,那麼可能導致進程會休眠,等待拷貝更多的數據;
(8)將數據從內核態拷貝到用戶態,recv返回拷貝數據的大小;
(9)爲了選擇降低網絡包延時或者提升吞吐量,系統提供了tcp_low_latency參數,如果爲 0 值,用戶暫時沒有讀數據則數據包進入 prequeue 隊列,提升吞吐量,否則不使用 prequeue 隊列,進入tcp_v4_do_rcv,降低延時;

第三部分:發送數據

(1)假設調用send方法來發送大於一個 MSS(比如 2K)的數據;
(2)內核調用tcp_sendmsg,實現複製數據,寫入隊列和組裝 tcp 協議頭;
(3)在調用tcp_sendmsg先需要在內核獲取 skb,將用戶態數據拷貝到內核態,內核真正執行報文的發送,與send方法的調用並不是同步的,即send方法返回成功,也不一定把 IP 報文都發送到網絡中了。因此,需要把用戶需要發送的用戶態內存中的數據,拷貝到內核態內存中,不依賴於用戶態內存,也使得進程可以快速釋放發送數據佔用的用戶態內存。但這個拷貝操作並不是簡單的複製,而是把待發送數據,按照 MSS 來劃分成多個儘量達到 MSS 大小的分片報文段,複製到內核中的 sk_buff 結構來存放;
(4)將數據拷貝到發送隊列中tcp_write_queue
(5)調用tcp_push發送數據到 IP 層,這裏主要滑動窗口,慢啓動,擁塞窗口的控制和判斷是否使用 Nagle 算法合併小報文(上一篇已經有介紹);
(6)組裝 IP 報文頭,通過經過iptables或者tcpdump等 netfilter 模塊過濾,將數據交給鄰居子系統(主要功能是查找需要發送的 MAC 地址,發送 arp 請求,封裝 MAC 頭等);
(7)調用網卡驅動程序將數據發送出去;

第四部分:關閉連接

關閉連接就是 TCP 揮手過程,我們都知道 TCP 連接是一種可靠的連接,那如何才能完整可靠的完成關閉連接呢?linux 系統提供了兩個函數:

(1)shutdown 可攜帶一個參數,取值有 3 個,分別意味着:只關閉讀、只關閉寫、同時關閉讀寫;
(2)若 shutdown 的是半打開的連接,則發出 RST 來關閉連接;
(3)若 shutdown 的是正常連接,那麼關閉讀其實與對端是沒有關係的;
(4)若參數中有標誌位爲關閉寫,那麼下面做的事與 close 是一致的,發出 FIN 包,告訴對方本機不會再發消息了;

第五部分:思考題

基於本文留幾個思考題,下一篇文章解答。

(1)發送方法返回成功後,數據一定發送到了 TCP 的對端麼?
(調用了 IP 層的方法返回後,也未必就保證此時數據一定發送成功)
(2)1 個 socket 套接字可能被多個進程在使用,出現併發訪問時,內核是怎麼處理這種狀況的?
(3)若 socket 爲默認的阻塞套接字,調用recv方法傳入的 len 參數,如果網絡包的數據小於 len,recv會返回麼?
(4)當 socket 被多進程或者多線程共享時,關閉連接時有何區別?

參考

(1)《TCP/IP 詳解》
(2)https://www.taohui.pub/
(3)《深入理解 linux 網絡》

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/QAOjcURlQ-RVwVgG5DAykw