搞了半天,終於弄懂了TCP Socket數據的接收和發送,太難

搞了半天,終於弄懂了TCP Socket數據的接收和發送,太難

本文將從上層介紹Linux上的TCP/IP棧是如何工作的,特別是socket系統調用和內核數據結構的交互、內核和實際網絡的交互。寫這篇文章的部分原因是解釋監聽隊列溢出(listen queue overflow)是如何工作的,因為它與我工作中一直在研究的一個問題相關。

建好的連接怎麼工作

先從建好的連接開始介紹,稍後將解釋新建連接是如何工作的。

內核管理的每一個TCP文件描述符都是一個struct, 它記錄TCP相關的信息(如序列號、當前窗口大小等等),以及一個接收緩衝區(receive buffer,或者叫receive queue)和一個寫緩衝區(write buffer,或者叫write queue),後面我會交替使用術語buffer和queue。如果你對更多細節感興趣,可以在Linux內核的net/sock.h中看到socket結構的實現。

當一個新的數據包進入網絡接口(NIC)時,通過被NIC中斷或通過輪詢NIC的方式通知內核獲取數據。通常內核是由中斷驅動還是處於輪詢模式取決於網絡通信量;當NIC非常繁忙時,內核輪詢效率更高,但如果NIC不繁忙,則可以使用中斷來節省CPU週期和電源。Linux稱這種技術為NAPI,字面意思是“新的api”。

當內核從NIC獲取數據包時,它會對數據包進行解碼,並根據源IP、源端口、目標IP和目標端口找出與該數據包相關聯的TCP連接。此信息用於查找與該連接關聯的內存中的struct sock。假設數據包是按順序的到來的,那麼數據有效負載就被複制到套接字的接收緩衝區中。此時,內核將執行read(2)或使用諸如select(2)或epoll_wait(2)等I/O多路複用方式系統調用,喚醒等待此套接字的進程。

當用戶態的進程實際調用文件描述符上的read(2)時,它會導致內核從其接收緩衝區中刪除數據,並將該數據複製到此進程調用read(2)所提供的緩衝區中。

發送數據的工作原理類似。當應用程序調用write(2)時,它將數據從用戶提供的緩衝區複製到內核寫入隊列中。隨後,內核將把數據從寫隊列複製到NIC中,並實際發送數據。如果網絡繁忙,如果TCP發送窗口已滿,或者如果有流量整形策略等等,從用戶實際調用write(2)開始,到向NIC傳輸數據的實際時間可能會有所延遲。

這種設計的一個結果是,如果應用程序讀取速度太慢或寫入速度太快,內核的接收和寫入隊列可能會被填滿。因此,內核為讀寫隊列設置最大大小。這樣可以確保行為不可控的應用程序使用有限制的內存量。例如,內核可能會將每個接收和寫入隊列的大小限制在100KB。然後每個TCP套接字可以使用的最大內核內存量大約為200KB(因為與隊列的大小相比,其他TCP數據結構的大小可以忽略不計)。

讀語義

如果接收緩衝區為空,並且用戶調用read(2),則系統調用將被阻塞,直到數據可用。

如果接收緩衝區是非空的,並且用戶調用read(2),系統調用將立即返回這些可用的數據。如果讀取隊列中準備好的數據量小於用戶提供的緩衝區的大小,則可能發生部分讀取。調用方可以通過檢查read(2)的返回值來檢測到這一點。

如果接收緩衝區已滿,而TCP連接的另一端嘗試發送更多的數據,內核將拒絕對數據包進行ACK。這只是常規的TCP擁塞控制。

寫語義

如果寫入隊列未滿,並且用戶調用寫入,則系統調用將成功。如果寫入隊列有足夠的空間,則將複製所有數據。如果寫入隊列只有部分數據的空間,那麼將發生部分寫入,並且只有部分數據將被複制到緩衝區。調用方通過檢查write(2)的返回值來檢查這一點。

如果寫入隊列已滿,並且用戶調用寫入write(2)),則系統調用將被阻塞。

新建連接的工作機制

在上一節中,我們看到了已建立的連接如何使用接收和寫入隊列來限制為每個連接分配的內核內存量。使用類似的技術也用來限制為新連接保留的內核內存量。

從用戶態的角度來看,新建立的TCP連接是通過在監聽套接字上調用accept(2)來創建的。監聽套接字是使用listen(2)系統調用的套接字。

