Socket和TCP連線過程解析

安全劍客發表於2019-09-15
本文主要說明的是TCP連線過程中,各個階段對套接字的操作,希望能對沒有網路程式設計基礎的人理解套接字是什麼、扮演的角色有所幫助。

Socket和TCP連線過程解析Socket和TCP連線過程解析

一. 背景
1.完整的套接字格式{protocol,src_addr,src_port,dest_addr,dest_port}。

這常被稱為套接字的五元組。其中protocol指定了是TCP還是UDP連線,其餘的分別指定了源地址、源埠、目標地址、目標埠。但是這些內容是怎麼來的呢?

2.TCP協議棧維護著兩個socket緩衝區:send buffer和recv buffer。

要透過TCP連線傳送出去的資料都先複製到send buffer,可能是從使用者空間程式的app buffer拷入的,也可能是從核心的kernel buffer拷入的,拷入的過程是透過send()函式完成的,由於也可以使用write()函式寫入資料,所以也把這個過程稱為寫資料,相應的send buffer也就有了別稱write buffer。不過send()函式比write()函式更有效率。

最終資料是透過網路卡流出去的,所以send buffer中的資料需要複製到網路卡中。由於一端是記憶體,一端是網路卡裝置,可以直接使用DMA的方式進行複製,無需CPU的參與。也就是說,send buffer中的資料透過DMA的方式複製到網路卡中並透過網路傳輸給TCP連線的另一端:接收端。

當透過TCP連線接收資料時,資料肯定是先透過網路卡流入的,然後同樣透過DMA的方式複製到recv buffer中,再透過recv()函式將資料從recv buffer拷入到使用者空間程式的app buffer中。

3.兩種套接字:監聽套接字和已連線套接字。

監聽套接字是在服務程式讀取配置檔案時,從配置檔案中解析出要監聽的地址、埠,然後透過socket()函式建立的,然後再透過bind()函式將這個監聽套接字繫結到對應的地址和埠上。隨後,程式/執行緒就可以透過listen()函式來監聽這個埠(嚴格地說是監控這個監聽套接字)。

已連線套接字是在監聽到TCP連線請求並三次握手後,透過accept()函式返回的套接字,後續程式/執行緒就可以透過這個已連線套接字和客戶端進行TCP通訊。

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

下面就來說明各種函式的作用,分析這些函式,也是在連線、斷開連線的過程。

二. 連線的具體過程分析
2.1 socket()函式

socket()函式的作用就是生成一個用於通訊的套接字檔案描述符sockfd(socket() creates an endpoint for communication and returns a descriptor)。這個套接字描述符可以作為稍後bind()函式的繫結物件。

2.2 bind()函式

服務程式透過分析配置檔案,從中解析出想要監聽的地址和埠,再加上可以透過socket()函式生成的套接字sockfd,就可以使用bind()函式將這個套接字繫結到要監聽的地址和埠組合"addr:port"上。繫結了埠的套接字可以作為listen()函式的監聽物件。

繫結了地址和埠的套接字就有了源地址和源埠(對伺服器自身來說是源),再加上透過配置檔案中指定的協議型別,五元組中就有了其中3個元組。即:

{protocal,src_addr,src_port}

但是,常見到有些服務程式可以配置監聽多個地址、埠實現多例項。這實際上就是透過多次socket()+bind()系統呼叫生成並繫結多個套接字實現的。

2.3 listen()函式和connect()函式

顧名思義,listen()函式就是監聽已經透過bind()繫結了addr+port的套接字的。監聽之後,套接字就從CLOSE狀態轉變為LISTEN狀態,於是這個套接字就可以對外提供TCP連線的視窗了。

而connect()函式則用於向某個已監聽的套接字發起連線請求,也就是發起TCP的三次握手過程。從這裡可以看出,連線請求方(如客戶端)才會使用connect()函式,當然,在發起connect()之前,連線發起方也需要生成一個sockfd,且使用的很可能是繫結了隨機埠的套接字。既然connect()函式是向某個套接字發起連線的,自然在使用connect()函式時需要帶上連線的目的地,即目標地址和目標埠,這正是服務端的監聽套接字上繫結的地址和埠。同時,它還要帶上自己的地址和埠,對於服務端來說,這就是連線請求的源地址和源埠。於是,TCP連線的兩端的套接字都已經成了五元組的完整格式。

2.3.1 深入分析listen()

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

不管使用select()還是poll()模式(至於epoll的不同監控方式就無需多言了),在程式/執行緒(監聽者)監聽的過程中,它阻塞在select()或poll()上。直到有資料(SYN資訊)寫入到它所監聽的sockfd中(即recv buffer),監聽者被喚醒並將SYN資料複製到使用者空間中自己管理的app buffer中進行一番處理,併傳送SYN+ACK,這個資料同樣需要從app buffer中拷入send buffer(使用send()函式)中,再拷入網路卡傳送出去。這時會在連線未完成佇列中為這個連線建立一個新專案,並設定為SYN_RECV狀態。然後再次使用select()/poll()方式監控著套接字listenfd,直到再次有資料寫入這個listenfd中監聽者才被喚醒,如果這次寫入的資料是ACK資訊,則將資料拷入到app buffer中進行一番處理後,把連線未完成佇列中對應的專案移入連線已完成佇列,並設定為ESTABLISHED狀態,如果這次接收的不是ACK,則肯定是SYN,也就是新的連線請求,於是和上面的處理過程一樣,放入連線未完成佇列。這就是監聽者處理整個TCP連線的迴圈過程。

也就是說,listen()函式還維護了兩個佇列:連線未完成佇列和連線已完成佇列。當監聽者接收到某個客戶端發來的SYN並回復了SYN+ACK之後,就會在未完成連線佇列的尾部建立一個關於這個客戶端的條目,並設定它的狀態為SYN_RECV。顯然,這個條目中必須包含客戶端的地址和埠相關資訊(可能是hash過的,我不太確定)。當服務端再次收到這個客戶端傳送的ACK資訊之後,監聽者執行緒透過分析資料就知道這個訊息是回覆給未完成連線佇列中的哪一項的,於是將這一項移入到已完成連線佇列,並設定它的狀態為ESTABLISHED。

當未完成連線佇列滿了,監聽者被阻塞不再接收新的連線請求,並透過select()/poll()等待兩個佇列觸發可寫事件。當已完成連線佇列滿了,則監聽者也不會接收新的連線請求,同時,正準備移入到已完成連線佇列的動作被阻塞。在  2.2以前,listen()函式有一個backlog的引數,用於設定這兩個佇列的最大總長度,從Linux 2.2開始,這個引數只表示已完成佇列的最大長度,而/proc/sys/net/ipv4/tcp_max_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請求資料。

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,即已完成佇列中當前的連線個數,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。

2.3.2 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選項的任何一種方法都不能很好兼顧效能和效率。所以在連線到達監聽者執行緒之前對資料包進行過濾是極其重要的手段。

2.4 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所謂非同步,其實是偽非同步。通俗而不嚴謹地說,同步連線是一個程式/執行緒處理一個連線,非同步連線是一個程式/執行緒處理多個連線。

2.5 send()和recv()函式

send()函式是將資料從app buffer複製到send buffer中(當然,也可能直接從核心的kernel buffer中複製),recv()函式則是將recv buffer中的資料複製到app buffer中。當然,使用write()和read()函式替代它們並沒有什麼不可以,只是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()了。

2.6 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相互繫結。

原文地址:

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31559985/viewspace-2657051/,如需轉載,請註明出處,否則將追究法律責任。

相關文章