为什么服务端程序都需要先 listen 一下?
作者 |?張彥飛allen
來源 |?開發內功修煉
大家都知道,在創建一個服務器程序的時候,需要先 listen 一下,然后才能接收客戶端的請求。例如下面的這段代碼我們再熟悉不過了。
int?main(int?argc,?char?const?*argv[]) {int?fd?=?socket(AF_INET,?SOCK_STREAM,?0);bind(fd,?...);listen(fd,?128);accept(fd,?...);那么我們今天來思考一個問題,為什么需要 listen 一下才能接收連接?或者換句話說,listen 內部執行的時候到底干了啥?
如果你也想搞清楚 listen 內部的這些秘密,那么請跟我來!
一、創建 socket
服務器要做的第一件事就是先創建一個 socket。具體就是通過調用 socket 函數。當 socket 函數執行完畢后,在用戶層視角我們是看到返回了一個文件描述符 fd。但在內核中其實是一套內核對象組合,大體結構如下。
這里簡單了解這個結構就行,后面我們在源碼中看到函數指針調用的時候需要回頭再來看它。
二、內核執行 listen
2.1 listen 系統調用
我在 net/socket.c 下找到了 listen 系統調用的源碼。
//file:?net/socket.c SYSCALL_DEFINE2(listen,?int,?fd,?int,?backlog) {//根據?fd?查找?socket?內核對象sock?=?sockfd_lookup_light(fd,?&err,?&fput_needed);if?(sock)?{//獲取內核參數?net.core.somaxconnsomaxconn?=?sock_net(sock->sk)->core.sysctl_somaxconn;if?((unsigned?int)backlog?>?somaxconn)backlog?=?somaxconn;//調用協議棧注冊的?listen?函數err?=?sock->ops->listen(sock,?backlog);...... }用戶態的 socket 文件描述符只是一個整數而已,內核是沒有辦法直接用的。所以該函數中第一行代碼就是根據用戶傳入的文件描述符來查找到對應的 socket 內核對象。
再接著獲取了系統里的 net.core.somaxconn 內核參數的值,和用戶傳入的 backlog 比較后取一個最小值傳入到下一步中。
所以,雖然 listen 允許我們傳入 backlog(該值和半連接隊列、全連接隊列都有關系)。但是如果用戶傳入的比 net.core.somaxconn 還大的話是不會起作用的。
接著通過調用 sock->ops->listen 進入協議棧的 listen 函數。
2.2 協議棧 listen
這里我們需要用到第一節中的 socket 內核對象結構圖了,通過它我們可以看出 sock->ops->listen 實際執行的是 inet_listen。
//file:?net/ipv4/af_inet.c int?inet_listen(struct?socket?*sock,?int?backlog) {//還不是?listen?狀態(尚未?listen?過)if?(old_state?!=?TCP_LISTEN)?{//開始監聽err?=?inet_csk_listen_start(sk,?backlog);}//設置全連接隊列長度sk->sk_max_ack_backlog?=?backlog; }在這里我們先看一下最底下這行,sk->sk_max_ack_backlog 是全連接隊列的最大長度。所以這里我們就知道了一個關鍵技術點,服務器的全連接隊列長度是 listen 時傳入的 backlog 和 net.core.somaxconn 之間較小的那個值。
如果你在線上遇到了全連接隊列溢出的問題,想加大該隊列長度,那么可能需要同時考慮 listen 時傳入的 backlog 和 net.core.somaxconn。
再回過頭看 inet_csk_listen_start 函數。
//file:?net/ipv4/inet_connection_sock.c int?inet_csk_listen_start(struct?sock?*sk,?const?int?nr_table_entries) {struct?inet_connection_sock?*icsk?=?inet_csk(sk);//icsk->icsk_accept_queue?是接收隊列,詳情見?2.3?節?//接收隊列內核對象的申請和初始化,詳情見?2.4節?int?rc?=?reqsk_queue_alloc(&icsk->icsk_accept_queue,?nr_table_entries);...... }在函數一開始,將 struct sock 對象強制轉換成了 inet_connection_sock,名叫 icsk。
這里簡單說下為什么可以這么強制轉換,這是因為 inet_connection_sock 是包含 sock 的。tcp_sock、inet_connection_sock、inet_sock、sock 是逐層嵌套的關系,類似面向對象里的繼承的概念。
對于 TCP 的 socket 來說,sock 對象實際上是一個 tcp_sock。因此 TCP 中的 sock 對象隨時可以強制類型轉化為 tcp_sock、inet_connection_sock、inet_sock 來使用。
在接下來的一行 reqsk_queue_alloc 中實際上包含了兩件重要的事情。一是接收隊列數據結構的定義。二是接收隊列的申請和初始化。這兩塊都比較重要,我們分別在 2.3 節,和 2.4 節介紹。
2.3 接收隊列定義
icsk->icsk_accept_queue 定義在 inet_connection_sock 下,是一個 request_sock_queue 類型的對象。是內核用來接收客戶端請求的主要數據結構。我們平時說的全連接隊列、半連接隊列全部都是在這個數據結構里實現的。
我們來看具體的代碼。
//file:?include/net/inet_connection_sock.h struct?inet_connection_sock?{/*?inet_sock?has?to?be?the?first?member!?*/struct?inet_sock???icsk_inet;struct?request_sock_queue?icsk_accept_queue;...... }我們再來查找到 request_sock_queue 的定義,如下。
//file:?include/net/request_sock.h struct?request_sock_queue?{//全連接隊列struct?request_sock?*rskq_accept_head;struct?request_sock?*rskq_accept_tail;//半連接隊列struct?listen_sock?*listen_opt;...... };對于全連接隊列來說,在它上面不需要進行復雜的查找工作,accept 的時候只是先進先出地接受就好了。所以全連接隊列通過 rskq_accept_head 和 rskq_accept_tail 以鏈表的形式來管理。
和半連接隊列相關的數據對象是 listen_opt,它是 listen_sock 類型的。
//file:? struct?listen_sock?{u8???max_qlen_log;u32???nr_table_entries;......struct?request_sock?*syn_table[0]; };因為服務器端需要在第三次握手時快速地查找出來第一次握手時留存的 request_sock 對象,所以其實是用了一個 hash 表來管理,就是 struct request_sock *syn_table[0]。max_qlen_log 和 nr_table_entries 都是和半連接隊列的長度有關。
2.4 接收隊列申請和初始化
了解了全/半連接隊列數據結構以后,讓我們再回到 inet_csk_listen_start 函數中。它調用了 reqsk_queue_alloc 來申請和初始化 icsk_accept_queue 這個重要對象。
//file:?net/ipv4/inet_connection_sock.c int?inet_csk_listen_start(struct?sock?*sk,?const?int?nr_table_entries) {...int?rc?=?reqsk_queue_alloc(&icsk->icsk_accept_queue,?nr_table_entries);... }在 reqsk_queue_alloc 這個函數中完成了接收隊列 request_sock_queue 內核對象的創建和初始化。其中包括內存申請、半連接隊列長度的計算、全連接隊列頭的初始化等等。
讓我們進入它的源碼:
//file:?net/core/request_sock.c int?reqsk_queue_alloc(struct?request_sock_queue?*queue,unsigned?int?nr_table_entries) {size_t?lopt_size?=?sizeof(struct?listen_sock);struct?listen_sock?*lopt;//計算半連接隊列的長度nr_table_entries?=?min_t(u32,?nr_table_entries,?sysctl_max_syn_backlog);nr_table_entries?=?......//為?listen_sock?對象申請內存,這里包含了半連接隊列lopt_size?+=?nr_table_entries?*?sizeof(struct?request_sock?*);if?(lopt_size?>?PAGE_SIZE)lopt?=?vzalloc(lopt_size);elselopt?=?kzalloc(lopt_size,?GFP_KERNEL);//全連接隊列頭初始化queue->rskq_accept_head?=?NULL;//半連接隊列設置lopt->nr_table_entries?=?nr_table_entries;queue->listen_opt?=?lopt;...... }開頭定義了一個 struct listen_sock 指針。這個 listen_sock 就是我們平時經常說的半連接隊列。
接下來計算半連接隊列的長度。計算出來了實際大小以后,開始申請內存。最后將全連接隊列頭 queue->rskq_accept_head 設置成了 NULL,將半連接隊列掛到了接收隊列 queue 上。
這里要注意一個細節,半連接隊列上每個元素分配的是一個指針大小(sizeof(struct request_sock *))。這其實是一個 Hash 表。真正的半連接用的 request_sock 對象是在握手過程中分配,計算完 Hash 值后掛到這個 Hash 表 上。
2.5 半連接隊列長度計算
在上一小節,我們提到 reqsk_queue_alloc 函數中計算了半連接隊列的長度,由于這個有點小復雜,所以我們單獨拉一個小節討論這個。
//file:?net/core/request_sock.c int?reqsk_queue_alloc(struct?request_sock_queue?*queue,unsigned?int?nr_table_entries) {//計算半連接隊列的長度nr_table_entries?=?min_t(u32,?nr_table_entries,?sysctl_max_syn_backlog);nr_table_entries?=?max_t(u32,?nr_table_entries,?8);nr_table_entries?=?roundup_pow_of_two(nr_table_entries?+?1);//為了效率,不記錄?nr_table_entries//而是記錄?2?的幾次冪等于?nr_table_entriesfor?(lopt->max_qlen_log?=?3;(1?<<?lopt->max_qlen_log)?<?nr_table_entries;lopt->max_qlen_log++);...... }傳進來的 nr_table_entries 在最初調用 reqsk_queue_alloc 的地方可以看到,它是內核參數 net.core.somaxconn 和用戶調用 listen 時傳入的 backlog 二者之間的較小值。
在這個 reqsk_queue_alloc 函數里,又將會完成三次的對比和計算。
min_t(u32, nr_table_entries, sysctl_max_syn_backlog) 這個是再次和 sysctl_max_syn_backlog 內核對象又取了一次最小值。
max_t(u32, nr_table_entries, 8) 這句保證 nr_table_entries 不能比 8 小,這是用來避免新手用戶傳入一個太小的值導致無法建立連接使用的。
roundup_pow_of_two(nr_table_entries + 1) 是用來上對齊到 2 的整數冪次的。
說到這兒,你可能已經開始頭疼了。確實這樣的描述是有點抽象。咱們換個方法,通過兩個實際的 Case 來計算一下。
假設:某服務器上內核參數 net.core.somaxconn 為 128, net.ipv4.tcp_max_syn_backlog 為 8192。那么當用戶 backlog 傳入 5 時,半連接隊列到底是多長呢?
和代碼一樣,我們還把計算分為四步,最終結果為 16。
min (backlog, somaxconn) ?= min (5, 128) = 5
min (5, tcp_max_syn_backlog) = min (5, 8192) = 5
max (5, 8) = 8
roundup_pow_of_two (8 + 1) = 16
somaxconn 和 tcp_max_syn_backlog 保持不變,listen 時的 backlog 加大到 512,再算一遍,結果為 256。
min (backlog, somaxconn) ?= min (512, 128) = 128
min (128, tcp_max_syn_backlog) = min (128, 8192) = 128
max (128, 8) = 128
roundup_pow_of_two (128 + 1) = 256
算到這里,我把半連接隊列長度的計算歸納成了一句話,半連接隊列的長度是 min(backlog, somaxconn, tcp_max_syn_backlog) + 1 再上取整到 2 的冪次,但最小不能小于16。 我用的內核源碼是 3.10, 你手頭的內核版本可能和這個稍微有些出入。
如果你在線上遇到了半連接隊列溢出的問題,想加大該隊列長度,那么就需要同時考慮 somaxconn、backlog、和 tcp_max_syn_backlog 三個內核參數。
最后再說一點,為了提升比較性能,內核并沒有直接記錄半連接隊列的長度。而是采用了一種晦澀的方法,只記錄其冪次假設隊列長度為 16,則記錄 max_qlen_log 為 4 (2 的 4 次方等于 16),假設隊列長度為 256,則記錄 max_qlen_log 為 8 (2 的 8 次方等于 16)。大家只要知道這個東東就是為了提升性能的就行了。
最后,總結一下
計算機系的學生就像背八股文一樣記著服務器端 socket 程序流程:先 bind、再 listen、然后才能 accept。至于為什么需要先 listen 一下才可以 accpet,似乎我們很少去關注。
通過今天對 listen 源碼的簡單瀏覽,我們發現 listen 最主要的工作就是申請和初始化接收隊列,包括全連接隊列和半連接隊列。其中全連接隊列是一個鏈表,而半連接隊列由于需要快速的查找,所以使用的是一個哈希表(其實半連接隊列更準確的的叫法應該叫半連接哈希表)。
全/半兩個隊列是三次握手中很重要的兩個數據結構,有了它們服務器才能正常響應來自客戶端的三次握手。所以服務器端都需要 listen 一下才行。
除此之外我們還有額外收獲,我們還知道了內核是如何確定全/半連接隊列的長度的。
1.全連接隊列的長度
對于全連接隊列來說,其最大長度是 listen 時傳入的 backlog 和 net.core.somaxconn 之間較小的那個值。如果需要加大全連接隊列長度,那么就是調整 backlog 和 somaxconn。
2.半連接隊列的長度
在 listen 的過程中,內核我們也看到了對于半連接隊列來說,其最大長度是 min(backlog, somaxconn, tcp_max_syn_backlog) + 1 再上取整到 2 的冪次,但最小不能小于16。如果需要加大半連接隊列長度,那么需要一并考慮 backlog,somaxconn 和 tcp_max_syn_backlog 這三個參數。網上任何告訴你修改某一個參數就能提高半連接隊列長度的文章都是錯的。
所以,不放過一個細節,你可能會有意想不到的收獲!
往期推薦
如果讓你來設計網絡
70% 開發者對云原生一知半解,“云深”如何知處?
淺述 Docker 的容器編排
如何在 Kubernetes Pod 內進行網絡抓包
點分享
點收藏
點點贊
點在看
總結
以上是生活随笔為你收集整理的为什么服务端程序都需要先 listen 一下?的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 彻底理解内存泄漏,memory leak
- 下一篇: Gartner:2022年全球IT支出将