不可不知的socket和TCP鏈接過程

本文主要說明的是TCP鏈接過程當中,各個階段對套接字的操做,但願能對沒有網絡編程基礎的人理解套接字是什麼、扮演的角色有所幫助。如發現錯誤,敬請指出web

背景

1.TCP協議棧維護着兩個socket緩衝區:send buffer和recv buffer算法

要經過TCP鏈接發送出去的數據都先拷貝到send buffer,多是從用戶空間進程的app buffer拷入的,也多是從內核的kernel buffer拷入的,拷入的過程是經過send()函數完成的,因爲也可使用write()函數寫入數據,因此也把這個過程稱爲寫數據,相應的send buffer也就有了別稱write buffer。不過send()函數比write()函數更有效率。apache

最終數據是經過網卡流出去的,因此send buffer中的數據須要拷貝到網卡中。因爲一端是內存,一端是網卡設備,能夠直接使用DMA的方式進行拷貝,無需CPU的參與。也就是說,send buffer中的數據經過DMA的方式拷貝到網卡中並經過網絡傳輸給TCP鏈接的另外一端:接收端。編程

當經過TCP鏈接接收數據時,數據確定是先經過網卡流入的,而後一樣經過DMA的方式拷貝到recv buffer中,再經過recv()函數將數據從recv buffer拷入到用戶空間進程的app buffer中。bash

大體過程以下圖:服務器

2.兩種套接字:監聽套接字和已鏈接套接字cookie

監聽套接字是在服務進程讀取配置文件時,從配置文件中解析出要監聽的地址、端口,而後經過socket()函數建立的,而後再經過bind()函數將這個監聽套接字綁定到對應的地址和端口上。隨後,進程/線程就能夠經過listen()函數來監聽這個端口(嚴格地說是監控這個監聽套接字)。網絡

已鏈接套接字是在監聽到TCP鏈接請求並三次握手後,經過accept()函數返回的套接字,後續進程/線程就能夠經過這個已鏈接套接字和客戶端進行TCP通訊。併發

爲了區分socket()函數和accept()函數返回的兩個套接字描述符,有些人使用listenfd和connfd分別表示監聽套接字和已鏈接套接字,挺形象的,下文偶爾也這麼使用。app

下面就來講明各類函數的做用,分析這些函數,也是在鏈接、斷開鏈接的過程。

鏈接的具體過程分析

以下圖:

socket()函數

socket()函數的做用就是生成一個用於通訊的套接字文件描述符sockfd(socket() creates an endpoint for communication and returns a descriptor)。這個套接字描述符能夠做爲稍後bind()函數的綁定對象。

bind()函數

服務程序經過分析配置文件,從中解析出想要監聽的地址和端口,再加上能夠經過socket()函數生成的套接字sockfd,就可使用bind()函數將這個套接字綁定到要監聽的地址和端口組合"addr:port"上。綁定了端口的套接字能夠做爲listen()函數的監聽對象。

綁定了地址和端口的套接字就有了源地址和源端口(對服務器自身來講是源),再加上經過配置文件中指定的協議類型,五元組中就有了其中3個元組。即:

{protocal,src_addr,src_port}

可是,常見到有些服務程序能夠配置監聽多個地址、端口實現多實例。這實際上就是經過屢次socket()+bind()系統調用生成並綁定多個套接字實現的。

listen()函數和connect()函數

顧名思義,listen()函數就是監聽已經經過bind()綁定了addr+port的套接字的。監聽以後,套接字就從CLOSE狀態轉變爲LISTEN狀態,因而這個套接字就能夠對外提供TCP鏈接的窗口了。

而connect()函數則用於向某個已監聽的套接字發起鏈接請求,也就是發起TCP的三次握手過程。從這裏能夠看出,鏈接請求方(如客戶端)纔會使用connect()函數,固然,在發起connect()以前,鏈接發起方也須要生成一個sockfd,且使用的極可能是綁定了隨機端口的套接字。既然connect()函數是向某個套接字發起鏈接的,天然在使用connect()函數時須要帶上鍊接的目的地,即目標地址和目標端口,這正是服務端的監聽套接字上綁定的地址和端口。同時,它還要帶上本身的地址和端口,對於服務端來講,這就是鏈接請求的源地址和源端口。因而,TCP鏈接的兩端的套接字都已經成了五元組的完整格式。

