深入理解Linux内核网络(五):TCP连接的建立过程

本文将深入探讨TCP协议中的listen和connect系统调用及其相关机制,并对TCP连接建立的完整过程进行详细分析,同时讨论异常情况及其处理方法。
部分内容来源于 《深入理解Linux网络》、《Linux内核源码分析TCP实现》

listen原理

系统调用概述

listen 用于将一个主动套接字(主动发起连接)转为被动套接字(被动等待连接)。当一个服务器套接字调用 listen 后,它会准备好接收来自客户端的连接请求。服务器通过 listen 指定的队列参数(backlog)来定义可等待处理的连接数量。

在 Linux 内核中,listen 的实现是通过系统调用定义的,其 C 代码中大致如下:

c 复制代码
SYSCALL_DEFINE2(listen, int, fd, int, backlog){
    // 1. 根据文件描述符 fd 查找内核中的 socket 对象
    sock = sockfd_lookup_light(fd, &err, &fput_needed);
    if (sock) {
        // 2. 获取内核参数 net.core.somaxconn,限制最大连接数
        somaxconn = sock_net(sock->sk)->core.sysctl_somaxconn;
        if ((unsigned int) backlog > somaxconn) {
            backlog = somaxconn;
        }
        // 3. 调用协议栈中注册的 listen 函数
        err = sock->ops->listen(sock, backlog);
    }
}

在用户态程序中,socket 是通过文件描述符(fd)来表示的。然而,内核需要实际的 socket 数据结构来操作网络连接。sockfd_lookup_light 函数的作用就是根据用户传入的文件描述符 fd 查找对应的 socket 内核对象。

在 TCP 服务器上,backlog 参数用于指定可以等待处理的连接请求的最大数量。为了防止用户传入过大的值而影响系统性能,内核使用 net.core.somaxconn 参数来限制 backlog 的上限。

内核通过 socket 对象的操作函数 sock->ops->listen 来调用协议栈实现的 listen 函数。这里的 listen 函数是与具体的传输协议(如 TCP、UDP 等)相关的协议栈函数。对于 TCP 协议来说,它会负责管理 TCP 的连接状态、半连接队列、全连接队列等。

协议栈listen

在前文中通过 sock->ops->listen 进入了协议栈的 listen 函数。对于 AF_INET 协议族的套接字来说,listen 函数指向 inet_listen,它负责处理 TCP 协议栈中的监听逻辑。

c 复制代码
int inet_listen(struct socket *sock, int backlog){
    struct sock *sk = sock->sk;
    int err = 0;
    // 获取当前 socket 的状态
    int old_state = sk->sk_state;
    // 如果 socket 尚未处于 TCP_LISTEN 状态,开始监听
    if (old_state != TCP_LISTEN) {
        // 开始监听并设置 backlog 参数
        err = inet_csk_listen_start(sk, backlog);
    }
    // 设置全连接队列长度
    sk->sk_max_ack_backlog = backlog;
    return err;
}

函数首先检查当前 socket 是否处于 TCP_LISTEN 状态。如果当前状态不是 TCP_LISTEN,则表明该 socket 尚未进入监听状态,需要调用 inet_csk_listen_start 函数开始监听。

函数将 backlog 作为参数传递给全连接队列。全连接队列用于存放已经完成三次握手的连接,等待应用程序调用 accept 来处理这些连接。该队列的长度由 backlog 参数决定,同时受到系统参数 net.core.somaxconn 的限制。

inet_csk_listen_start 是实际执行监听逻辑的函数,负责处理接收队列的申请和初始化,以及状态的转换。

c 复制代码
int inet_csk_listen_start(struct sock *sk, const int nr_table_entries){
    struct inet_connection_sock *icsk = inet_csk(sk);
    int rc;
    // 为接收队列分配内存并初始化
    rc = reqsk_queue_alloc(&icsk->icsk_accept_queue, nr_table_entries);
    if (rc != 0)
        return rc;
    // 设置 socket 的状态为 TCP_LISTEN
    sk->sk_state = TCP_LISTEN;
    return 0;
}

函数首先通过 inet_csk 宏将 struct sock 类型的套接字强制转换为 inet_connection_sock 类型。inet_connection_sock 是包含 struct sock 的一个更高级别的结构体,专门用于处理带有连接管理的套接字(如 TCP)。这一点之所以可行,是因为在内核设计中,inet_connection_sockinet_socksocktcp_sock 是层层嵌套的。

c 复制代码
struct tcp_sock {
    /* inet_connection_sock has to be the first member of tcp_sock */
    struct inet_connection_sock inet_conn;
    u16 tcp_header_len; /* Bytes of tcp header to send      */
    u16 xmit_size_goal_segs; /* Goal for segmenting output packets */
...
};

struct inet_connection_sock {
    /* inet_sock has to be the first member! */
    struct inet_sock      icsk_inet;
    struct request_sock_queue icsk_accept_queue;
    struct inet_bind_bucket   *icsk_bind_hash;
...
};

struct inet_sock {
    /* sk and pinet6 has to be the first two members of inet_sock */
    struct sock     sk;
#if IS_ENABLED(CONFIG_IPV6)
    struct ipv6_pinfo   *pinet6;
#endif
...
};

struct socket {
    socket_state        state;
...
    struct sock     *sk;
    const struct proto_ops  *ops;
};

接收队列的定义

icsk->icsk_accept_queue定义在inet_connection_sock下,是一个request_sock_queue类型的对象,是内核用来接收客户端请求的主要数据结构。我们平时说的全连接队列、半连接队列全都是在这个数据结构里实现的。

c 复制代码
struct inet_connection_sock {
    struct inet_sock icsk_inet;              // 基础的网络层结构
    struct request_sock_queue icsk_accept_queue; // 连接队列,用于管理客户端连接请求
    ...
};
struct request_sock_queue {
    // 全连接队列:链表头和尾指针
    struct request_sock *rskq_accept_head;   // 全连接队列的头
    struct request_sock *rskq_accept_tail;   // 全连接队列的尾

    // 半连接队列
    struct listen_sock *listen_opt;          // 用于管理半连接队列的对象
    ...
};
struct listen_sock {
    u8 max_qlen_log;             // 半连接队列的最大长度对数
    u32 nr_table_entries;        // 半连接队列的实际长度
    struct request_sock *syn_table[0]; // 半连接请求的哈希表
};

对于全连接队列来说,在它上面不需要进行复杂的查找工作,accept处理的时候只是先进先出地接受就好了。所以全连接队列通过rskq_accept_headrskq_accept_tail以链表的形式来管理。

和半连接队列相关联的数据对象是listen_opt,它是listen_sock类型的。因为服务端需要在第三次握手时快速地查找出来第一次握手时留存的request_sock对象,所以其实是用了一个哈希表来管理,就是struct request_sock *syn_table[0]。这样当服务器收到后续的握手包时,可以快速查找到对应的连接请求。

接收队列申请与初始化

在上文中,reqsk_queue_alloc 函数的主要任务是创建并初始化接收队列 request_sock_queue 的内核对象,包括内存的申请、半连接队列长度的计算以及全连接队列的初始化。它确保服务器能够为即将到来的客户端连接请求准备好存储空间。

c 复制代码
int reqsk_queue_alloc(struct request_sock_queue *queue, unsigned int nr_table_entries){
    size_t lopt_size = sizeof(struct listen_sock);   // 计算 listen_sock 的基本大小
    struct listen_sock *lopt;

    // 计算半连接队列的实际长度,受限于系统参数 sysctl_max_syn_backlog
    nr_table_entries = min_t(u32, nr_table_entries, sysctl_max_syn_backlog);

    // 计算 listen_sock 的总大小,包括半连接队列的空间
    lopt_size += nr_table_entries * sizeof(struct request_sock *);

    // 为 listen_sock 对象分配内存,根据大小选择不同的分配方式
    if (lopt_size > PAGE_SIZE)
        lopt = vzalloc(lopt_size);  // 若超过单页大小,使用 vzalloc 进行分配
    else
        lopt = kzalloc(lopt_size, GFP_KERNEL);  // 否则使用 kzalloc 进行分配

    // 初始化全连接队列的头部指针,初始时为空
    queue->rskq_accept_head = NULL;

    // 设置半连接队列的相关参数
    lopt->nr_table_entries = nr_table_entries;  // 半连接队列的大小
    queue->listen_opt = lopt;  // 将半连接队列挂载到接收队列上
}

半连接队列本质上是一个哈希表,存储在listen_socksyn_table 中。每个表项分配的是一个指针(request_sock *),而实际指向的 request_sock 对象还未在此时分配。request_sock 的分配是在三次握手的过程中完成的。通过计算客户端发送 SYN 包时的信息(如源 IP 和端口)生成哈希值,将其存入该哈希表中。

半连接队列长度的计算

reqsk_queue_alloc函数中,半连接队列的长度通过一系列复杂的计算和调整得出,以确保系统既能够处理高负载,又能在性能和资源使用之间取得平衡。

c 复制代码
int reqsk_queue_alloc(struct request_sock_queue *queue, unsigned int nr_table_entries){
    // 1. 与内核参数 sysctl_max_syn_backlog 比较,取较小值
    nr_table_entries = min_t(u32, nr_table_entries, sysctl_max_syn_backlog);
    // 2. 确保最小值不小于 8
    nr_table_entries = max_t(u32, nr_table_entries, 8);
    // 3. 将长度上调到 2 的整数次幂
    nr_table_entries = roundup_pow_of_two(nr_table_entries + 1);
    // 计算二进制幂值
    for (lopt->max_qlen_log = 3; (1 << lopt->max_qlen_log) < nr_table_entries; lopt->max_qlen_log++);
    ...
}

半连接队列的长度是通过用户传入的 backlog、系统参数 tcp_max_syn_backlogsomaxconn 三者之间的较小值计算得出的。内核确保队列长度至少为 8,防止过小的队列影响连接处理能力。最后,队列长度被向上调整为 2 的整数次幂,这是为了优化哈希表操作和内存管理的性能。

内核不直接记录队列长度,而是记录其二进制幂次 max_qlen_log,以提升计算和比较的效率。

因此,半连接队列的最终长度是通过 min(backlog, somaxconn, tcp_max_syn_backlog) + 1,并向上调整到最近的 2 的幂次方,确保性能优化且长度不少于 16。

小结

文件描述符转换为 socket 内核对象: 在调用 listen 系统调用时,首先根据用户传入的文件描述符查找对应的 socket 内核对象。因为用户态的文件描述符无法直接被内核使用,必须进行转换。

