开发内功修炼@张彦飞开发内功修炼@张彦飞

talk is cheap,
show me the code!

能将三次握手理解到这个深度,面试官拍案叫绝!

大家好,我是飞哥!

在后端相关岗位的入职面试中,三次握手的出场频率非常的高,甚至说它是必考题也不为过。一般的答案都是说客户端如何发起 SYN 握手进入 SYN_SENT 状态,服务器响应 SYN 并回复 SYNACK,然后进入 SYN_RECV,...... , 吧啦吧啦诸如此类。

但我今天想给出一份不一样的答案,相信这份答案一定能帮你在面试官面前赢得非常多的加分。下次再有面试官问起你三次握手。你先故作深思状,思考五秒,然后拿起纸笔,边讲边画,把本文的内容一点一点回答给他。同时别忘了观察面试官脸上表情的变化,一定是从一本正经严肃的脸上逐步一点点地流露出了喜悦之情。

在基于 TCP 的服务开发中,三次握手的主要流程图如下。

1_三次握手简要流程.png

服务器中的核心逻辑是创建 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, ...);
    ...
}

关于 socket 对象的创建和 bind 这里就先不讲,我们直接从和三次握手过程关系最大的 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。

当全连接队列和半连接队列中有元素的时候,他们在内核中的结构图大致如下。

2-连接队列.png

所以,在服务器 listen 的时候,主要是进行了全/半连接队列的长度限制计算,以及相关的内存申请和初始化。如果想了解更多的 listen 内部操作细节可以看前文《为什么服务端程序都需要先 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连接中客户端的端口号是如何确定的?》),进入到 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 包,然后将其发出。

注意了, 当发送完毕以后,会启动一个重传定时器。tcp_connect_init 函数将首次超时时间设置成了 1 s (我手头的版本是 3.10,在一些老版本是 是 3 s)。

//file:ipv4/tcp_output.c
void tcp_connect_init(struct sock *sk)
{
    //初始化为 TCP_TIMEOUT_INIT 
    inet_csk(sk)->icsk_rto = TCP_TIMEOUT_INIT;
    ......
}

TCP_TIMEOUT_INIT 在 include/net/tcp.h 下被定义成了 1 秒。

//file: include/net/tcp.h
#define TCP_TIMEOUT_INIT ((unsigned)(1*HZ))    

在一些老版本下,比如 linux/v2.6.30 下这个初始值是 3 秒。

//file: include/net/tcp.h
#define TCP_TIMEOUT_INIT ((unsigned)(3*HZ))    

总结一下客户端 connect 做的事情,主要是三件

  • 1. 选择可用的本地端口
  • 2. 发出 SYN 握手请求
  • 3. 启动重传定时器

三、服务器响应 SYN

在服务器端,所有的 TCP 包(包括客户端发来的握手请求)都经过网卡、软中断,最后进入到 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 if (tcp_v4_conn_req_fastopen(sk, skb, skb_synack, req))
}

在这里首先判断半连接队列是否满了,如果满了的话进入 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 洋洋洒洒这么多代码,主要也是三件事

  • 1. 发出SYN ACK
  • 2. 进入半连接队列
  • 3. 启动定时器

四、客户端响应 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_init_nondata_skb(buff, tcp_acceptable_seq(sk), TCPHDR_ACK);

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

客户端响应来自服务器端的 synack 时做的工作也可以大致整理成三件事。

  • 1. 清除重传定时器
  • 2. 设置为已连接
  • 3. 发送 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 = {
    .queue_xmit    = ip_queue_xmit,
    .send_check    = tcp_v4_send_check,
    .rebuild_header    = inet_sk_rebuild_header,
    .sk_rx_dst_set     = inet_sk_rx_dst_set,
    .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);
}

把连接请求块从半连接队列中删除

//file: include/net/request_sock.h
static inline void reqsk_queue_unlink(struct request_sock_queue *queue, struct request_sock *req,
    struct request_sock **prev_req)
{
    write_lock(&queue->syn_wait_lock);
    *prev_req = req->dl_next; /* 改变了指针的值,相当于删除了req指向的实例 */
    write_unlock(&queue->syn_wait_lock);
}
//file:include/net/inet_connection_sock.h
static inline void inet_csk_reqsk_queue_removed(struct sock *sk, struct request_sock *req)
{
    /* 如果半连接队列长度为0,则删除定时器 */
    if (reqsk_queue_removed(&inet_csk(sk)->icsk_accept_queue, req) == 0)
        inet_csk_delete_keepalive_timer(sk);
}

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);
}
//file: include/net/request_sock.h
static inline void reqsk_queue_add(struct request_sock_queue *queue,
                   struct request_sock *req,
                   struct sock *parent,
                   struct sock *child)
{
    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;
}

服务器响应第三次握手所做的工作也可以大致归纳成三件

  • 1. 创建新 sock
  • 2. 从半连接队列删除
  • 3. 加入全连接队列

六、服务器 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;

    WARN_ON(req == NULL);

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

    return req;
}

所以,accept 的重点工作就是从已经建立好的全连接队列中取出一个返回给用户进程。

本文总结

在后端相关岗位的入职面试中,三次握手的出场频率非常的高。其实在三次握手的过程中,不仅仅是一个握手包的发送 和 TCP 状态的流转。还包含了端口选择,连接队列创建与处理等很多关键技术点。通过今天一篇文章,我们深度去了解了三次握手过程中内核中的这些内部操作。

全文洋洋洒洒大几千字,其实总结起来一幅图就搞定了。

1_正常三次握手.png

如果你能在面试官面前讲出来内核的这些底层操作,相信面试官一定会对你刮目相看的!

写在最后,由于我的这些知识在公众号里文章比较分散,很多人似乎没有理解到我对知识组织的体系结构。而且图文也不像视频那样理解起来更直接。所以我在知识星球上规划了视频系列课程,包括硬件原理、内存管理、进程管理、文件系统、网络管理、Golang语言、容器原理、性能观测、性能优化九大部分大约 120 节内容,每周更新。加入方式参见我要开始搞知识星球啦如何才能高效地学习技术,我投“融汇贯通”一票

Github:https://github.com/yanfeizhang/coder-kung-fu
关注公众号:微信扫描下方二维码
qrcode2_640.png

本原创文章未经允许不得转载 | 当前页面:开发内功修炼@张彦飞 » 能将三次握手理解到这个深度,面试官拍案叫绝!