內容摘自:TCP之深入淺出send和recv、再次深入理解TCP網絡編程中的send和recv
建議閱讀時參考:Unix環境高級編程-TCP、UDP緩沖區
概念
先明確一個概念:每個TCP socket在內核中都有一個發送緩沖區和一個接收緩沖區,TCP的全雙工的工作模式以及TCP的滑動窗口便是依賴於這兩個獨立的buffer以及此buffer的填充狀態。接收緩沖區把數據緩存入內核,應用進程一直沒有調用read進行讀取的話,此數據會一直緩存在相應 socket的接收緩沖區內。再啰嗦一點,不管進程是否讀取socket,對端發來的數據都會經由內核接收並且緩存到socket的內核接收緩沖區之中。 read所做的工作,就是把內核緩沖區中的數據拷貝到應用層用戶的buffer里面,僅此而已。進程調用send發送的數據的時候,最簡單情況(也是一般情況),將數據拷貝進入socket的內核發送緩沖區之中,然后send便會在上層返回。換句話說,send返回之時,數據不一定會發送到對端去(和 write寫文件有點類似),send僅僅是把應用層buffer的數據拷貝進socket的內核發送buffer中。后續我會專門用一篇文章介紹 read和send所關聯的內核動作。每個UDP socket都有一個接收緩沖區,沒有發送緩沖區,從概念上來說就是只要有數據就發,不管對方是否可以正確接收,所以不緩沖,不需要發送緩沖區。
接收緩沖區被TCP和UDP用來緩存網絡上來的數據,一直保存到應用進程讀走為止。對於TCP,如果應用進程一直沒有讀取,buffer滿了之后,發生的動作是:通知對端TCP協議中的窗口關閉。這個便是滑動窗口的實現。保證TCP套接口接收緩沖區不會溢出,從而保證了TCP是可靠傳輸。因為對方不允許發出超過所通告窗口大小的數據。 這就是TCP的流量控制,如果對方無視窗口大小而發出了超過窗口大小的數據,則接收方TCP將丟棄它。 UDP:當套接口接收緩沖區滿時,新來的數據報無法進入接收緩沖區,此數據報就被丟棄。UDP是沒有流量控制的;快的發送者可以很容易地就淹沒慢的接收者,導致接收方的UDP丟棄數據報。
以上便是TCP可靠,UDP不可靠的實現。
if(條件1){ 向buffer_last_modified填充協議內容“Last-Modified: Sat, 04 May 2012 05:28:58 GMT”; send(buffer_last_modified); } if(條件2){ 向buffer_expires填充協議內容“Expires: Mon, 14 Aug 2023 05:17:29 GMT”; send(buffer_expires); } if(條件N){ 向buffer_N填充協議內容“。。。”; send(buffer_N); }
對於這樣的實現,當前的http應答在執行這段代碼時,假設有M(M<=N)個條件都滿足,那么會有連續的M個send調用,那是不是下層會依次向客戶端發出M個TCP包呢?答案是否定的,包的數目在應用層是無法控制的,並且應用層也是不需要控制的。
用下列四個假設場景來解釋一下這個答案:
由於TCP是流式的,對於TCP而言,每個TCP連接只有syn開始和fin結尾,中間發送的數據是沒有邊界的,多個連續的send所干的事情僅僅是:
假如socket的文件描述符被設置為阻塞方式,而且發送緩沖區還有足夠空間容納這個send所指示的應用層buffer的全部數據,那么把這些數據從應用層的buffer,拷貝到內核的發送緩沖區,然后返回。
假如socket的文件描述符被設置為阻塞方式,但是發送緩沖區沒有足夠空間容納這個send所指示的應用層buffer的全部數據,那么能拷貝多少就拷貝多少,然后進程掛起,等到TCP對端的接收緩沖區有空余空間時,通過滑動窗口協議(ACK包的又一個作用----打開窗口)通知TCP本端:“親,我已經做好准備,您現在可以繼續向我發送X個字節的數據了”,然后本端的內核喚醒進程,繼續向發送緩沖區拷貝剩余數據,並且內核向TCP對端發送TCP數據,如果send所指示的應用層buffer中的數據在本次仍然無法全部拷貝完,那么過程重復。。。直到所有數據全部拷貝完,返回。請注意,對於send的行為,我用了“拷貝一次”,send和下層是否發送數據包,沒有任何關系。
假如socket的文件描述符被設置為非阻塞方式,而且發送緩沖區還有足夠空間容納這個send所指示的應用層buffer的全部數據,那么把這些數據從應用層的buffer,拷貝到內核的發送緩沖區,然后返回。
假如socket的文件描述符被設置為非阻塞方式,但是發送緩沖區沒有足夠空間容納這個send所指示的應用層buffer的全部數據,那么能拷貝多少就拷貝多少,然后返回拷貝的字節數。多涉及一點,返回之后有兩種處理方式:
1.死循環,一直調用send,持續測試,一直到結束(基本上不會這么搞)。
2.非阻塞搭配epoll或者select,用這兩種東西來測試socket是否達到可發送的活躍狀態,然后調用send(高性能服務器必需的處理方式)。
綜上,以及請參考本文前述的SO_RCVBUF和SO_SNDBUF,你會發現,在實際場景中,你能發出多少TCP包以及每個包承載多少數據,除了受到自身服務器配置和環境帶寬影響,對端的接收狀態也能影響你的發送狀況。
至於為什么說“應用層也是不需要控制發送行為的”,這個說法的原因是:
軟件系統分層處理、分模塊處理各種軟件行為,目的就是為了各司其職,分工。應用層只關心業務實現,控制業務。數據傳輸由專門的層面去處理,這樣應用層開發的規模和復雜程度會大為降低,開發和維護成本也會相應降低。
再回到發送的話題上來:)之前說應用層無法精確控制和完全控制發送行為,那是不是就是不控制了?非也!雖然無法控制,但也要盡量控制!
如何盡量控制?現在引入本節主題----TCP_CORK和TCP_NODELAY。
cork:塞子,塞住
nodelay:不要延遲
TCP_CORK:盡量向發送緩沖區中攢數據,攢到多了再發送,這樣網絡的有效負載會升高。簡單粗暴地解釋一下這個有效負載的問題。假如每個包中只有一個字節的數據,為了發送這一個字節的數據,再給這一個字節外面包裝一層厚厚的TCP包頭,那網絡上跑的幾乎全是包頭了,有效的數據只占其中很小的部分,很多訪問量大的服務器,帶寬可以很輕松的被這么耗盡。那么,為了讓有效負載升高,我們可以通過這個選項指示TCP層,在發送的時候盡量多攢一些數據,把他們填充到一個TCP包中再發送出去。這個和提升發送效率是相互矛盾的,空間和時間總是一堆冤家!!
TCP_NODELAY:盡量不要等待,只要發送緩沖區中有數據,並且發送窗口是打開的,就盡量把數據發送到網絡上去。
很明顯,兩個選項是互斥的。實際場景中該怎么選擇這兩個選項呢?再次舉例說明
webserver,,下載服務器(ftp的發送文件服務器),需要帶寬量比較大的服務器,用TCP_CORK。
涉及到交互的服務器,比如ftp的接收命令的服務器,必須使用TCP_NODELAY。默認是TCP_CORK。設想一下,用戶每次敲幾個字節的命令,而下層在攢這些數據,想等到數據量多了再發送,這樣用戶會等到發瘋。這個糟糕的場景有個專門的詞匯來形容-----粘(nian拼音二聲)包
接下來我們用一個測試機上的阻塞socket實例來說明主題。文章中所有圖都是在測試系統上現截取的。
需要理解的3個概念
1. TCP socket的buffer
每個TCP socket在內核中都有一個發送緩沖區和一個接收緩沖區,TCP的全雙工的工作模式以及TCP的流量(擁塞)控制便是依賴於這兩個獨立的buffer以及buffer的填充狀態。接收緩沖區把數據緩存入內核,應用進程一直沒有調用recv()進行讀取的話,此數據會一直緩存在相應socket的接收緩沖區內。再啰嗦一點,不管進程是否調用recv()讀取socket,對端發來的數據都會經由內核接收並且緩存到socket的內核接收緩沖區之中。recv()所做的工作,就是把內核緩沖區中的數據拷貝到應用層用戶的buffer里面,並返回,僅此而已。進程調用send()發送的數據的時候,最簡單情況(也是一般情況),將數據拷貝進入socket的內核發送緩沖區之中,然后send便會在上層返回。換句話說,send()返回之時,數據不一定會發送到對端去(和write寫文件有點類似),send()僅僅是把應用層buffer的數據拷貝進socket的內核發送buffer中,發送是TCP的事情,和send其實沒有太大關系。接收緩沖區被TCP用來緩存網絡上來的數據,一直保存到應用進程讀走為止。對於TCP,如果應用進程一直沒有讀取,接收緩沖區滿了之后,發生的動作是:收端通知發端,接收窗口關閉(win=0)。這個便是滑動窗口的實現。保證TCP套接口接收緩沖區不會溢出,從而保證了TCP是可靠傳輸。因為對方不允許發出超過所通告窗口大小的數據。 這就是TCP的流量控制,如果對方無視窗口大小而發出了超過窗口大小的數據,則接收方TCP將丟棄它。
查看測試機的socket發送緩沖區大小,如圖1所示
圖1
第一個值是一個限制值,socket發送緩存區的最少字節數;
第二個值是默認值;
第三個值是一個限制值,socket發送緩存區的最大字節數;
根據實際測試,發送緩沖區的尺寸在默認情況下的全局設置是16384字節,即16k。
在測試系統上,發送緩存默認值是16k。
proc文件系統下的值和sysctl中的值都是全局值,應用程序可根據需要在程序中使用setsockopt()對某個socket的發送緩沖區尺寸進行單獨修改,詳見文章《TCP選項之SO_RCVBUF和SO_SNDBUF》,不過這都是題外話。
2. 接收窗口(滑動窗口)
TCP連接建立之時的收端的初始接受窗口大小是14600,細節如圖2所示(129是收端,130是發端)
圖2
接收窗口是TCP中的滑動窗口,TCP的收端用這個接受窗口----win=14600,通知發端,我目前的接收能力是14600字節。
后續發送過程中,收端會不斷的用ACK(ACK的全部作用請參照博文《TCP之ACK發送情景》)通知發端自己的接收窗口的大小狀態,如圖3,而發端發送數據的量,就根據這個接收窗口的大小來確定,發端不會發送超過收端接收能力的數據量。這樣就起到了一個流量控制的的作用。
圖3
圖3說明
21,22兩個包都是收端發給發端的ACK包
第21個包,收端確認收到的前7240個字節數據,7241的意思是期望收到的包從7241號開始,序號加了1.同時,接收窗口從最初的14656(如圖2)經過慢啟動階段增加到了現在的29120。用來表明現在收端可以接收29120個字節的數據,而發端看到這個窗口通告,在沒有收到新的ACK的時候,發端可以向收端發送29120字節這么多數據。
第22個包,收端確認收到的前8688個字節數據,並通告自己的接收窗口繼續增長為32000這么大。
3. 單個TCP的負載量和MSS的關系
MSS在以太網上通常大小是1460字節,而我們在后續發送過程中的單個TCP包的最大數據承載量是1448字節,這二者的關系可以參考博文《TCP之1460MSS和1448負載》。
實例詳解send()
實例功能說明:接收端129作為客戶端去連接發送端130,連接上之后並不調用recv()接收,而是sleep(1000),把進程暫停下來,不讓進程接收數據。內核會緩存數據至接收緩沖區。發送端作為服務器接收TCP請求之后,立即用ret = send(sock,buf,70k,0);這個C語句,向接收端發送70k數據。
我們現在來觀察這個過程。看看究竟發生了些什么事。wireshark抓包截圖如下圖4
圖4
圖4說明,包序號等同於時序
1. 客戶端sleep在recv()之前,目的是為了把數據壓入接收緩沖區。服務端調用"ret = send(sock,buf,70k,0);"這個C語句,向接收端發送70k數據。由於發送緩沖區大小16k,send()無法將70k數據全部拷貝進發送緩沖區,故先拷貝16k進入發送緩沖區,下層發送緩沖區中有數據要發送,內核開始發送。上層send()在應用層處於阻塞狀態;
2. 11號TCP包,發端從這兒開始向收端發送1448個字節的數據;
3. 12號TCP包,發端沒有收到之前發送的1448個數據的ACK包,仍然繼續向收端發送1448個字節的數據;
4. 13號TCP包,收端向發端發送1448字節的確認包,表明收端成功接收總共1448個字節。此時收端並未調用recv()讀取,目前發送緩沖區中被壓入1448字節。由於處於慢啟動狀態,win接收窗口持續增大,表明接受能力在增加,吞吐量持續上升;
5. 14號TCP包,收端向發端發送2896字節的確認包,表明收端成功接收總共2896個字節。此時收端並未調用recv()讀取,目前發送緩沖區中被壓入2896字節。由於處於慢啟動狀態,win接收窗口持續增大,表明接受能力在增加,吞吐量持續上升;
6. 15號TCP包,發端繼續向收端發送1448個字節的數據;
7. 16號TCP包,收端向發端發送4344字節的確認包,表明收端成功接收總共4344個字節。此時收端並未調用recv()讀取,目前發送緩沖區中被壓入4344字節。由於處於慢啟動狀態,win接收窗口持續增大,表明接受能力在增加,吞吐量持續上升;
8. 從這兒開始,我略去很多包,過程類似上面過程。同時,由於不斷的發送出去的數據被收端用ACK確認,發送緩沖區的空間被逐漸騰出空地,send()內部不斷的把應用層buf中的數據向發送緩沖區拷貝,從而不斷的發送,過程重復。70k數據並沒有被完全送入內核,send()不管是否發送出去,send不管發送出去的是否被確認,send()只關心buf中的數據有沒有被全部送往內核發送緩沖區。如果buf中的數據沒有被全部送往內核發送緩沖區,send()在應用層阻塞,負責等待內核發送緩沖區中有空余空間的時候,逐步拷貝buf中的數據;如果buf中的數據被全部拷入內核發送緩沖區,send()立即返回。
9. 經過慢啟動階段接收窗口增大到穩定階段,TCP吞吐量升高到穩定階段,收端一直處於sleep狀態,沒有調用recv()把內核中接收緩沖區中的數據拷貝到應用層去,此時收端的接收緩沖區中被壓入大量數據;
10. 66號、67號TCP數據包,發端繼續向收端發送數據;
11. 68號TCP數據包,收端發送ACK包確認接收到的數據,ACK=62265表明收端已經收到62265字節的數據,這些數據目前被壓在收端的接收緩沖區中。win=3456,比較之前的16號TCP包的win=23296,表明收端的窗口已經處於收縮狀態,收端的接收緩沖區中的數據遲遲未被應用層讀走,導致接收緩沖區空間吃緊,故收縮窗口,控制發送端的發送量,進行流量控制;
12. 69號、70號TCP數據包,發端在接收窗口允許的數據量的范圍內,繼續向收端發送2段1448字節長度的數據;
13. 71號TCP數據包,至此,收端已經成功接收65160字節的數據,全部被壓在接收緩沖區之中,接收窗口繼續收縮,尺寸為1600字節;
14. 72號TCP數據包,發端在接收窗口允許的數據量的范圍內,繼續向收端發送1448字節長度的數據;
15. 73號TCP數據包,至此,收端已經成功接收66609字節的數據,全部被壓在接收緩沖區之中,接收窗口繼續收縮,尺寸為192字節。
16. 74號TCP數據包,和我們這個例子沒有關系,是別的應用發送的包;
17. 75號TCP數據包,發端在接收窗口允許的數據量的范圍內,向收端發送192字節長度的數據;
18. 76號TCP數據包,至此,收端已經成功接收66609字節的數據,全部被壓在接收緩沖區之中,win=0接收窗口關閉,接收緩沖區滿,無法再接收任何數據;
19. 77號、78號、79號TCP數據包,由keepalive觸發的數據包,響應的ACK持有接收窗口的狀態win=0,另外,ACK=66801表明接收端的接收緩沖區中積壓了66800字節的數據。
20. 從以上過程,我們應該熟悉了滑動窗口通告字段win所說明的問題,以及ACK確認數據等等。現在可得出一個結論,接收端的接收緩存尺寸應該是66800字節(此結論並非本篇主題)。
send()要發送的數據是70k,現在發出去了66800字節,發送緩存中還有16k,應用層剩余要拷貝進內核的數據量是N=70k-66800-16k。接收端仍處於sleep狀態,無法recv()數據,這將導致接收緩沖區一直處於積壓滿的狀態,窗口會一直通告0(win=0)。發送端在這樣的狀態下徹底無法發送數據了,send()的剩余數據無法繼續拷貝進內核的發送緩沖區,最終導致send()被阻塞在應用層;
21. send()一直阻塞中。。。
圖4和send()的關系說明完畢。
那什么時候send返回呢?有3種返回場景
send()返回場景
場景1,我們繼續圖4這個例子,不過這兒開始我們就跳出圖4所示的過程了
22. 接收端sleep(1000)到時間了,進程被喚醒,代碼片段如圖5
圖5
隨着進程不斷的用"recv(fd,buf,2048,0);"將數據從內核的接收緩沖區拷貝至應用層的buf,在使用win=0關閉接收窗口之后,現在接收緩沖區又逐漸恢復了緩存的能力,這個條件下,收端會主動發送攜帶"win=n(n>0)"這樣的ACK包去通告發送端接收窗口已打開;
23. 發端收到攜帶"win=n(n>0)"這樣的ACK包之后,開始繼續在窗口運行的數據量范圍內發送數據。發送緩沖區的數據被發出;
24. 收端繼續接收數據,並用ACK確認這些數據;
25. 發端收到ACK,可以清理出一些發送緩沖區空間,應用層send()的剩余數據又可以被不斷的拷貝進內核的發送緩沖區;
26. 不斷重復以上發送過程;
27. send()的70k數據全部進入內核,send()成功返回。
場景2,我們繼續圖4這個例子,不過這兒開始我們就跳出圖4所示的過程了
22. 收端進程或者socket出現問題,給發端發送一個RST,請參考博文《》;
23. 內核收到RST,send返回-1。
場景3,和以上例子沒關系
連接上之后,馬上send(1k),這樣,發送的數據肯定可以一次拷貝進入發送緩沖區,send()拷貝完數據立即成功返回。
send()發送結論
其實場景1和場景2說明一個問題
send()只是負責拷貝,拷貝完立即返回,不會等待發送和發送之后的ACK。如果socket出現問題,RST包被反饋回來。在RST包返回之時,如果send()還沒有把數據全部放入內核或者發送出去,那么send()返回-1,errno被置錯誤值;如果RST包返回之時,send()已經返回,那么RST導致的錯誤會在下一次send()或者recv()調用的時候被立即返回。
場景3完全說明send()只要完成拷貝就成功返回,如果發送數據的過程中出現各種錯誤,下一次send()或者recv()調用的時候被立即返回。
概念上容易疑惑的地方
1. TCP協議本身是為了保證可靠傳輸,並不等於應用程序用tcp發送數據就一定是可靠的,必須要容錯;
2. send()和recv()沒有固定的對應關系,不定數目的send()可以觸發不定數目的recv(),這話不專業,但是還是必須說一下,初學者容易疑惑;
3. 關鍵點,send()只負責拷貝,拷貝到內核就返回,我通篇在說拷貝完返回,很多文章中說send()在成功發送數據后返回,成功發送是說發出去的東西被ACK確認過。send()只拷貝,不會等ACK;
4. 此次send()調用所觸發的程序錯誤,可能會在本次返回,也可能在下次調用網絡IO函數的時候被返回。
實際上理解了阻塞式的,就能理解非阻塞的。