accept(2)的原型採用一個套接字和兩個字段來存儲另一端套接字的信息。accept(2)返回的值是一個整數,表示新建立連接的文件描述符:

<code>int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);/<code>

listen(2)的原型採用了一個套接字文件描述符和一個backlog參數:

<code>int listen(int sockfd, int backlog);/<code>

backlog是一個參數,當用戶沒有足夠快地調用accept(2)時,它控制內核將為新連接保留多少內存。

例如,假設您有一個阻塞的單線程HTTP服務器,每個HTTP請求大約需要100毫秒。在這種情況下,HTTP服務器將花費100毫秒處理每個請求,然後才能再次調用accept(2)。這意味著在最多10個 rps 的情況下不會有排隊現象。如果內核中有10個以上的 rps,則有兩個選擇。

內核的第一個選擇是根本不接受連接。例如,內核可以拒絕對傳入的SYN包進行ACK。更常見的情況是,內核將完成TCP三次握手,然後使用RST終止連接。不管怎樣,結果都是一樣的:如果連接被拒絕,就不需要分配接收或寫入緩衝區。這樣做的理由是,如果用戶空間進程沒有足夠快地接受連接,那麼正確的做法是使新請求失敗。反對這樣做的理由是,這太粗暴(aggressive),尤其是如果新的連接爆發(bursty)的時候。

內核的第二個選擇是接受連接併為其分配一個套接字結構(包括接收/寫入緩衝區),然後將套接字對象排隊以備以後使用。下次用戶調用accept(2)將立即獲得已分配的套接字, 而不是阻塞系統調用。

支持第二種方式的理由是,當處理速率或連接速率趨向於爆發時,它過於“寬宏大量”。例如,在我們剛才描述的服務器中,假設有10個新連接同時出現,然後這一秒中沒有更多的連接出現。如果內核將新連接排隊,那麼在第這一秒中所有的請求都會被處理。如果內核採用拒絕新的連接的策略,那麼即使進程本來能夠滿足請求速率的,也只有一個連接會成功。

不過有兩個反對排隊的論點。第一個問題是,過多的排隊會導致分配大量的內核內存。如果內核正在分配帶有大接收緩衝區的數千個套接字,那麼內存使用量可能會快速增長,而用戶空間進程甚至可能無法處理所有這些請求。另一個反對排隊的論點是,它使應用程序在連接的另一端(客戶機)看起來很慢。客戶機將看到它可以建立新的TCP連接,但是當它嘗試使用它們時,服務器似乎響應非常慢。所以建議在這種情況下,最好是讓新的連接失敗,因為這樣可以提供更明顯的服務器不正常的反饋。此外,如果服務器嚴重破壞了新的連接,客戶機就可以知道要退讓(back off);這是另一種擁塞控制形式。

監聽隊列(listen queue)和溢出

正如您可能懷疑的那樣,內核實際上結合了這兩種方法。內核將會對新連接進行排隊,但只是一定數量的連接。內核將排隊的連接數量由listen(2)的backlog參數控制。通常此值設置為相對較小的值。在Linux上,socket.h 將 somaxconn 的值設置為128,在kernel 2.4.25之前,這是允許的最大值。現在最大值是在/proc/sys/net/core/somaxconn中指定的,但是通常您會發現程序使用somaxconn(或更小的硬編碼值)。

當監聽隊列填滿時,新連接會被拒絕。這稱為監聽隊列溢出。您可以通過讀取/proc/net/netstat並檢查ListenOverflows的值來觀察情況。這是整個內核的全局計數器。據我所知,您無法獲得每個監聽套接字的監聽溢出統計信息。

在編寫網絡服務器時,監控監聽溢出非常重要,因為監聽溢出不會從服務器的角度觸發任何用戶可見的行為。服務器將愉快地accept(2)每日的連接,而不返回任何連接被丟棄的跡象。例如,假設您為Python應用程序使用Nginx作為代理服務器。

如果python應用程序太慢,則可能導致nginx listen套接字溢出。當發生這種情況時,您將在nginx日誌中看不到任何關於這一點的指示,您將一直看到200狀態代碼,像往常一樣。因此,如果您只是監視應用程序的HTTP狀態代碼,您將無法看到阻止請求轉發到應用程序的TCP錯誤。


分享到:


相關文章: