TCP 三次握手源碼解析

之前我的圖解網絡系列,寫了很多關於 TCP 的圖解文章,很多同學看完後都跟我說,每次面試的時候,TCP 部分都能聊跨面試官。

但是對於 TCP 三次握手的源碼分析,我還沒寫過。

今天就跟大家來嘮嗑下,TCP 三次握手的源碼,看看他到底做什麼?

在後端相關崗位的入職面試中,三次握手的出場頻率非常的高,甚至說它是必考題也不爲過。一般的答案都是說客戶端如何發起 SYN 握手進入 SYN_SENT 狀態,服務器響應 SYN 並回復 SYNACK,然後進入 SYN_RECV,...... , 吧啦吧啦諸如此類。

但我今天想給出一份不一樣的答案。其實三次握手在內核的實現中,並不只是簡單的狀態的流轉,還包括半連接隊列、syncookie、全連接隊列、重傳計時器等關鍵操作。如果能深刻理解這些,你對線上把握和理解將更進一步。如果有面試官問起你三次握手,相信這份答案一定能幫你在面試官面前贏得非常多的加分。

在基於 TCP 的服務開發中,三次握手的主要流程圖如下。

服務器中的核心代碼是創建 socket,綁定端口,listen 監聽,最後 accept 接收客戶端的請求。

//服務端核心代碼
int main(int argc, char const *argv[])
{
 int fd = socket(AF_INET, SOCK_STREAM, 0);
 bind(fd, ...);
 listen(fd, 128);
 accept(fd, ...);
 ...
}

客戶端的相關代碼是創建 socket,然後調用 connect 連接 server。

//客戶端核心代碼
int main(){
 fd = socket(AF_INET,SOCK_STREAM, 0);
 connect(fd, ...);
 ...
}

圍繞這個三次握手圖,以及客戶端,服務端的核心代碼,我們來深度探索一下三次握手過程中的內部操作。我們從和三次握手過程關係比較大的 listen 講起!

友情提示:本文中內核源碼會比較多。如果你能理解的了更好,如果覺得理解起來有困難,那直接重點看本文中的描述性的文字,尤其是加粗部分的即可。另外文章最後有一張總結圖歸納和整理了全文內容。

一、服務器的 listen

我們都知道,服務器在開始提供服務之前都需要先 listen 一下。但 listen 內部究竟幹了啥,我們平時很少去琢磨。

今天就讓我們詳細來看看,直接上一段 listen 時執行到的內核代碼。

//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 = ......

 //爲半連接隊列申請內存
 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;
 ......
}

在這段代碼裏,內核計算了半連接隊列的長度。然後據此算出半連接隊列所需要的實際內存大小,開始申請用於管理半連接隊列對象的內存(半連接隊列需要快速查找,所以內核是用哈希表來管理半連接隊列的,具體在 listen_sock 下的 syn_table 下)。最後將半連接隊列掛到了接收隊列 queue 上。

另外 queue->rskq_accept_head 代表的是全連接隊列,它是一個鏈表的形式。在 listen 這裏因爲還沒有連接,所以將全連接隊列頭 queue->rskq_accept_head 設置成 NULL。

當全連接隊列和半連接隊列中有元素的時候,他們在內核中的結構圖大致如下。

在服務器 listen 的時候,主要是進行了全 / 半連接隊列的長度限制計算,以及相關的內存申請和初始化。全 / 連接隊列初始化了以後纔可以相應來自客戶端的握手請求。

二、客戶端 connect

客戶端通過調用 connect 來發起連接。在 connect 系統調用中會進入到內核源碼的 tcp_v4_connect。

//file: net/ipv4/tcp_ipv4.c
int tcp_v4_connect(struct sock *sk, struct sockaddr *uaddr, int addr_len)
{
 //設置 socket 狀態爲 TCP_SYN_SENT
 tcp_set_state(sk, TCP_SYN_SENT);

 //動態選擇一個端口
 err = inet_hash_connect(&tcp_death_row, sk);

 //函數用來根據 sk 中的信息,構建一個完成的 syn 報文,並將它發送出去。
 err = tcp_connect(sk);
}