深刻分析listen()

再來細說listen()函數。若是監聽了多個地址+端口,即須要監聽多個套接字,那麼此刻負責監聽的進程/線程會採用select()、poll()的方式去輪詢這些套接字(固然,也可使用epoll()模式),其實只監控一個套接字時,也是使用這些模式去輪詢的,只不過select()或poll()所感興趣的套接字描述符只有一個而已。

無論使用select()仍是poll()模式(至於epoll的不一樣監控方式就無需多言了), 在進程/線程(監聽者)監聽的過程當中,它阻塞在select()或poll()上。直到有數據(SYN信息)寫入到它所監聽的sockfd中(即recv buffer),內核被喚醒(注意不是app進程被喚醒,由於TCP三次握手和四次揮手是在內核空間由內核完成的,不涉及用戶空間)並將SYN數據拷貝到kernel buffer中進行一番處理(好比判斷SYN是否合理),並準備SYN+ACK數據,這個數據須要從kernel buffer中拷入send buffer中,再拷入網卡傳送出去。這時會在鏈接未完成隊列(syn queue)中爲這個鏈接建立一個新項目,並設置爲SYN_RECV狀態。而後再次使用select()/poll()方式監控着套接字listenfd,直到再次有數據寫入這個listenfd中,內核再次被喚醒,若是此次寫入的數據是ACK信息,表示是某個客戶端對服務端內核發送的SYN的迴應,因而將數據拷入到kernel buffer中進行一番處理後,把鏈接未完成隊列中對應的項目移入鏈接已完成隊列(accept queue/established queue),並設置爲ESTABLISHED狀態,若是此次接收的不是ACK,則確定是SYN,也就是新的鏈接請求,因而和上面的處理過程同樣,放入鏈接未完成隊列。對於已經放入已完成隊列中的鏈接,將等待內核經過accept()函數進行消費(由用戶空間進程發起accept()系統調用,由內核完成消費操做),只要通過accept()過的鏈接,鏈接將從已完成隊列中移除,也就表示TCP已經創建完成了,兩端的用戶空間進程能夠經過這個鏈接進行真正的數據傳輸了,直到使用close()或shutdown()關閉鏈接時的4次揮手,中間不再須要內核的參與。這就是監聽者處理整個TCP鏈接的循環過程

也就是說, listen()函數還維護了兩個隊列:鏈接未完成隊列(syn queue)和鏈接已完成隊列(accept queue)。當監聽者接收到某個客戶端發來的SYN並回復了SYN+ACK以後,就會在未完成鏈接隊列的尾部建立一個關於這個客戶端的條目,並設置它的狀態爲SYN_RECV。顯然,這個條目中必須包含客戶端的地址和端口相關信息(多是hash過的,我不太肯定)。當服務端再次收到這個客戶端發送的ACK信息以後,監聽者線程經過分析數據就知道這個消息是回覆給未完成鏈接隊列中的哪一項的,因而將這一項移入到已完成鏈接隊列,並設置它的狀態爲ESTABLISHED,最後等待內核使用accept()函數來消費接收這個鏈接。今後開始,內核暫時退出舞臺,直到4次揮手。

當未完成鏈接隊列滿了,監聽者被阻塞再也不接收新的鏈接請求,並經過select()/poll()等待兩個隊列觸發可寫事件。當已完成鏈接隊列滿了,則監聽者也不會接收新的鏈接請求,同時,正準備移入到已完成鏈接隊列的動做被阻塞。在Linux 2.2之前,listen()函數有一個backlog的參數,用於設置這兩個隊列的最大總長度(其實是隻有一個隊列,但分爲兩種狀態,見下面的"小知識"),從Linux 2.2開始,這個參數只表示已完成隊列(accept queue)的最大長度,而/proc/sys/net/ipv4/tcp_max_syn_backlog則用於設置未完成隊列(syn queue/syn backlog)的最大長度。/proc/sys/net/core/somaxconn則是硬限制已完成隊列的最大長度,默認爲128,若是backlog參數大於somaxconn,則backlog會被截短爲該硬限制值。

