天天看點

socket怎麼同時監聽兩個端口_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()等待兩個隊列觸發可寫事件。當已完成連接配接隊列滿了,則監聽者也不會接收新的連接配接請求,同時,正準備移入到已完成連接配接隊列的動作被阻塞。在Linux 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連接配接的套接字更注重收、發的資料大小。

[[email protected] ~]# netstat -tnlActive 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[[email protected] ~]# ss -tnlState Recv-Q Send-Q Local Address:Port Peer Address:PortLISTEN 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互相綁定。