在這裏將完成把 socket 狀態設置爲 TCP_SYN_SENT。再通過 inet_hash_connect 來動態地選擇一個可用的端口後,進入到 tcp_connect 中。

//file:net/ipv4/tcp_output.c
int tcp_connect(struct sock *sk)
{
 tcp_connect_init(sk);

 //申請 skb 並構造爲一個 SYN 包
 ......

 //添加到發送隊列 sk_write_queue 上
 tcp_connect_queue_skb(sk, buff);

 //實際發出 syn
 err = tp->fastopen_req ? tcp_send_syn_data(sk, buff) :
    tcp_transmit_skb(sk, buff, 1, sk->sk_allocation);

 //啓動重傳定時器
 inet_csk_reset_xmit_timer(sk, ICSK_TIME_RETRANS,
      inet_csk(sk)->icsk_rto, TCP_RTO_MAX);
}

在 tcp_connect 申請和構造 SYN 包,然後將其發出。同時還啓動了一個重傳定時器,該定時器的作用是等到一定時間後收不到服務器的反饋的時候來開啓重傳。在 3.10 版本中首次超時時間是 1 s,一些老版本中是 3 s。

總結一下,客戶端在 connect 的時候,把本地 socket 狀態設置成了 TCP_SYN_SENT,選了一個可用的端口,接着發出 SYN 握手請求並啓動重傳定時器

三、服務器響應 SYN

在服務器端,所有的 TCP 包(包括客戶端發來的 SYN 握手請求)都經過網卡、軟中斷,進入到 tcp_v4_rcv。在該函數中根據網絡包(skb)TCP 頭信息中的目的 IP 信息查到當前在 listen 的 socket。然後繼續進入 tcp_v4_do_rcv 處理握手過程。

//file: net/ipv4/tcp_ipv4.c
int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)
{
 ...
 //服務器收到第一步握手 SYN 或者第三步 ACK 都會走到這裏
 if (sk->sk_state == TCP_LISTEN) {
  struct sock *nsk = tcp_v4_hnd_req(sk, skb);
 }

 if (tcp_rcv_state_process(sk, skb, tcp_hdr(skb), skb->len)) {
  rsk = sk;
  goto reset;
 }
}

在 tcp_v4_do_rcv 中判斷當前 socket 是 listen 狀態後,首先會到 tcp_v4_hnd_req 去查看半連接隊列。服務器第一次響應 SYN 的時候,半連接隊列裏必然是空空如也,所以相當於什麼也沒幹就返回了。

//file:net/ipv4/tcp_ipv4.c
static struct sock *tcp_v4_hnd_req(struct sock *sk, struct sk_buff *skb)
{
 // 查找 listen socket 的半連接隊列
 struct request_sock *req = inet_csk_search_req(sk, &prev, th->source,
          iph->saddr, iph->daddr);
 ...
 return sk;
}

在 tcp_rcv_state_process 里根據不同的 socket 狀態進行不同的處理。