當鏈接已完成隊列中的某個鏈接被accept()後,表示TCP鏈接已經創建完成, 這個鏈接將採用本身的socket buffer和客戶端進行數據傳輸。這個socket buffer和監聽套接字的socket buffer都是用來存儲TCP收、發的數據,但它們的意義已經再也不同樣:監聽套接字的socket buffer只接受TCP鏈接請求過程當中的syn和ack數據;而已創建的TCP鏈接的socket buffer主要存儲的內容是兩端傳輸的"正式"數據,例如服務端構建的響應數據,客戶端發起的Http請求數據。

小知識:兩種TCP套接字
實際上,有兩種不一樣類型的TCP套接字實現方式。上面介紹的使用兩種隊列的類型是Linux 2.2以後採用的一種。還有一種(BSD衍生)的套接字類型只採用了一個隊列,在這單個隊列中存放3次握手過程當中的全部鏈接,可是隊列中的每一個鏈接分爲兩種狀態:syn-recv和established。

Recv-Q和Send-Q的解釋

netstat命令的Send-Q和Recv-Q列表示的就是socket buffer相關的內容,如下是man netstat的解釋。

Recv-Q
    Established: The count of bytes not copied by the user program connected to this socket.  Listening: Since Kernel 2.6.18 this  column  contains the current syn backlog.

Send-Q
    Established:  The count of bytes not acknowledged by the remote host.  Listening: Since Kernel 2.6.18 this column contains the maximum size of the syn backlog.

對於監聽狀態的套接字,Recv-Q表示的是當前syn backlog,即堆積的syn消息的個數,也即未完成隊列中當前的鏈接個數,Send-Q表示的是syn backlog的最大值,即未完成鏈接隊列的最大鏈接限制個數;
對於已經創建的tcp鏈接,Recv-Q列表示的是recv buffer中還未被用戶進程拷貝走的數據大小,Send-Q列表示的是遠程主機還未返回ACK消息的數據大小。

之因此區分已創建TCP鏈接的套接字和監聽狀態的套接字,就是由於這兩種狀態的套接字採用不一樣的socket buffer,其中監聽套接字更注重隊列的長度,而已創建TCP鏈接的套接字更注重收、發的數據大小。

[root@xuexi ~]# netstat -tnl
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State      
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN     
tcp        0      0 127.0.0.1:25            0.0.0.0:*               LISTEN     
tcp6       0      0 :::80                   :::*                    LISTEN     
tcp6       0      0 :::22                   :::*                    LISTEN     
tcp6       0      0 ::1:25                  :::*                    LISTEN
[root@xuexi ~]# ss -tnl
State      Recv-Q Send-Q                    Local Address:Port      Peer Address:Port
LISTEN     0      128                                   *:22                   *:*   
LISTEN     0      100                           127.0.0.1:25                   *:*   
LISTEN     0      128                                  :::80                  :::*   
LISTEN     0      128                                  :::22                  :::*   
LISTEN     0      100                                 ::1:25                  :::*

注意,Listen狀態下的套接字,netstat的Send-Q和ss命令的Send-Q列的值不同,由於netstat根本就沒寫上未完成隊列的最大長度。所以,判斷隊列中是否還有空閒位置接收新的tcp鏈接請求時,應該儘量地使用ss命令而不是netstat。

syn flood的影響

此外,若是監聽者發送SYN+ACK後,遲遲收不到客戶端返回的ACK消息,監聽者將被select()/poll()設置的超時時間喚醒,並對該客戶端從新發送SYN+ACK消息,防止這個消息遺失在茫茫網絡中。可是,這一重發就出問題了,若是客戶端調用connect()時僞造源地址,那麼監聽者回復的SYN+ACK消息是必定到不了對方的主機的,也就是說,監聽者會遲遲收不到ACK消息,因而從新發送SYN+ACK。但不管是監聽者由於select()/poll()設置的超時時間一次次地被喚醒,仍是一次次地將數據拷入send buffer,這期間都是須要CPU參與的,並且send buffer中的SYN+ACK還要再拷入網卡(此次是DMA拷貝,不須要CPU)。若是,這個客戶端是個攻擊者,源源不斷地發送了數以千、萬計的SYN,監聽者幾乎直接就崩潰了,網卡也會被阻塞的很嚴重。這就是所謂的syn flood攻擊。

解決syn flood的方法有多種,例如,縮小listen()維護的兩個隊列的最大長度,減小重發syn+ack的次數,增大重發的時間間隔,減小收到ack的等待超時時間,使用syncookie等,但直接修改tcp選項的任何一種方法都不能很好兼顧性能和效率。因此在鏈接到達監聽者線程以前對數據包進行過濾是極其重要的手段。

accept()函數

accpet()函數的做用是讀取已完成鏈接隊列中的第一項(讀完就從隊列中移除),並對此項生成一個用於後續鏈接的套接字描述符,假設使用connfd來表示。有了新的鏈接套接字,工做進程/線程(稱其爲工做者)就能夠經過這個鏈接套接字和客戶端進行數據傳輸,而前文所說的監聽套接字(sockfd)則仍然被監聽者監聽。

例如,prefork模式的httpd,每一個子進程既是監聽者,又是工做者,每一個客戶端發起鏈接請求時,子進程在監聽時將它接收進來,並釋放對監聽套接字的監聽,使得其餘子進程能夠去監聽這個套接字。多個來回後,終因而經過accpet()函數生成了新的鏈接套接字,因而這個子進程就能夠經過這個套接字專心地和客戶端創建交互,固然,中途可能會由於各類io等待而屢次被阻塞或睡眠。這種效率真的很低,僅僅考慮從子進程收到SYN消息開始到最後生成新的鏈接套接字這幾個階段,這個子進程一次又一次地被阻塞。固然,能夠將監聽套接字設置爲非阻塞IO模式,只是即便是非阻塞模式,它也要不斷地去檢查狀態。

再考慮worker/event處理模式,每一個子進程中都使用了一個專門的監聽線程和N個工做線程。監聽線程專門負責監聽並創建新的鏈接套接字描述符,放入apache的套接字隊列中。這樣監聽者和工做者就分開了,在監聽的過程當中,工做者能夠仍然能夠自由地工做。若是隻從監聽這一個角度來講,worker/event模式比prefork模式性能高的不是一點半點。

當監聽者發起accept()系統調用的時候,若是已完成鏈接隊列中沒有任何數據,那麼監聽者會被阻塞。固然,可將套接字設置爲非阻塞模式,這時accept()在得不到數據時會返回EWOULDBLOCK或EAGAIN的錯誤。可使用select()或poll()或epoll來等待已完成鏈接隊列的可讀事件。還能夠將套接字設置爲信號驅動IO模式,讓已完成鏈接隊列中新加入的數據通知監聽者將數據複製到app buffer中並使用accept()進行處理。

常聽到同步鏈接和異步鏈接的概念,它們究竟是怎麼區分的?同步鏈接的意思是,從監聽者監聽到某個客戶端發送的SYN數據開始,它必須一直等待直到創建鏈接套接字、並和客戶端數據交互結束,在和這個客戶端的鏈接關閉以前,中間不會接收任何其餘客戶端的鏈接請求。細緻一點解釋,那就是同步鏈接時須要保證socket buffer和app buffer數據保持一致。一般以同步鏈接的方式處理時,監聽者和工做者是同一個進程,例如httpd的prefork模型。而異步鏈接則能夠在創建鏈接和數據交互的任何一個階段接收、處理其餘鏈接請求。一般,監聽者和工做者不是同一個進程時使用異步鏈接的方式,例如httpd的event模型,儘管worker模型中監聽者和工做者分開了,可是仍採用同步鏈接,監聽者將鏈接請求接入並建立了鏈接套接字後,當即交給工做線程,工做線程處理的過程當中一直只服務於該客戶端直到鏈接斷開,而event模式的異步也僅僅是在工做線程處理特殊的鏈接(如處於長鏈接狀態的鏈接)時,能夠將它交給監聽線程保管而已,對於正常的鏈接,它仍等價於同步鏈接的方式,所以httpd的event所謂異步,實際上是僞異步。通俗而不嚴謹地說,同步鏈接是一個進程/線程處理一個鏈接,異步鏈接是一個進程/線程處理多個鏈接