确定 backlog 值: 用户传入的 backlog(期望的最大连接数)与系统参数(如 net.core.somaxconn)进行比较,取较小值作为实际的 backlog,以限制最大连接请求数,防止过度资源占用。

进入协议栈的 listen 处理: 系统调用会将 socket 设置为监听状态,并根据传入的 backlog 值来设置全连接队列的最大长度。

初始化接收队列: 半连接队列和全连接队列会被申请和初始化。半连接队列用于存放尚未完成三次握手的连接请求,通常通过哈希表来管理;全连接队列用于存储已完成三次握手的连接,采用链表管理。

转换套接字状态: 将 socket 的状态设置为 LISTEN,表明它已开始监听客户端连接请求,准备接收和处理新的连接。

connect原理

connect调用链展开

当客户端调用 connect 函数时,它会触发一系列系统调用和内核操作,以建立与服务器端的 TCP 连接。

c 复制代码
SYSCALL_DEFINE3(connect, int, fd, struct sockaddr __user *, uservaddr, int, addrlen){
    struct socket *sock;
    // 根据用户的 fd 查找对应的内核 socket 对象
    sock = sockfd_lookup_light(fd, &err, &fput_needed);
    
    // 执行 connect 操作
    err = sock->ops->connect(sock, (struct sockaddr *)&address, addlen, sock->file->f_flags);
    ...
}

查找到 socket 后,调用 sock->ops->connect 进入协议栈处理 connect 请求。对于 AF_INET 类型的 socket,它会调用 inet_stream_connect。实际上会调用 __inet_stream_connect 来执行更具体的操作。

c 复制代码
int __inet_stream_connect(struct socket *sock, ...){
    struct sock *sk = sock->sk;
    
    switch (sock->state) {
        default:
            err = -EINVAL;  // 状态无效
            goto out;
        
        case SS_CONNECTED:  // 已经连接
            err = -EISCONN;  // 表示已经建立连接
            goto out;
        
        case SS_CONNECTING:  // 正在连接
            err = -EALREADY;  // 表示正在连接中
            break;
        
        case SS_UNCONNECTED:  // 尚未连接
            err = sk->sk_prot->connect(sk, uaddr, addr_len);  // 调用底层协议的 connect
            sock->state = SS_CONNECTING;  // 设置状态为连接中
            err = -EINPROGRESS;  // 返回连接正在进行中
            break;
    }
    ...
}

对于 TCP 协议,sk->sk_prot->connect 对应的是 tcp_v4_connect。该函数负责执行具体的 TCP 连接逻辑,包括设置状态、选择端口以及发送 SYN 请求。

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);
    // 根据 sock 中的信息构建 SYN 报文并发送
    err = tcp_connect(sk);
}

选择可用端口

内核会通过 inet_hash_connect 动态分配一个可用的端口。这是为了确保每个 TCP 连接有一个唯一的源端口和目的地址组合。

c 复制代码
int inet_hash_connect(struct inet_timewait_death_row *death_row, struct sock *sk){
    return __inet_hash_connect(death_row, sk, inet_sk_port_offset(sk), 
    	__inet_check_established, __inet_hash_nolisten);
}

inet_sk_port_offset(sk):这个函数根据 socket 的目的 IP 和端口等信息生成一个随机数(offset),以确保每次连接的端口选择顺序有随机性,避免端口被过度重用。

__inet_check_established:该函数用于检查新选的端口是否与已经建立的连接发生冲突(端口复用检查)。

接下来进入核心逻辑 __inet_hash_connect 函数,主要任务是遍历可用端口范围,找到一个未被使用的端口。如果未绑定任何端口 (snum == 0),函数会从可用的端口范围内选择一个合适的端口。

c 复制代码
int __inet_hash_connect(...){
    const unsigned short snum = inet_sk(sk)->inet_num;  // 判断是否绑定过端口
    inet_get_local_port_range(&low, &high);  // 获取本地端口范围
    remaining = (high - low) + 1;  // 计算端口范围总量

    if (!snum) {  // 若未绑定端口
        for (int i = 1; i <= remaining; i++) {
            port = low + (i + offset) % remaining;  // 选择一个随机端口
            if (inet_is_reserved_local_port(port))  // 如果是保留端口则跳过
                continue;

            // 获取已使用的端口的哈希表链
            head = &hinfo->bhash[inet_bhashfn(net, port, hinfo->bhash_size)];
            inet_bind_bucket_for_each(tb, &head->chain) {
                if (net_eq(ib_net(tb), net) && tb->port == port) {
                    // 调用 check_established 进一步检查端口是否可用
                    if (!check_established(death_row, sk, port, &tw))
                        goto ok;
                }
            }
            // 若找到可用端口,记录该端口
            tb = inet_bind_bucket_create(hinfo->bind_bucket_cachep, ...);
            goto ok;
        }
    }
    return -EADDRNOTAVAIL;  // 未找到可用端口,返回错误
}

函数首先检查 socket 是否已经绑定了一个特定的端口(通过 bind 调用)。如果已经绑定,则不需要选择新的端口,直接返回。

使用 inet_get_local_port_range 函数获取当前系统配置的可用端口范围。这个范围通常由 net.ipv4.ip_local_port_range 决定(默认是 32768 到 61000)。从端口范围的 low 到 high,通过遍历的方式查找未被使用的端口。遍历时,从一个基于随机偏移量 offset 计算的起始点开始,确保端口选择的随机性。

c 复制代码
port = low + (i + offset) % remaining;  // 选择一个随机端口