//file:net/ipv4/tcp_input.c
int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb,
     const struct tcphdr *th, unsigned int len)
{
 switch (sk->sk_state) {
  //第一次握手
  case TCP_LISTEN:
   if (th->syn) { //判斷是 SYN 握手包
    ...
    if (icsk->icsk_af_ops->conn_request(sk, skb) < 0)
     return 1;
 ......
}

其中 conn_request 是一個函數指針,指向 tcp_v4_conn_request。服務器響應 SYN 的主要處理邏輯都在這個 tcp_v4_conn_request 裏

//file: net/ipv4/tcp_ipv4.c
int tcp_v4_conn_request(struct sock *sk, struct sk_buff *skb)
{
 //看看半連接隊列是否滿了
 if (inet_csk_reqsk_queue_is_full(sk) && !isn) {
  want_cookie = tcp_syn_flood_action(sk, skb, "TCP");
  if (!want_cookie)
   goto drop;
 }

 //在全連接隊列滿的情況下,如果有 young_ack,那麼直接丟
 if (sk_acceptq_is_full(sk) && inet_csk_reqsk_queue_young(sk) > 1) {
  NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS);
  goto drop;
 }
 ...
 //分配 request_sock 內核對象
 req = inet_reqsk_alloc(&tcp_request_sock_ops);

 //構造 syn+ack 包
 skb_synack = tcp_make_synack(sk, dst, req,
  fastopen_cookie_present(&valid_foc) ? &valid_foc : NULL);

 if (likely(!do_fastopen)) {
  //發送 syn + ack 響應
  err = ip_build_and_send_pkt(skb_synack, sk, ireq->loc_addr,
    ireq->rmt_addr, ireq->opt);

  //添加到半連接隊列,並開啓計時器
  inet_csk_reqsk_queue_hash_add(sk, req, TCP_TIMEOUT_INIT);
 }else ...
}

在這裏首先判斷半連接隊列是否滿了,如果滿了的話進入 tcp_syn_flood_action 去判斷是否開啓了 tcp_syncookies 內核參數。如果隊列滿,且未開啓 tcp_syncookies,那麼該握手包將直接被丟棄!!

接着還要判斷全連接隊列是否滿。因爲全連接隊列滿也會導致握手異常的,那乾脆就在第一次握手的時候也判斷了。如果全連接隊列滿了,且有 young_ack 的話,那麼同樣也是直接丟棄。

young_ack 是半連接隊列裏保持着的一個計數器。記錄的是剛有 SYN 到達,沒有被 SYN_ACK 重傳定時器重傳過 SYN_ACK,同時也沒有完成過三次握手的 sock 數量

接下來是構造 synack 包,然後通過 ip_build_and_send_pkt 把它發送出去。

最後把當前握手信息添加到半連接隊列,並開啓計時器。計時器的作用是如果某個時間之內還收不到客戶端的第三次握手的話,服務器會重傳 synack 包。

總結一下,服務器響應 ack 是主要工作是判斷下接收隊列是否滿了,滿的話可能會丟棄該請求,否則發出 synack。申請 request_sock 添加到半連接隊列中,同時啓動定時器

四、客戶端響應 SYNACK

客戶端收到服務器端發來的 synack 包的時候,也會進入到 tcp_rcv_state_process 函數中來。不過由於自身 socket 的狀態是 TCP_SYN_SENT,所以會進入到另一個不同的分支中去。

//file:net/ipv4/tcp_input.c
//除了 ESTABLISHED 和 TIME_WAIT,其他狀態下的 TCP 處理都走這裏
int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb,
     const struct tcphdr *th, unsigned int len)
{
 switch (sk->sk_state) {
  //服務器收到第一個ACK包
  case TCP_LISTEN:
   ...
  //客戶端第二次握手處理 
  case TCP_SYN_SENT:
   //處理 synack 包
   queued = tcp_rcv_synsent_state_process(sk, skb, th, len);
   ...
   return 0;
}

tcp_rcv_synsent_state_process 是客戶端響應 synack 的主要邏輯。

//file:net/ipv4/tcp_input.c
static int tcp_rcv_synsent_state_process(struct sock *sk, struct sk_buff *skb,
      const struct tcphdr *th, unsigned int len)
{
 ...

 tcp_ack(sk, skb, FLAG_SLOWPATH);

 //連接建立完成 
 tcp_finish_connect(sk, skb);

 if (sk->sk_write_pending ||
   icsk->icsk_accept_queue.rskq_defer_accept ||
   icsk->icsk_ack.pingpong)
  //延遲確認...
 else {
  tcp_send_ack(sk);
 }
}

tcp_ack()->tcp_clean_rtx_queue()

//file: net/ipv4/tcp_input.c
static int tcp_clean_rtx_queue(struct sock *sk, int prior_fackets,
       u32 prior_snd_una)
{
 //刪除發送隊列
 ...

 //刪除定時器
 tcp_rearm_rto(sk);
}
//file: net/ipv4/tcp_input.c
void tcp_finish_connect(struct sock *sk, struct sk_buff *skb)
{
 //修改 socket 狀態
 tcp_set_state(sk, TCP_ESTABLISHED);

 //初始化擁塞控制
 tcp_init_congestion_control(sk);
 ...

 //保活計時器打開
 if (sock_flag(sk, SOCK_KEEPOPEN))
  inet_csk_reset_keepalive_timer(sk, keepalive_time_when(tp));
}

客戶端修改自己的 socket 狀態爲 ESTABLISHED,接着打開 TCP 的保活計時器。

//file:net/ipv4/tcp_output.c
void tcp_send_ack(struct sock *sk)
{
 //申請和構造 ack 包
 buff = alloc_skb(MAX_TCP_HEADER, sk_gfp_atomic(sk, GFP_ATOMIC));
 ...

 //發送出去
 tcp_transmit_skb(sk, buff, 0, sk_gfp_atomic(sk, GFP_ATOMIC));
}

在 tcp_send_ack 中構造 ack 包,並把它發送了出去。

客戶端響應來自服務器端的 synack 時清除了 connect 時設置的重傳定時器,把當前 socket 狀態設置爲 ESTABLISHED,開啓保活計時器後發出第三次握手的 ack 確認。

五、服務器響應 ACK

服務器響應第三次握手的 ack 時同樣會進入到 tcp_v4_do_rcv

//file: net/ipv4/tcp_ipv4.c
int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)
{
 ...
 if (sk->sk_state == TCP_LISTEN) {
  struct sock *nsk = tcp_v4_hnd_req(sk, skb);
 }

 if (tcp_rcv_state_process(sk, skb, tcp_hdr(skb), skb->len)) {
  rsk = sk;
  goto reset;
 }
}