tcp鏈接和套接字的關係

先明確一點,每一個tcp鏈接的兩端都會關聯一個套接字和該套接字指向的文件描述符

前面說過,當服務端收到了ack消息後,就表示三次握手完成了,表示和客戶端的這個tcp鏈接已經創建好了。鏈接創建好的一開始,這個tcp鏈接會放在listen()打開的established queue隊列中等待accept()的消費。這個時候的tcp鏈接在服務端所關聯的套接字是listen套接字和它指向的文件描述符

當established queue中的tcp鏈接被accept()消費後,這個tcp鏈接就會關聯accept()所指定的套接字,並分配一個新的文件描述符。也就是說,通過accept()以後,這個鏈接和listen套接字已經沒有任何關係了。

換句話說,鏈接仍是那個鏈接,只不過服務端偷偷地換掉了這個tcp鏈接所關聯的套接字和文件描述符,而客戶端並不知道這一切。但這並不影響雙方的通訊,由於數據傳輸是基於鏈接而不是基於套接字的,只要能從文件描述符中將數據放入tcp鏈接這根"管道"裏,數據就能到達另外一端。

實際上,並不必定須要accept()才能進行tcp通訊,由於在accept()以前鏈接就以創建好了,只不過它關聯的是listen套接字對應的文件描述符,而這個套接字只識別三次握手和四次揮手涉及到的數據,並且這個套接字中的數據是由操做系統內核負責的。能夠想像一下,只有listen()沒有accept()時,客戶端不斷地發起connect(),服務端將一直將創建僅只鏈接而不作任何操做,直到listen的隊列滿了。

send()和recv()函數

send()函數是將數據從app buffer複製到send buffer中(固然,也可能直接從內核的kernel buffer中複製),recv()函數則是將recv buffer中的數據複製到app buffer中。固然,對於tcp套接字來講,更多的是使用write()和read()函數來發送、讀取socket buffer數據,這裏使用send()/recv()來講明僅僅只是它們的名稱針對性更強而已。

這兩個函數都涉及到了socket buffer,可是在調用send()或recv()時,複製的源buffer中是否有數據、複製的目標buffer中是否已滿而致使不可寫是須要考慮的問題。無論哪一方,只要不知足條件,調用send()/recv()時進程/線程會被阻塞(假設套接字設置爲阻塞式IO模型)。固然,能夠將套接字設置爲非阻塞IO模型,這時在buffer不知足條件時調用send()/recv()函數,調用函數的進程/線程將返回錯誤狀態信息EWOULDBLOCK或EAGAIN。buffer中是否有數據、是否已滿而致使不可寫,其實可使用select()/poll()/epoll去監控對應的文件描述符(對應socket buffer則監控該socket描述符),當知足條件時,再去調用send()/recv()就能夠正常操做了。還能夠將套接字設置爲信號驅動IO或異步IO模型,這樣數據準備好、複製好以前就不用再作無用功去調用send()/recv()了。

close()、shutdown()函數

通用的close()函數能夠關閉一個文件描述符,固然也包括面向鏈接的網絡套接字描述符。當調用close()時,將會嘗試發送send buffer中的全部數據。可是close()函數只是將這個套接字引用計數減1,就像rm同樣,刪除一個文件時只是移除一個硬連接數,只有這個套接字的全部引用計數都被刪除,套接字描述符纔會真的被關閉,纔會開始後續的四次揮手中。對於父子進程共享套接字的併發服務程序,調用close()關閉子進程的套接字並不會真的關閉套接字,由於父進程的套接字還處於打開狀態,若是父進程一直不調用close()函數,那麼這個套接字將一直處於打開狀態,將一直進入不了四次揮手過程。