系统通过哈希表管理已使用的端口。选中端口后,需要检查该端口是否已经被其他连接使用。如果端口已使用,会进一步调用 check_established 检查该端口是否可以复用(例如,在相同 IP 但不同连接的情况下)。

当系统中可用端口较少或端口范围被设置得过小(net.ipv4.ip_local_port_range 参数过窄)时,可能会导致连接失败,报 Cannot assign requested address 错误。

端口被使用过的情况

在选择可用端口的过程中,如果一个端口已经被使用过,但系统仍需要判断是否可以复用该端口。TCP 连接是基于四元组(源 IP、源端口、目的 IP、目的端口)来区分不同连接的,因此只要四元组不同,即便端口被占用,也可以复用该端口建立新的连接。

这一过程通过 check_established 函数实现,而该函数最终调用 __inet_check_established 来进行详细检查。

c 复制代码
static int __inet_check_established(struct inet_timewait_death_row *death_row,struct sock *sk, __u16 lport,
									struct inet_timewait_sock **twp){
    // 查找哈希桶,获取所有正在使用该端口的连接
    ehash_bucket *head = inet_ehash_bucket(hinfo, hash);
    
    // 遍历该哈希桶中的所有连接,检查是否有四元组完全一致的连接
    sk_nulls_for_each(sk2, node, &head->chain) {
        if (sk2->sk_hash != hash)
            continue;

        // 比较四元组,判断是否完全相同
        if (likely(INET_MATCH(sk2, net, acookie, saddr, daddr, ports, dif)))
            goto not_unique;  // 四元组完全一致,端口不能复用
    }

unique:
    return 0;  // 四元组不同,端口可复用

not_unique:
    return -EADDRNOTAVAIL;  // 四元组相同,端口不能复用
}

INET_MATCH中除了将__saddr、__daddr、__ports进行了比较,还比较了一些其他项目,所以TCP连接还有五元组、七元组之类的说法。只要有足够多的不同服务器,客户端可以通过相同的源端口,与不同的目标 IP 地址或端口建立多条连接。

发起SYN请求

在选择到可用的端口后,tcp_v4_connect 函数会调用 tcp_connect,以根据套接字(sock)中的信息构建并发送一个 SYN 报文。SYN 报文是 TCP 三次握手的第一步,客户端发起连接请求时需要向服务器发送 SYN 包。

c 复制代码
int tcp_connect(struct sock *sk){
    // 1. 申请并设置 skb(用于传输数据包的结构)
    buff = alloc_skb_fclone(MAX_TCP_HEADER + 15, sk->sk_allocation);
    tcp_init_nondata_skb(buff, tp->write_seq++, TCPHDR_SYN);
    
    // 2. 添加到发送队列 sk_write_queue
    tcp_connect_queue_skb(sk, buff);
    
    // 3. 实际发出 SYN
    err = tp->fastopen_req ? tcp_send_syn_data(sk, buff) : tcp_transmit_skb(sk, buff, 1, sk->sk_allocation);
    
    // 4. 启动重传定时器
    inet_csk_reset_xmit_timer(sk, ICSK_TIME_RETRANS, inet_csk(sk)->icsk_rto, TCP_RTO_MAX);
}

小结

系统调用入口:客户端调用 connect 函数发起连接请求,系统首先根据文件描述符找到对应的 socket 内核对象。

**检查 socket 状态:**系统检查当前 socket 是否已连接、正在连接或未连接。如果未连接,则进入下一步的连接过程。

**选择可用端口:**如果客户端未绑定本地端口,内核会动态选择一个未使用的源端口。通过遍历系统配置的端口范围,内核检查已使用端口的哈希表,并判断端口是否可以复用(通过比较 TCP 四元组)。找到可用端口后,内核为该连接分配该端口。

**发起 SYN 请求:**找到可用端口后,内核构建一个 SYN 报文,表示客户端发起连接请求。SYN 报文被加入到发送队列 sk_write_queue 中,并通过网络层发送给服务器。

**启动重传定时器:**为防止报文丢失,TCP 启动了重传定时器。如果在指定时间内未收到服务器的 SYN-ACK 响应,定时器触发 SYN 报文的重传。初始的重传超时时间通常为 1 秒,随着重传次数增加,超时时间逐渐增加,遵循指数退避算法。

完整TCP连接建立过程

在一次TCP连接建立(三次握手)的过程中,并不只是简单的状态的流转,还包括端口选择、半连接队列、syncookie、全连接队列、重传计时器等关键操作。

在三次握手的过程,服务端核心逻辑是创建socket绑定端口,listen监听,最后accept接收客户端的的请求;而客户端的核心逻辑是创建socket,然后调用connect连接服务端。

服务端响应SYN

当客户端发出 SYN 请求后,服务端的响应过程通过 TCP 协议栈的多个步骤来处理。服务端首先会检查是否有可用资源,并通过 SYN+ACK 进行响应。具体过程如下:

所有 TCP 包(包括 SYN 包)通过网卡和软中断进入到 TCP 协议栈处理,最终调用 tcp_v4_rcv 处理该请求。在 tcp_v4_rcv 中,根据 TCP 包头信息(如目的 IP 和端口),查找处于 LISTEN 状态的 socket 进行处理。

当确认是 LISTEN 状态的 socket,进入 tcp_v4_do_rcv 函数,进一步处理该连接请求。

