TCP/IP的底層隊列

 自從上次學習了TCP/IP的擁塞控制算法後,我愈加想要更加深刻的瞭解TCP/IP的一些底層原理,搜索了不少網絡上的資料,看到了陶輝大神關於高性能網絡編程的專欄,收益頗多。今天就總結一下,而且加上本身的一些思考。html

 我本身比較瞭解Java語言,對Java網絡編程的理解就止於Netty框架的使用。Netty的源碼貢獻者Norman Maurer對於Netty網絡開發有過一句建議,"Never block the event loop, reduce context-swtiching"。也就是儘可能不要阻塞IO線程,也儘可能減小線程切換。咱們今天只關注前半句,對這句話感興趣的同窗能夠看一下[螞蟻通訊框架實踐
](https://mp.weixin.qq.com/s/JR...react

 爲何不能阻塞讀取網絡信息的IO線程呢?這裏就要從經典的網絡C10K開始理解,服務器如何支持併發1萬請求。C10K的根源在於網絡的IO模型。Linux 中網絡處理都用同步阻塞的方式,也就是每一個請求都分配一個進程或者線程,那麼要支持1萬併發,難道就要使用1萬個線程處理請求嘛?這1萬個線程的調度、上下文切換乃至它們佔用的內存,都會成爲瓶頸。解決C10K的通用辦法就是使用I/O 多路複用,Netty就是這樣。linux

Netty的reactor模型

 Netty有負責服務端監聽創建鏈接的線程組(mainReactor)和負責鏈接讀寫操做的IO線程組(subReactor),還能夠有專門處理業務邏輯的Worker線程組(ThreadPool)。三者相互獨立,這樣有不少好處。一是有專門的線程組負責監聽和處理網絡鏈接的創建,能夠防止TCP/IP的半鏈接隊列(sync)和全鏈接隊列(acceptable)被佔滿。二是IO線程組和Worker線程分開,雙方並行處理網絡I/O和業務邏輯,能夠避免IO線程被阻塞,防止TCP/IP的接收報文的隊列被佔滿。固然,若是業務邏輯較少,也就是IO 密集型的輕計算業務,能夠將業務邏輯放在IO線程中處理,避免線程切換,這也就是Norman Maurer話的後半部分。git

 TCP/IP怎麼就這麼多隊列啊?今天咱們就來細看一下TCP/IP的幾個隊列,包括創建鏈接時的半鏈接隊列(sync),全鏈接隊列(accept)和接收報文時的receive、out_of_order、prequeue以及backlog隊列。github

創建鏈接時的隊列

TCP三次握手和隊列示意圖

 如上圖所示,這裏有兩個隊列:syns queue(半鏈接隊列)和accept queue(全鏈接隊列)。三次握手中,服務端接收到客戶端的SYN報文後,把相關信息放到半鏈接隊列中,同時回覆SYN+ACK給客戶端。
 第三步的時候服務端收到客戶端的ACK,若是這時全鏈接隊列沒滿,那麼從半鏈接隊列拿出相關信息放入到全鏈接隊列中,不然按tcp_abort_on_overflow的值來執行相關操做,直接拋棄或者過一段時間在重試。算法

接收報文時的隊列

 相比於創建鏈接,TCP在接收報文時的處理邏輯更爲複雜,相關的隊列和涉及的配置參數更多。編程

 應用程序接收TCP報文和程序所在服務器系統接收網絡裏發來的TCP報文是兩個獨立流程。兩者都會操控socket實例,可是會經過鎖競爭來決定某一時刻由誰來操控,由此產生不少不一樣的場景。例如,應用程序正在接收報文時,操做系統經過網卡又接收到報文,這時該如何處理?若應用程序沒有調用read或者recv讀取報文時,操做系統收到報文又會如何處理?服務器

 咱們接下來就以三張圖爲主,介紹TCP接收報文時的三種場景,並在其中介紹四個接收相關的隊列。微信

接收報文場景一

場景一

上圖是TCP接收報文場景一的示意圖。操做系統首先接收報文,存儲到socket的receive隊列,而後用戶進程再調用recv進行讀取。網絡

1) 當網卡接收報文而且判斷爲TCP協議時,通過層層調用,最終會調用到內核的tcp_v4_rcv方法。因爲當前TCP要接收的下一個報文正是S1,因此tcp_v4_rcv函數將其直接加入到receive隊列中。receive隊列是將已經接收到的TCP報文,去除了TCP頭部、排好序放入的、用戶進程能夠直接按序讀取的隊列。因爲socket不在用戶進程上下文中(也就是沒有用戶進程在讀socket),而且咱們須要S1序號的報文,而剛好收到了S1報文,所以,它進入了receive隊列。