而shutdown()函數專門用於關閉網絡套接字的鏈接,和close()對引用計數減一不一樣的是,它直接掐斷套接字的全部鏈接,從而引起四次揮手的過程。能夠指定3種關閉方式:

1.關閉寫。此時將沒法向send buffer中再寫數據,send buffer中已有的數據會一直髮送直到完畢。
2.關閉讀。此時將沒法從recv buffer中再讀數據,recv buffer中已有的數據只能被丟棄。
3.關閉讀和寫。此時沒法讀、沒法寫,send buffer中已有的數據會發送直到完畢,但recv buffer中已有的數據將被丟棄。

不管是shutdown()仍是close(),每次調用它們,在真正進入四次揮手的過程當中,它們都會發送一個FIN。

地址/端口重用技術

正常狀況下,一個addr+port只能被一個套接字綁定,換句話說,addr+port不能被重用,不一樣套接字只能綁定到不一樣的addr+port上。舉個例子,若是想要開啓兩個sshd實例,前後啓動的sshd實例配置文件中,必須不能配置一樣的addr+port。同理,配置web虛擬主機時,除非是基於域名,不然兩個虛擬主機必須不能配置同一個addr+port,而基於域名的虛擬主機能綁定同一個addr+port的緣由是http的請求報文中包含主機名信息,實際上在這類鏈接請求到達的時候,還是經過同一個套接字進行監聽的,只不過監聽到以後,httpd的工做進程/線程能夠將這個鏈接分配到對應的主機上。

既然上面說的是正常狀況下,固然就有非正常狀況,也就是地址重用和端口重用技術,組合起來就是套接字重用。在如今的Linux內核中,已經有支持地址重用的socket選項SO_REUSEADDR和支持端口重用的socket選項SO_REUSEPORT。設置了端口重用選項後,再去綁定套接字,就不會再有錯誤了。並且,一個實例綁定了兩個addr+port以後(能夠綁定多個,此處以兩個爲例),就能夠同一時刻使用兩個監聽進程/線程分別去監聽它們,客戶端發來的鏈接也就能夠經過round-robin的均衡算法輪流地被接待。

對於監聽進程/線程來講,每次重用的套接字被稱爲監聽桶(listener bucket),即每一個監聽套接字都是一個監聽桶。

以httpd的worker或event模型爲例,假設目前有3個子進程,每一個子進程中都有一個監聽線程和N個工做線程。

那麼,在沒有地址重用的狀況下,各個監聽線程是爭搶式監聽的。在某一時刻,這個監聽套接字上只能有一個監聽線程在監聽(經過獲取互斥鎖mutex方式獲取監聽資格),當這個監聽線程接收到請求後,讓出監聽的資格,因而其餘監聽線程去搶這個監聽資格,並只有一個線程能夠搶的到。以下圖:

當使用了地址重用和端口重用技術,就能夠爲同一個addr+port綁定多個套接字。例以下圖中是多使用一個監聽桶時,有兩個套接字,因而有兩個監聽線程能夠同時進行監聽,當某個監聽線程接收到請求後,讓出資格,讓其餘監聽線程去爭搶資格。

若是再多綁定一個套接字,那麼這三個監聽線程都不用讓出監聽資格,能夠無限監聽。以下圖。

彷佛感受上去,性能很好,不只減輕了監聽資格(互斥鎖)的爭搶,避免"飢餓問題",還能更高效地監聽,並由於能夠負載均衡,從而能夠減輕監聽線程的壓力。但實際上,每一個監聽線程的監聽過程都是須要消耗CPU的,若是隻有一核CPU,即便重用了也體現不出重用的優點,反而由於切換監聽線程而下降性能。所以,要使用端口重用,必須考慮是否已將各監聽進程/線程隔離在各自的cpu中,也就是說是否重用、重用幾回都需考慮cpu的核數以及是否將進程與cpu相互綁定。

暫時就先寫這麼多了。

相關文章
相關標籤/搜索