聲明
本文絕大部分內(nèi)容參考了:https://cloud.tencent.com/developer/article/1666211
一、建好的連接怎么工作
內(nèi)核管理的每一個TCP文件描述符都對應(yīng)于一個struct sock結(jié)構(gòu)體, 用于記錄TCP相關(guān)的信息(如序列號、當(dāng)前窗口大小等等),以及一個接收緩沖區(qū)(receive buffer,或者叫receive queue)和一個寫緩沖區(qū)(write buffer,或者叫write queue),后面我會交替使用術(shù)語buffer和queue。(見net/sock.h)
當(dāng)一個新的數(shù)據(jù)包進入網(wǎng)絡(luò)接口(NIC)時,通過被NIC中斷或通過輪詢NIC的方式通知內(nèi)核獲取數(shù)據(jù)。通常內(nèi)核是由中斷驅(qū)動還是處于輪詢模式取決于網(wǎng)絡(luò)通信量;當(dāng)NIC非常繁忙時,內(nèi)核輪詢效率更高,但如果NIC不繁忙,則可以使用中斷來節(jié)省CPU周期和電源。Linux稱這種技術(shù)為NAPI,字面意思是“新的api”。
當(dāng)內(nèi)核從NIC獲取數(shù)據(jù)包時,它會對數(shù)據(jù)包進行解碼,并根據(jù)源IP、源端口、目標(biāo)IP和目標(biāo)端口找出與該數(shù)據(jù)包相關(guān)聯(lián)的TCP連接。此信息用于查找與該連接關(guān)聯(lián)的內(nèi)存中的struct sock。假設(shè)數(shù)據(jù)包是按順序的到來的,那么數(shù)據(jù)有效負載就被復(fù)制到套接字的接收緩沖區(qū)中。此時,內(nèi)核將執(zhí)行read(2)或使用諸如select(2)或epoll_wait(2)等I/O多路復(fù)用方式系統(tǒng)調(diào)用,喚醒等待此套接字的進程。
當(dāng)用戶態(tài)的進程實際調(diào)用文件描述符上的read(2)時,它會導(dǎo)致內(nèi)核從其接收緩沖區(qū)中刪除數(shù)據(jù),并將該數(shù)據(jù)復(fù)制到此進程調(diào)用read(2)所提供的緩沖區(qū)中。
發(fā)送數(shù)據(jù)的工作原理類似。當(dāng)應(yīng)用程序調(diào)用write(2)時,它將數(shù)據(jù)從用戶提供的緩沖區(qū)復(fù)制到內(nèi)核寫入隊列中。隨后,內(nèi)核將把數(shù)據(jù)從寫隊列復(fù)制到NIC中,并實際發(fā)送數(shù)據(jù)。如果網(wǎng)絡(luò)繁忙,如果TCP發(fā)送窗口已滿,或者如果有流量整形策略等等,從用戶實際調(diào)用write(2)開始,到向NIC傳輸數(shù)據(jù)的實際時間可能會有所延遲。
這種設(shè)計的一個結(jié)果是,如果應(yīng)用程序讀取速度太慢或?qū)懭胨俣忍欤瑑?nèi)核的接收和寫入隊列可能會被填滿。因此,內(nèi)核為讀寫隊列設(shè)置最大大小。這樣可以確保行為不可控的應(yīng)用程序使用有限制的內(nèi)存量。例如,內(nèi)核可能會將每個接收和寫入隊列的大小限制在100KB。然后每個TCP套接字可以使用的最大內(nèi)核內(nèi)存量大約為200KB(因為與隊列的大小相比,其他TCP數(shù)據(jù)結(jié)構(gòu)的大小可以忽略不計)。
讀語義
如果接收緩沖區(qū)為空,并且用戶調(diào)用read(2),則系統(tǒng)調(diào)用將被阻塞,直到數(shù)據(jù)可用。
如果接收緩沖區(qū)是非空的,并且用戶調(diào)用read(2),系統(tǒng)調(diào)用將立即返回這些可用的數(shù)據(jù)。如果讀取隊列中準(zhǔn)備好的數(shù)據(jù)量小于用戶提供的緩沖區(qū)的大小,則可能發(fā)生部分讀取。調(diào)用方可以通過檢查read(2)的返回值來檢測到這一點。
如果接收緩沖區(qū)已滿,而TCP連接的另一端嘗試發(fā)送更多的數(shù)據(jù),內(nèi)核將拒絕對數(shù)據(jù)包進行ACK。這只是常規(guī)的TCP擁塞控制。
寫語義
如果寫入隊列未滿,并且用戶調(diào)用寫入,則系統(tǒng)調(diào)用將成功。如果寫入隊列有足夠的空間,則將復(fù)制所有數(shù)據(jù)。如果寫入隊列只有部分數(shù)據(jù)的空間,那么將發(fā)生部分寫入,即只有部分數(shù)據(jù)將被復(fù)制到緩沖區(qū)。調(diào)用方通過檢查write(2)的返回值來檢查這一點。
如果寫入隊列已滿,并且用戶調(diào)用寫入write(2)),則系統(tǒng)調(diào)用將被阻塞。
二、建立新連接
從用戶態(tài)的角度來看,新建立的TCP連接是通過在監(jiān)聽套接字上調(diào)用accept(2)來創(chuàng)建的。監(jiān)聽套接字是使用listen(2)系統(tǒng)調(diào)用的套接字。
accept(2)的原型采用一個套接字和兩個字段來存儲另一端套接字的信息。accept(2)返回的值是一個整數(shù),表示新建立連接的文件描述符:
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
listen(2)的原型采用了一個套接字文件描述符和一個backlog參數(shù):
int listen(int sockfd, int backlog);
詳細的內(nèi)容,可以查看:《linux手冊翻譯——listen(2)》和《linux手冊翻譯——accept(2)》
對于listen(2)的backlog參數(shù),用于控制內(nèi)核將為新連接保留多少內(nèi)存。
例如,假設(shè)有一個阻塞的單線程HTTP服務(wù)器,每個HTTP請求大約需要100毫秒。在這種情況下,HTTP服務(wù)器將花費100毫秒處理每個請求,然后才能再次調(diào)用accept(2)。這意味著在最多10個 rps 的情況下不會有排隊現(xiàn)象。如果內(nèi)核中有10個以上的 rps,則有兩個選擇:
不接受連接
例如,內(nèi)核可以拒絕對傳入的SYN包進行ACK。更常見的情況是,內(nèi)核將完成TCP三次握手,然后使用RST終止連接。不管怎樣,結(jié)果都是一樣的:如果連接被拒絕,就不需要分配接收或?qū)懭刖彌_區(qū)。這樣做的理由是,如果用戶空間進程沒有足夠快地接受連接,那么正確的做法是使新請求失敗。但是這種做法太粗暴(aggressive),尤其是新連接爆發(fā)(bursty)的時候。接受連接并為其分配一個套接字結(jié)構(gòu)(包括接收/寫入緩沖區(qū)),然后將套接字對象排隊以備以后使用
此時調(diào)用accept(2)將立即獲得已分配的套接字。這樣做就可以提前將連接的socket給準(zhǔn)備好,也不會出現(xiàn)拒絕連接的情況,但是可能會造成占用大量的內(nèi)核內(nèi)存。
此外,這會使應(yīng)用程序在連接的另一端(客戶機)看起來很慢??蛻魴C將看到它可以建立新的TCP連接,但是當(dāng)它嘗試使用它們時,服務(wù)器似乎響應(yīng)非常慢。所以在這種情況下,讓新的連接失敗,提供更明顯的服務(wù)器不正常的反饋。
監(jiān)聽隊列(listen queue)和溢出
顯然,最好的方法就是中庸之道了:允許一定數(shù)量的新連接進行排隊。
內(nèi)核將排隊的連接數(shù)量由listen(2)的backlog參數(shù)控制。通常此值設(shè)置為相對較小的值。在Linux上,socket.h 將 somaxconn 的值設(shè)置為128,在kernel 2.4.25之前,這是允許的最大值?,F(xiàn)在最大值是在/proc/sys/net/core/somaxconn中指定的。參見《linux手冊翻譯——listen(2)》NOTES部分的討論。
當(dāng)監(jiān)聽隊列填滿時,新連接會被拒絕。這稱為監(jiān)聽隊列溢出。通過讀取/proc/net/netstat并檢查ListenOverflows的值來觀察情況。注意這是整個內(nèi)核的全局計數(shù)器。目前還無法獲得每個監(jiān)聽套接字的監(jiān)聽溢出統(tǒng)計信息。
在編寫網(wǎng)絡(luò)服務(wù)器時,監(jiān)控監(jiān)聽溢出非常重要,因為監(jiān)聽溢出不會從服務(wù)器的角度觸發(fā)任何用戶可見的行為。服務(wù)器執(zhí)行accept(2)連接,不會返回任何連接被丟棄的信息。
例如,使用Nginx作為Python應(yīng)用程序的代理服務(wù)器,如果python應(yīng)用程序太慢,則可能導(dǎo)致nginx listen套接字的監(jiān)聽隊列溢出。當(dāng)發(fā)生這種情況時,在nginx日志中看不到任何關(guān)于這一點的指示,將一直看到200狀態(tài)代碼。因此,如果只是監(jiān)視應(yīng)用程序的HTTP狀態(tài)代碼,將無法看到拒絕連接的TCP錯誤。