c 复制代码
int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb){
    if (sk->sk_state == TCP_LISTEN) {  // 判断 socket 是否处于 LISTEN 状态
        struct sock *nsk = tcp_v4_hnd_req(sk, skb);  // 处理半连接
        if (!nsk)
            goto discard;  // 未找到半连接,丢弃该请求
        if (nsk != sk) {  // 该连接已经存在,处理新连接请求
            if (tcp_child_process(sk, nsk, skb)) {
                rsk = nsk;
                goto reset;  // 连接请求被拒绝或其他问题
            }
            return 0;  // 处理成功,结束
        }
    }
    if (tcp_rcv_state_process(sk, skb, tcp_hdr(skb), skb->len)) {
        rsk = sk;
        goto reset;
    }
}

tcp_v4_hnd_req 函数中,服务端查找是否存在半连接队列中对应的连接请求:

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);  // 检查半连接队列
    ...
}

如果找到了,则说明 SYN 请求已经在处理流程中。反之,则返回 nsk == sk,表示这是一个新的连接请求,需要进一步处理。如果是新的 SYN 请求(即半连接队列中没有找到对应的记录),会进入tcp_rcv_state_process 函数,根据当前 socket 的状态进行相应的处理:

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 请求,并构造 SYN+ACK 响应。

c 复制代码
int tcp_v4_conn_request(struct sock *sk, struct sk_buff *skb){
    if (inet_csk_reqsk_is_full(sk) && !isn) {  // 检查半连接队列是否满了
        want_cookie = tcp_syn_flood_action(sk, skb, "TCP");  // 防止 SYN Flood
        if (!want_cookie)
            goto drop;  // 丢弃请求
    }

    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;  // 丢弃请求
    }

    req = inet_reqsk_alloc(&tcp_request_sock_ops);  // 分配 request_sock 内核对象

    skb_synack = tcp_make_synack(sk, dst, req, fastopen_cookie_present(&valid_foc) ? &valid_foc : NULL);  // 构造 SYN+ACK
    err = ip_build_and_send_pkt(skb_synack, sk, ireq->loc_addr, ireq->rmt_addr, ireq->opt);  // 发送 SYN+ACK
    inet_csk_reqsk_queue_hash_add(sk, req, TCP_TIMEOUT_INIT);  // 添加到半连接队列并启动计时器
}

半连接队列检查: 如果半连接队列已满,检查是否启用了 TCP SYN Cookie 防御。如果没有启用,丢弃连接请求。

全连接队列检查: 如果全连接队列已满且有多个 "年轻" 连接,服务端会丢弃请求。

构造 SYN+ACK: 调用 tcp_make_synack 函数生成 SYN+ACK 报文,并通过 ip_build_and_send_pkt 函数将其发送给客户端。

半连接队列: 将新的请求添加到半连接队列,并启动计时器,等待客户端的 ACK 响应。

TCP SYN Cookie 是一种防止 SYN Flood 攻击 的技术。SYN Flood 是一种 拒绝服务攻击 (DoS),攻击者通过伪造大量的 SYN 请求,消耗服务器的资源,导致服务器无法处理合法用户的连接请求。SYN Cookie 技术通过不为每个收到的 SYN 请求分配资源,而是通过算法计算一个 Cookie,将这个 Cookie 作为 SYN-ACK 包的序列号发回客户端。客户端收到 SYN-ACK 后,正常情况下会发送 ACK 包,确认连接。这时,服务器通过客户端发送的 ACK 中的确认号,计算出是否符合之前发送的 SYN Cookie。如果 ACK 包中的确认号能通过验证,服务器再为该连接分配资源,并继续完成三次握手。如果不能通过验证,则丢弃该连接请求。

客户端响应 SYN-ACK

当客户端接收到服务端发送的 SYN-ACK 包时,客户端处于 TCP_SYN_SENT 状态,此时客户端的处理逻辑与服务端不同。客户端接收到这个包后,主要负责完成三次握手的最后一步,发送 ACK 包确认连接,并将 socket 状态改为 ESTABLISHED,表示连接已成功建立。

当客户端收到 SYN-ACK 包时,会调用 tcp_rcv_state_process 函数,根据当前 socket 的状态(此时为 TCP_SYN_SENT),进入相应的处理分支。

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_SENT:  // 客户端第二次握手处理
            queued = tcp_rcv_synsent_state_process(sk, skb, th, len);  // 处理 SYN-ACK 包
            return 0;
    }
}

当收到 SYN-ACK 包时,进入 tcp_rcv_synsent_state_process 函数,是客户端处理 SYN-ACK 的核心逻辑,它负责清除重传队列、更新 socket 状态、构造并发送 ACK 包,完成三次握手。

c 复制代码
static int tcp_rcv_synsent_state_process(struct sock *sk, struct sk_buff *skb, 
                                         const struct tcphdr *tp, unsigned int len){
    tcp_ack(sk, skb, FLAG_SLOWPATH);  // 确认 SYN-ACK 包,更新状态和序列号
    tcp_finish_connect(sk, skb);  // 完成连接,设置状态为 ESTABLISHED

    if (sk->sk_write_pending || icsk->icsk_accept_queue.rskq_defer_accept || icsk->icsk_ack.pingpong) {
        // 延迟确认逻辑
    } else {
        tcp_send_ack(sk);  // 发送 ACK,完成三次握手
    }
}

