爲什麼服務端程序都需要先 listen 一下?

大家好,我是飛哥。飛哥在北京搖號 9 年多,最近終於算是搞下來個北京的電動車牌,其中的艱難過程寫個一萬字估計都寫不完。不管咋說,新能源也是車,總算是有車能開了。這幾天買車賣車(外地牌)忙的團團轉。不過無論多忙,硬核文章仍然不能停!

大家都知道,在創建一個服務器程序的時候,需要先 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.somaxconn
  somaxconn = 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);
 else
  lopt = 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_entries
 for (lopt->max_qlen_log = 3;
      (<< 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 函數里,又將會完成三次的對比和計算。

說到這兒,你可能已經開始頭疼了。確實這樣的描述是有點抽象。咱們換個方法,通過兩個實際的 Case 來計算一下。

假設:某服務器上內核參數 net.core.somaxconn 爲 128, net.ipv4.tcp_max_syn_backlog 爲 8192。那麼當用戶 backlog 傳入 5 時,半連接隊列到底是多長呢?

和代碼一樣,我們還把計算分爲四步,最終結果爲 16

  1. min (backlog, somaxconn)  = min (5, 128) = 5

  2. min (5, tcp_max_syn_backlog) = min (5, 8192) = 5

  3. max (5, 8) = 8

  4. roundup_pow_of_two (8 + 1) = 16

somaxconn 和 tcp_max_syn_backlog 保持不變,listen 時的 backlog 加大到 512,再算一遍,結果爲 256

  1. min (backlog, somaxconn)  = min (512, 128) = 128

  2. min (128, tcp_max_syn_backlog) = min (128, 8192) = 128

  3. max (128, 8) = 128

  4. 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 這三個參數。網上任何告訴你修改某一個參數就能提高半連接隊列長度的文章都是錯的。

所以,不放過一個細節,你可能會有意想不到的收穫!

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/U1yKfmC_uy5-Ipn79DGyHg