2) 接收到S3報文,因爲TCP要接收的下一個報文序號是S2,因此加入到out_of_order隊列,全部亂序的報文會放在這裏。

3) 接着,收到了TCP指望的S2報文,直接進入recevie隊列。因爲此時out_of_order隊列不爲空,須要檢查一下。

4) 每次向receive隊列插入報文時都會檢查out_of_order隊列,因爲接收到S2報文後,指望的的序號爲S3,因此out_of_order隊列中的S3報文會被移到receive隊列。

5) 用戶進程開始讀取socket,先在進程中分配一塊內存,而後調用read或者recv方法。socket有一系列的具備默認值的配置屬性,好比socket默認是阻塞式的,它的SO_RCVLOWAT屬性值默認爲1。固然,recv這樣的方法還會接收一個flag參數,它能夠設置爲MSG_WAITALLMSG_PEEKMSG_TRUNK等等,這裏咱們假定爲最經常使用的0。進程調用了recv方法。

6) 調用tcp_recvmsg方法

7) tcp_recvmsg方法會首先鎖住socket。socket是能夠被多線程使用的,並且操做系統也會使用,因此必須處理併發問題。要操控socket,就先獲取鎖。

8) 此時,receive隊列已經有3個報文了,將第一個報文拷貝到用戶態內存中,因爲第五步中socket的參數並無帶MSG_PEEK,因此將第一個報文從隊列中移除,從內核態釋放掉。反之,MSG_PEEK標誌位會致使receive隊列不會刪除報文。因此,MSG_PEEK主要用於多進程讀取同一套接字的情形。

9) 拷貝第二個報文,固然,執行拷貝前都會檢查用戶態內存的剩餘空間是否足以放下當前這個報文,不夠時會直接返回已經拷貝的字節數。
10) 拷貝第三個報文。
11) receive隊列已經爲空,此時會檢查SO_RCVLOWAT這個最小閾值。若是已經拷貝字節數小於它,進程會休眠,等待更多報文。默認的SO_RCVLOWAT值爲1,也就是讀取到報文就能夠返回。

12) 檢查backlog隊列,backlog隊列是用戶進程正在拷貝數據時,網卡收到的報文會進這個隊列。若是此時backlog隊列有數據,就順帶處理下。backlog隊列是沒有數據的,所以釋放鎖,準備返回用戶態。

13) 用戶進程代碼開始執行,此時recv等方法返回的就是從內核拷貝的字節數。

接收報文場景二

 第二張圖給出了第二個場景,這裏涉及了prequeue隊列。用戶進程調用recv方法時,socket隊列中沒有任何報文,而socket是阻塞的,因此進程睡眠了。而後操做系統收到了報文,此時prequeue隊列開始產生做用。該場景中,tcp_low_latency爲默認的0,套接字socket的SO_RCVLOWAT是默認的1,仍然是阻塞socket,以下圖。

場景二

 其中1,2,3步驟的處理和以前同樣。咱們直接從第四步開始。

4) 因爲此時receive,prequeuebacklog隊列都爲空,因此沒有拷貝一個字節到用戶內存中。而socket的配置要求至少拷貝SO_RCVLOWAT也就是1字節的報文,所以進入阻塞式套接字的等待流程。最長等待時間爲SO_RCVTIMEO指定的時間。socket在進入等待前會釋放socket鎖,會使第五步中,新來的報文再也不只能進入backlog隊列。
5) 接到S1報文,將其加入prequeue隊列中。
6) 插入到prequeue隊列後,會喚醒在socket上休眠的進程。
7) 用戶進程被喚醒後,從新獲取socket鎖,此後再接收到的報文只能進入backlog隊列。
8) 進程先檢查receive隊列,固然仍然是空的;再去檢查prequeue隊列,發現有報文S1,正好是正在等待序號的報文,因而直接從prequeue隊列中拷貝到用戶內存,再釋放內核中的這個報文。
9) 目前已經拷貝了一個字節的報文到用戶內存,檢查這個長度是否超過了最低閾值,也就是len和SO_RCVLOWAT的最小值。
10) 因爲SO_RCVLOWAT使用了默認值1,拷貝字節數大於最低閾值,準備返回用戶態,順便會查看一下backlog隊列中是否有數據,此時沒有,因此準備放回,釋放socket鎖。
11) 返回用戶已經拷貝的字節數。