tcp_ack 函数: 处理 SYN-ACK 包,确认服务端的 SYN-ACK,并更新 socket 的状态(如序列号、确认号等)。会调用 tcp_clean_rtx_queue,检查并清除重传队列中已经确认的数据包,停止重传定时器。还会根据接收到的确认包计算往返时间(RTT),用于动态调整 TCP 超时重传时间。

tcp_finish_connect 函数: 完成 TCP 连接的建立,将 socket 状态从 TCP_SYN_SENT 修改为 ESTABLISHED,初始化了 TCP 连接的拥塞控制算法,分配接收和发送缓存空间等。

连接建立后,会启动 Keep-Alive 计时器,用于检测连接是否空闲和保持连接活跃。

Keep-Alive 计时器:如果连接上没有数据传输,定时发送 Keep-Alive 包,确保连接正常。如果在一定时间内没有收到响应,则认为连接已经断开。

如果不满足延迟确认机制,客户端会立即发送 ACK 包,确认服务端的 SYN-ACK,完成三次握手。

延迟确认机制:在某些情况下(如 sk_write_pending 或 icsk_ack.pingpong),客户端可能会延迟发送 ACK,以减少网络中的包的数量。这种情况下,ACK 可能会与后续的数据包一起发送。

服务端响应 ACK

在三次握手的第三步中,客户端发送 ACK 包,服务端收到该 ACK 后,会将连接状态从半连接(SYN_RECV)更新为全连接(ESTABLISHED),并将对应的 request_sock 从半连接队列中移除,创建新的子 sock,并加入全连接队列。

当服务端收到客户端的第三次握手 ACK 包时,进入 tcp_v4_do_rcv 函数。由于此时 socket 处于 TCP_LISTEN 或 SYN_RECV 状态:

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 (!nsk)
            goto discard;
        if (nsk != sk) {
            if (tcp_child_process(sk, nsk, skb)) {  // 创建子 socket,处理连接
                rsk = nsk;
                goto reset;
            }
            return 0;
        }
    }
}

tcp_v4_hnd_req 函数中,服务端首先在半连接队列中查找 request_sock,用于匹配客户端发来的 ACK。

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:此函数根据客户端的 IP 和端口号,在半连接队列中查找对应的 request_sock

tcp_check_req:如果找到了半连接 request_sock,则进入 tcp_check_req 函数进行处理。

c 复制代码
struct sock *tcp_check_req(...){
    child = inet_csk(sk)->icsk_af_ops->syn_recv_sock(sk, skb, req, NULL);  // 创建子 socket
    ...
    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;
}

为新的连接创建一个子 sock,用于管理该连接的状态。删除对应的 request_sock,因为该连接已经完成了三次握手,不再需要存放在半连接队列中。将新创建的 sock 加入全连接队列,等待 accept 系统调用处理。

request_sock 被转换为一个新的 sock 时,tcp_child_process 函数会对新创建的 sock 进行状态处理,并根据当前 ACK 更新连接状态。

c 复制代码
int tcp_child_process(struct sock *parent, struct sock *child, struct sk_buff *skb){
    int ret = 0;
    int state = child->sk_state;

    if (!sock_owned_by_user(child)) {
        ret = tcp_rcv_state_process(child, skb, tcp_hdr(skb), skb->len);  // 处理状态
        if (state == TCP_SYN_RECV && child->sk_state != state)  // 检查状态变化
            parent->sk_data_ready(parent, 0);  // 通知监听 socket,调用 accept
    } else {
        __sk_add_backlog(child, skb);  // 如果 socket 被进程锁定,则加入 backlog
    }
    
    bh_unlock_sock(child);
    sock_put(child);
    return ret;
}

tcp_rcv_state_process 函数用于处理新的 sock 的状态。此时,状态会从 SYN_RECV 更新为 ESTABLISHED。如果状态变更为 ESTABLISHED,通知监听 socket,该连接已经准备好,进程可以通过 accept 系统调用接受新的连接。

其中再一次调用了tcp_rcv_state_process,然后唤醒等待队列上的进程。

c 复制代码
int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb, const struct tcphdr *th, unsigned int len){
    switch(sk->sk_state) {
	// 服务端收到第一次握手的SYN包
	case TCP_LISTEN:
	    ......
	// 客户端第二次握手处理
    	case TCP_SYN_SENT:
	    ......
	// 服务端收到第三次握手的ACK包
	case TCP_SYN_RECV:
	    // 改变状态为连接
	    tcp_set_state(sk, TCP_ESTABLISHED);
	    ......
    }
}

服务端响应第三次握手ACK所做的工作就是把当前半连接对象删除,创建了新的sock后加入全连接队列,最后将新连接状态设置为ESTABLISHED。

服务端accept

在服务端,当应用程序调用 accept 函数时,内核会从全连接队列中取出已经完成三次握手的连接,并将其与新的 socket 对象关联。主要的过程是创建一个新的 socket 对象,并从全连接队列中取出之前三次握手时创建的 sock,将其与新的 socket 关联,随后释放不再需要的 request_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);  // 从全连接队列中移除 request_sock
    
    newsk = req->sk;  // 获取已经完成三次握手的子 socket
    return newsk;  // 返回新的子 socket
}

全连接队列: icsk_accept_queue 是一个全连接队列,保存了所有已经完成三次握手的连接。在调用 accept 时,内核会从该队列中取出一个 request_sock 对象。