不過由於這已經是第三次握手了,半連接隊列裏會存在上次第一次握手時留下的半連接信息。所以 tcp_v4_hnd_req 的執行邏輯會不太一樣。

//file:net/ipv4/tcp_ipv4.c
static struct sock *tcp_v4_hnd_req(struct sock *sk, struct sk_buff *skb)
{
 ...
 struct request_sock *req = inet_csk_search_req(sk, &prev, th->source,
          iph->saddr, iph->daddr);
 if (req)
  return tcp_check_req(sk, skb, req, prev, false);
 ...
}

inet_csk_search_req 負責在半連接隊列裏進行查找,找到以後返回一個半連接 request_sock 對象。然後進入到 tcp_check_req 中。

//file:net/ipv4/tcp_minisocks.c
struct sock *tcp_check_req(struct sock *sk, struct sk_buff *skb,
      struct request_sock *req,
      struct request_sock **prev,
      bool fastopen)
{
 ...
 //創建子 socket
 child = inet_csk(sk)->icsk_af_ops->syn_recv_sock(sk, skb, req, NULL);
 ...

 //清理半連接隊列
 inet_csk_reqsk_queue_unlink(sk, req, prev);
 inet_csk_reqsk_queue_removed(sk, req);

 //添加全連接隊列
 inet_csk_reqsk_queue_add(sk, req, child);
 return child;
}

5.1 創建子 socket

icsk_af_ops->syn_recv_sock 對應的是 tcp_v4_syn_recv_sock 函數。