接收報文場景三

 在第三個場景中,系統參數tcp_low_latency爲1,socket上設置了SO_RCVLOWAT屬性值。服務器先收到報文S1,可是其長度小於SO_RCVLOWAT。用戶進程調用recv方法讀取,雖然讀取到了一部分,可是沒有到達最小閾值,因此進程睡眠了。與此同時,在睡眠前接收的亂序的報文S3直接進入backlog隊列。而後,報文S2到達,因爲沒有使用prequeue隊列(由於設置了tcp_low_latency),而它起始序號正是下一個待拷貝的值,因此直接拷貝到用戶內存中,總共拷貝字節數已知足SO_RCVLOWAT的要求!最後在返回用戶前把backlog隊列中S3報文也拷貝給用戶。

場景三

1) 接收到報文S1,正是準備接收的報文序號,所以,將它直接加入到有序的receive隊列中。
2) 將系統屬性tcp_low_latency設置爲1,代表服務器但願程序可以及時的接收到TCP報文。用戶調用的recv接收阻塞socket上的報文,該socket的SO_RCVLOWAT值大於第一個報文的大小,而且用戶分配了足夠大的長度爲len的內存。
3) 調用tcp_recvmsg方法來完成接收工做,先鎖住socket。
4) 準備處理內核各個接收隊列中的報文。
5) receive隊列中有報文能夠直接拷貝,其大小小於len,直接拷貝到用戶內存。
6) 在進行第五步的同時,內核又接收到S3報文,此時socket被鎖,報文直接進入backlog隊列。這個報文並非有序的。
7) 在第五步時,拷貝報文S1到用戶內存,它的大小小於SO_RCVLOWAT的值。因爲socket是阻塞型,因此用戶進程進入睡眠狀態。進入睡眠前,會先處理backlog隊列的報文。由於S3報文是失序的,因此進入out_of_order 隊列。用戶進程進入休眠狀態前都會先處理一下backlog隊列。
8) 進程休眠,直到超時或者receive隊列不爲空。
9) 內核接收到報文S2。注意,此時因爲打開了tcp_low_latency標誌位,因此報文是不會進入prequeue隊列等待進程處理。
10) 因爲報文S2正是要接收的報文,同時,一個用戶進程在休眠等待該報文,因此直接將報文S2拷貝到用戶內存。
11) 每處理完一個有序報文後,不管是拷貝到receive隊列仍是直接複製到用戶內存,都會檢查out_of_order隊列,看看是否有報文能夠處理。報文S3拷貝到用戶內存,而後喚醒用戶進程。
12) 喚醒用戶進程。
13) 此時會檢查已拷貝的字節數是否大於SO_RCVLOWAT,以及backlog隊列是否爲空。二者皆知足,準備返回。

 總結一下四個隊列的做用。

  • receive隊列是真正的接收隊列,操做系統收到的TCP數據包通過檢查和處理後,就會保存到這個隊列中。
  • backlog是「備用隊列」。當socket處於用戶進程的上下文時(即用戶正在對socket進行系統調用,如recv),操做系統收到數據包時會將數據包保存到backlog隊列中,而後直接返回。
  • prequeue是「預存隊列」。當socket沒有正在被用戶進程使用時,也就是用戶進程調用了read或者recv系統調用,可是進入了睡眠狀態時,操做系統直接將收到的報文保存在prequeue中,而後返回。
  • out_of_order是「亂序隊列」。隊列存儲的是亂序的報文,操做系統收到的報文並非TCP準備接收的下一個序號的報文,則放入out_of_order隊列,等待後續處理。

後記

 若是你以爲本篇文章對你有幫助,請點個贊。同時歡迎訂閱本人的微信公衆號。

我的博客: Remcarpediem

參考

相關文章
相關標籤/搜索