reqsk_queue_remove: 该函数从全连接队列中取出 request_sock,并将其从队列中移除。

获取 sock 对象: request_sock 保存了指向已经完成三次握手的 sock,通过 req->sk 取出这个已经建立的连接。

返回子 sock: 函数返回新创建的 sock 对象,表示客户端与服务器之间的连接已经准备好,可以进行数据传输。

request_sock 是一个数据结构,用来存储 TCP 三次握手过程中产生的中间状态和信息。在 accept 之前,request_sock 保存了部分连接状态,直到 accept 调用后,真正的 sock 被提取并与新创建的 socket 关联。

c 复制代码
struct request_sock {
    struct request_sock *dl_next;  // 链表中的下一个 request_sock 对象
    u16 mss;  // 客户端在 SYN 包中通告的 MSS
    u8 retrans;  // SYN+ACK 重传次数,初始值为 0
    unsigned long expires;  // SYN+ACK 的超时时间
    const struct request_sock_ops *rsk_ops;  // 操作集,用于处理 ACK 段和创建 tcp_sock 对象
    struct sock *sk;  // 指向已经创建的 tcp_sock 结构
};

小结

客户端发送 SYN 请求: 客户端通过调用 connect 函数发起连接请求。内核为该连接选择一个可用的源端口,并构建一个 SYN 包,发送给服务端。SYN 包中包含客户端的初始序列号,表明客户端希望与服务器建立连接。此时,客户端的 socket 状态变为 SYN_SENT。客户端会将 SYN 包加入重传队列,并启动重传定时器,以防止网络延迟或丢包的情况。

服务端接收并响应 SYN-ACK: 服务端通过网卡接收到客户端发来的 SYN 包,进入 tcp_v4_do_rcv 函数进行处理。由于服务端处于 TCP_LISTEN 状态,系统会检查半连接队列是否已满。

半连接队列:这是服务器存放尚未完成三次握手的连接请求的队列。如果队列已满,系统可能通过 SYN Cookie 机制进行防护,防止 SYN Flood 攻击。

服务器构建并发送 SYN-ACK 包,确认接收到客户端的 SYN 请求,并附带自己的初始序列号。此时,服务端将该连接加入半连接队列,状态设置为 SYN_RECV,并启动重传计时器等待客户端的 ACK。

客户端接收 SYN-ACK 并发送 ACK: 客户端收到服务端的 SYN-ACK 包后,调用 tcp_rcv_synsent_state_process 进行处理。客户端首先确认服务端的序列号,并清理重传队列中的 SYN 包,停止重传计时器。

然后客户端发送一个 ACK 包,确认服务端的 SYN-ACK,完成三次握手的第三步。客户端的 socket 状态从 SYN_SENT 更新为 ESTABLISHED,表明连接已经建立。如果开启了延迟确认机制,ACK 包可能会稍后发送,否则立即发送 ACK。

服务端接收 ACK 并完成连接: 服务端在 tcp_v4_do_rcv 中收到客户端的 ACK 后,会从半连接队列中找到对应的 request_sock 对象,并调用 tcp_check_req 进行处理。

request_sock:保存了客户端的连接信息和三次握手的状态。在收到 ACK 后,服务端会创建一个新的子 sock,将 request_sock 中的信息移交给新的 sock,并将该连接加入全连接队列。

服务端状态更新为 ESTABLISHED,表明该连接已经完成三次握手,准备处理后续的应用层数据传输。服务端创建的新的 sock 会进一步初始化,最终可以通过 accept 被应用程序获取。

服务端 accept 处理: 当服务器应用程序调用 accept 时,系统会从全连接队列中取出已经完成三次握手的连接,并返回新的子 sock,供应用程序使用。

accept 函数从全连接队列中提取出由 request_sock 转化的 sock 对象,释放不再需要的 request_sock,并将新的 sock 关联到应用层 socket 上。服务端可以通过这个新的子 sock 与客户端进行数据传输,整个 TCP 连接的建立过程至此完成。

异常TCP建立情况

connect系统调用耗时失控

在客户端发起 connect 系统调用时,主要的工作之一是为连接选择一个可用的源端口。这个过程虽然在端口充足的情况下非常迅速,但如果端口资源不足,可能导致系统调用耗时失控,CPU 使用率急剧上升。

c 复制代码
int inet_hash_connect(...)
{
    inet_get_local_range(&low, &high);  // 获取本地端口范围
    remaining = (high - low) + 1;  // 计算端口范围的大小
    for (int i = 1; i <= remaining; i++) {  // 从随机位置开始遍历
        port = low + (i + offset) % remaining;
        head = &hinfo->bhash[inet_bhashfn(net, port, hinfo->bhash_size)];
        spin_lock(&head->lock);  // 自旋锁保护哈希表
        // 选择逻辑,成功则跳出循环
        ...
    next_port:
        spin_unlock(&head->lock);  // 释放自旋锁
    }
}

如果 ip_local_port_range 中的大部分端口都已被使用,系统会遍历整个端口范围,尝试找到一个可用的端口。遍历的次数与可用端口的数量直接相关。

在每次检查端口可用性的过程中,系统使用自旋锁保护哈希表。如果锁无法立即获得(例如其他进程正在访问该哈希表),进程不会挂起,而是持续占用 CPU,等待获取锁。这样会导致 CPU 占用率急剧上升,特别是在大范围遍历时。

解决方案:

通过修改内核参数 ipv4.ip_local_port_range,增加可用的本地端口范围,减小端口耗尽的风险。

避免频繁地建立和关闭连接,可以减少对端口资源的消耗,尤其是 TIME_WAIT 状态的端口。如果应用场景允许,使用长连接可以显著降低端口消耗。

通过调节内核参数,加快处于 TIME_WAIT 状态的端口回收速度,从而腾出更多的可用端口。

第一次握手丢包

半连接队列满导致丢包: 在处理客户端的 SYN 请求时,服务端会首先检查 半连接队列 是否已经满了。半连接队列保存的是那些已经完成第一步握手(SYN 请求已收到)但还未完成整个三次握手的连接。队列满的情况下,服务端可能直接丢弃 SYN 包。

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;  // 丢弃 SYN 包
    }
    ...
}

全连接队列满导致丢包: 当半连接队列检查通过后,服务端接下来还会检查全连接队列。全连接队列保存的是那些已经完成三次握手的连接。在全连接队列已满的情况下,服务端也可能丢弃 SYN 包。

c 复制代码
int tcp_v4_conn_request(struct sock *sk, struct sk_buff *skb){
    ...
    // 检查全连接队列是否已满
    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;  // 丢弃 SYN 包
    }
    ...
}

**客户端发起重传:**当服务端因队列溢出或其他原因丢弃 SYN 包时,客户端不会立即收到服务端的 SYN-ACK 响应。在这种情况下,客户端会进入 重传机制,即通过超时重发 SYN 包。

c 复制代码
int tcp_connect(struct sock *sk){
    // 发出 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 三次握手过程中,第三次握手是客户端发送 ACK,确认服务端发出的 SYN-ACK,从而正式建立连接的阶段。客户端在发送 ACK 后会认为连接已建立,并开始发送数据。然而,如果服务端在处理第三次握手时遇到问题(例如全连接队列已满),可能会导致服务端丢弃客户端的 ACK,从而导致连接未能真正建立。

当服务端收到客户端的 ACK 包后,会调用 tcp_check_req 函数尝试创建子 socket,用于管理这个已经完成三次握手的连接。在 syn_recv_sock 处理中,服务端还需要检查全连接队列是否已满。

c 复制代码
struct sock *tcp_check_req(...){
    // 创建子 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);

    // 将新建的子 socket 加入全连接队列
    inet_csk_reqsk_queue_add(sk, req, child);
    return child;
}

struct sock *tcp_v4_syn_recv_sock(struct sock *sk, ...){
    // 检查全连接队列是否已满
    if (sk_acceptq_is_full(sk))
        goto exit_overflow;  // 如果队列已满,则丢弃 ACK 包
}

小结

connect 系统调用耗时失控

客户端在发起 connect 系统调用时,内核需要为其选择一个源端口。如果本地端口资源不足,系统可能需要遍历整个端口范围来寻找可用端口。这种情况下,遍历会涉及大量的哈希查找和自旋锁争夺,导致系统 CPU 使用率增加,connect 系统调用耗时明显延长。

解决办法: 扩大本地端口范围(通过调整 ip_local_port_range)。使用长连接减少频繁的端口分配。加快 TIME_WAIT 端口的回收速度。

第一次握手丢包

服务端在处理来自客户端的 SYN 请求时,如果 半连接队列 或 全连接队列 已满,可能会直接丢弃 SYN 包,客户端无法收到 SYN-ACK 响应。这种情况下,客户端会启用重传机制,重新发送 SYN 包,但每次重传都伴随着一定的延迟,影响连接建立速度。

解决办法: 启用 TCP SYN Cookie 机制(tcp_syncookies)来防止 SYN Flood 攻击,在半连接队列满的情况下仍能继续处理连接请求。扩大半连接和全连接队列的大小。

第三次握手丢包

在三次握手的第三步,客户端向服务端发送 ACK 包,但如果服务端的 全连接队列已满,可能会丢弃该 ACK 包,导致连接无法完成。客户端此时认为连接已建立,并开始发送数据,但服务端尚未真正完成连接,导致数据被丢弃。服务端会重传 SYN-ACK 进行重试,最终可能因为重试次数达到上限而放弃连接。

解决办法: 增加全连接队列的大小。优化服务端连接的处理速度,避免队列溢出。

相关推荐
sky_feiyu19 分钟前
HTTP超文本协议
网络·网络协议·web安全·http
xiaozhiwise1 小时前
Makefile 之 join
linux
C++忠实粉丝1 小时前
计算机网络socket编程(6)_TCP实网络编程现 Command_server
网络·c++·网络协议·tcp/ip·计算机网络·算法
儿时可乖了2 小时前
Linux 定时任务全解析
linux·oracle
北'辰2 小时前
使用ENSP实现默认路由
运维·网络
学习使我飞升2 小时前
OSPF路由状态数据库、type 类型、完整的LSA
服务器·网络·智能路由器
北'辰2 小时前
使用ENSP实现静态路由
运维·网络
学习使我飞升2 小时前
spf算法、三类LSA、区间防环路机制/规则、虚连接
服务器·网络·算法·智能路由器
hgdlip2 小时前
重装系统后ip地址错误,网络无法接通怎么办
服务器·网络协议·tcp/ip·重装系统
放逐者-保持本心,方可放逐2 小时前
一文详细了解websocket应用以及连接断开的解决方案
网络·websocket·网络协议