//file:net/ipv4/tcp_ipv4.c
const struct inet_connection_sock_af_ops ipv4_specific = {
 ......
 .conn_request      = tcp_v4_conn_request,
 .syn_recv_sock     = tcp_v4_syn_recv_sock,

//三次握手接近就算是完畢了,這裏創建 sock 內核對象
struct sock *tcp_v4_syn_recv_sock(struct sock *sk, struct sk_buff *skb,
      struct request_sock *req,
      struct dst_entry *dst)
{    
 //判斷接收隊列是不是滿了
 if (sk_acceptq_is_full(sk))
  goto exit_overflow;

 //創建 sock && 初始化
 newsk = tcp_create_openreq_child(sk, req, skb);

** 注意,在第三次握手的這裏又繼續判斷一次全連接隊列是否滿了,如果滿了修改一下計數器就丟棄了。** 如果隊列不滿,那麼就申請創建新的 sock 對象。

5.2 刪除半連接隊列

把連接請求塊從半連接隊列中刪除。

//file: include/net/inet_connection_sock.h 
static inline void inet_csk_reqsk_queue_unlink(struct sock *sk, struct request_sock *req,
 struct request_sock **prev)
{
 reqsk_queue_unlink(&inet_csk(sk)->icsk_accept_queue, req, prev);
}

reqsk_queue_unlink 中把連接請求塊從半連接隊列中刪除。

5.3 添加全連接隊列

接着添加到全連接隊列裏邊來。

//file:net/ipv4/syncookies.c
static inline void inet_csk_reqsk_queue_add(struct sock *sk,
      struct request_sock *req,
      struct sock *child)
{
 reqsk_queue_add(&inet_csk(sk)->icsk_accept_queue, req, sk, child);
}

在 reqsk_queue_add 中將握手成功的 request_sock 對象插入到全連接隊列鏈表的尾部。

//file: include/net/request_sock.h
static inline void reqsk_queue_add(...)
{
 req->sk = child;
 sk_acceptq_added(parent);

 if (queue->rskq_accept_head == NULL)
  queue->rskq_accept_head = req;
 else
  queue->rskq_accept_tail->dl_next = req;

 queue->rskq_accept_tail = req;
 req->dl_next = NULL;
}

5.4 設置連接爲 ESTABLISHED

//file:net/ipv4/tcp_input.c
int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb,
     const struct tcphdr *th, unsigned int len)
{
 ...
 switch (sk->sk_state) {

  //服務端第三次握手處理
  case TCP_SYN_RECV:

   //改變狀態爲連接
   tcp_set_state(sk, TCP_ESTABLISHED);
   ...
 }
}

將連接設置爲 TCP_ESTABLISHED 狀態。

服務器響應第三次握手 ack 所做的工作是把當前半連接對象刪除,創建了新的 sock 後加入到全連接隊列中,最後將新連接狀態設置爲 ESTABLISHED

六、服務器 accept

最後 accept 一步咱們長話短說。

//file: net/ipv4/inet_connection_sock.c
struct sock *inet_csk_accept(struct sock *sk, int flags, int *err)
{
 //從全連接隊列中獲取
 struct request_sock_queue *queue = &icsk->icsk_accept_queue;
 req = reqsk_queue_remove(queue);

 newsk = req->sk;
 return newsk;
}

reqsk_queue_remove 這個操作很簡單,就是從全連接隊列的鏈表裏獲取出第一個元素返回就行了。

//file:include/net/request_sock.h
static inline struct request_sock *reqsk_queue_remove(struct request_sock_queue *queue)
{
 struct request_sock *req = queue->rskq_accept_head;

 queue->rskq_accept_head = req->dl_next;
 if (queue->rskq_accept_head == NULL)
  queue->rskq_accept_tail = NULL;

 return req;
}

所以,accept 的重點工作就是從已經建立好的全連接隊列中取出一個返回給用戶進程。

本文總結

在後端相關崗位的入職面試中,三次握手的出場頻率非常的高。其實在三次握手的過程中,不僅僅是一個握手包的發送 和 TCP 狀態的流轉。還包含了端口選擇,連接隊列創建與處理等很多關鍵技術點。通過今天一篇文章,我們深度去了解了三次握手過程中內核中的這些內部操作。

全文洋洋灑灑上萬字字,其實可以用一幅圖總結起來。

另外要注意的是,如果握手過程中發生丟包(網絡問題,或者是連接隊列溢出),內核會等待定時器到期後重試,重試時間間隔在 3.10 版本里分別是 1s 2s 4s ...。在一些老版本里,比如 2.6 裏,第一次重試時間是 3 秒。最大重試次數分別由 tcp_syn_retries 和 tcp_synack_retries 控制。

如果你的線上接口正常都是幾十毫秒內返回,但偶爾出現了 1 s、或者 3 s 等這種偶發的響應耗時變長的問題,那麼你就要去定位一下看看是不是出現了握手包的超時重傳了。

以上就是三次握手中一些更詳細的內部操作。如果你能在面試官面前講出來內核的這些底層邏輯,我相信面試官一定會對你刮目相看的!

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