【Linux网络】深入理解 TCP 协议:从四次挥手到内核数据结构


🔥草莓熊Lotso: 个人主页
❄️个人专栏: 《C++知识分享》 《Linux 入门到实践:零基础也能懂》
✨生活是默默的坚持,毅力是永久的享受!


🎬 博主简介:


文章目录

  • 前言:
  • [一. TCP 连接管理深度解析](#一. TCP 连接管理深度解析)
    • [1.1 四次挥手的本质:为什么是四次?](#1.1 四次挥手的本质:为什么是四次?)
    • [1.2 CLOSE_WAIT 状态:服务器资源泄露的元凶](#1.2 CLOSE_WAIT 状态:服务器资源泄露的元凶)
    • [1.3 TIME_WAIT 状态:为什么要等 2MSL?](#1.3 TIME_WAIT 状态:为什么要等 2MSL?)
    • [1.4 解决 TIME_WAIT 问题的最佳实践](#1.4 解决 TIME_WAIT 问题的最佳实践)
  • [二. 内核视角看 TCP 连接:数据结构的奥秘](#二. 内核视角看 TCP 连接:数据结构的奥秘)
    • [2.1 从文件描述符到 TCP 连接:层层嵌套的结构体](#2.1 从文件描述符到 TCP 连接:层层嵌套的结构体)
      • [1. struct file:文件结构体](#1. struct file:文件结构体)
      • [2. struct socket:套接字结构体](#2. struct socket:套接字结构体)
      • [3. struct sock:通用套接字结构体](#3. struct sock:通用套接字结构体)
      • [4. struct inet_sock:IPv4 套接字结构体](#4. struct inet_sock:IPv4 套接字结构体)
      • [5. struct inet_connection_sock:面向连接的套接字结构体](#5. struct inet_connection_sock:面向连接的套接字结构体)
      • [6. struct tcp_sock:TCP 套接字结构体](#6. struct tcp_sock:TCP 套接字结构体)
    • [2.2 C 语言实现的多态:统一的套接字接口](#2.2 C 语言实现的多态:统一的套接字接口)
    • [2.3 全连接队列:accept 背后的机制](#2.3 全连接队列:accept 背后的机制)
  • [三. TCP 流量控制:不止是滑动窗口](#三. TCP 流量控制:不止是滑动窗口)
    • [3.1 流量控制的基本原理](#3.1 流量控制的基本原理)
    • [3.2 窗口扩大因子:突破 64KB 的限制](#3.2 窗口扩大因子:突破 64KB 的限制)
    • [3.3 窗口探测与窗口更新](#3.3 窗口探测与窗口更新)
  • [四. 实战:TCP 服务器的常见问题与优化(想看的可以看看)](#四. 实战:TCP 服务器的常见问题与优化(想看的可以看看))
    • [4.1 端口复用的正确配置](#4.1 端口复用的正确配置)
    • [4.2 避免 CLOSE_WAIT 泄露的代码规范](#4.2 避免 CLOSE_WAIT 泄露的代码规范)
  • 结尾:

前言:

TCP(传输控制协议)作为互联网的基石之一,承担着绝大多数可靠数据传输的重任。从浏览器访问网页到微信发送消息,背后都离不开 TCP 的默默工作。然而,很多开发者对 TCP 的理解仅停留在 "三次握手、四次挥手" 的表面,一旦遇到服务器出现大量CLOSE_WAITTIME_WAIT连接、端口无法复用等问题时,往往束手无策。本文将从连接管理的本质 出发,深入剖析CLOSE_WAITTIME_WAIT这两个最容易出问题的状态,然后带你走进 Linux 内核,看看一个 TCP 连接到底是由哪些数据结构维护的,最后补充流量控制的高级特性。全文严格基于 Linux 内核源码和 TCP 协议规范,结合实际代码案例,帮你彻底搞懂 TCP 的底层机制。


一. TCP 连接管理深度解析

1.1 四次挥手的本质:为什么是四次?

我们都知道 TCP 建立连接需要三次握手(为什么我们之前的博客中讲过),断开连接需要四次挥手。但为什么断开不能像建立那样三次完成呢?

根本原因 :TCP 是全双工协议,连接的断开需要双方分别关闭各自的发送通道。建立连接时,服务器的SYNACK可以合并在一个报文里发送;但断开连接时,服务器收到客户端的FIN报文后,可能还有数据需要发送,不能立即回复FIN,只能先回复ACK确认,等自己的数据发送完毕后,再发送FIN报文关闭自己的发送通道。

四次挥手的完整流程

  • 客户端调用close(),发送FIN报文,进入FIN_WAIT_1状态(关闭客户端到服务器的发送通道)
  • 服务器收到FIN,回复ACK,进入CLOSE_WAIT状态(确认收到客户端的关闭请求)
  • 客户端收到ACK,进入FIN_WAIT_2状态(等待服务器关闭发送通道)
  • 服务器数据发送完毕,调用close(),发送FIN报文,进入LAST_ACK状态(关闭服务器到客户端的发送通道)
  • 客户端收到FIN,回复ACK,进入TIME_WAIT状态
  • 服务器收到ACK,进入CLOSED状态
  • 客户端等待2MSL时间后,进入CLOSED状态

注意: 四次挥手也可能变成三次。如果客户端发送FIN时,服务器刚好也没有数据要发送了,那么服务器可以将ACKFIN合并在一个报文里发送,这样就变成了三次挥手。

1.2 CLOSE_WAIT 状态:服务器资源泄露的元凶

CLOSE_WAIT是 TCP 连接中最容易被忽视但危害极大的一种状态。很多服务器运行一段时间后变得越来越卡,最终崩溃,往往就是因为出现了大量的CLOSE_WAIT连接。

产生原因

CLOSE_WAIT状态出现在被动关闭连接的一方 。当客户端主动关闭连接(发送FIN),服务器回复ACK后,就进入了CLOSE_WAIT状态。此时,服务器需要调用close()来关闭自己的文件描述符,才能发送FIN报文给客户端,完成四次挥手。

如果服务器忘记调用close(),就会一直停留在CLOSE_WAIT状态,对应的文件描述符和内核资源永远不会被释放。

代码案例分析

看下面这段有问题的 TCP 服务器代码(这些代码片段都是直接用的我们之前写过的):

cpp 复制代码
// 有问题的代码:缺少close()调用
void Start() 
{
    while(true) 
    {
        struct sockaddr_in clientaddr;
        socklen_t len = sizeof(clientaddr);
        int sockfd = accept(_listensockfd, (struct sockaddr *)&clientaddr, &len);
        if(sockfd < 0) 
        {
            LOG(LogLevel::WARNING) << "accept error";
            continue;
        }
        
        InetAddr clientaddress(clientaddr);
        LOG(LogLevel::INFO) << "get a new link:" << clientaddress.StringAddress() << " sockfd:" << sockfd;
        
        // 处理客户端请求...
        // 处理完成后,忘记调用close(sockfd)!
    }
}

当客户端断开连接时,服务器会收到FIN报文,回复ACK后进入CLOSE_WAIT状态。但由于代码中没有调用close(sockfd),服务器永远不会发送FIN报文,连接会一直停留在CLOSE_WAIT状态。

危害

  • 文件描述符泄露 :每个CLOSE_WAIT连接都会占用一个文件描述符,而进程的文件描述符数量是有限的(默认 1024)。当文件描述符耗尽后,服务器将无法接受新的连接。
  • 内存泄露 :每个 TCP 连接在内核中都对应着一系列数据结构,会占用大量内存。大量的CLOSE_WAIT连接会导致服务器内存不足。

解决方法

确保在所有代码路径上都正确调用close()关闭 socket。无论客户端是正常断开还是异常断开,服务器都应该在处理完请求后及时关闭文件描述符。

1.3 TIME_WAIT 状态:为什么要等 2MSL?

TIME_WAIT状态出现在主动关闭连接的一方 。当客户端收到服务器的FIN报文并回复ACK后,会进入TIME_WAIT状态,等待2MSL(Maximum Segment Lifetime,报文最大生存时间)后才进入CLOSED状态。

什么是 MSL?

MSL 是 TCP 报文在网络中能够存在的最长时间,RFC 1122 规定为 2 分钟,但 Linux 系统中默认设置为 60 秒。可以通过以下命令查看:

bash 复制代码
cat /proc/sys/net/ipv4/tcp_fin_timeout

为什么是 2MSL?

TCP 协议规定TIME_WAIT等待时间为2MSL,主要有两个原因:

  • 保证网络中所有迟到的报文都自然消散 :如果主动关闭方不等待2MSL就立即关闭连接,然后用相同的端口重新建立连接,可能会收到上一个连接中迟到的报文,导致数据错误。等待2MSL可以确保两个传输方向上的所有报文都已经在网络中消失。
  • 保证最后一个 ACK 报文可靠到达 :如果主动关闭方发送的最后一个ACK报文丢失了,被动关闭方会重发FIN报文。此时主动关闭方还在TIME_WAIT状态,可以重新发送ACK报文。如果没有TIME_WAIT状态,主动关闭方已经关闭了连接,就无法处理重发的FIN报文,导致被动关闭方无法正常关闭。

危害

TIME_WAIT状态虽然是必要的,但在高并发服务器场景下也会带来问题:

  • 端口资源占用 :每个TIME_WAIT连接都会占用一个端口号。如果服务器主动关闭了大量连接,会导致端口号耗尽,无法建立新的连接。
  • 服务器无法立即重启 :如果服务器程序崩溃,作为主动关闭方会进入TIME_WAIT状态,此时立即重启服务器会出现bind error: Address already in use错误。

1.4 解决 TIME_WAIT 问题的最佳实践

方法一:设置 SO_REUSEADDR 选项

这是最常用也是最推荐的方法。通过设置SO_REUSEADDR选项,可以允许在同一个端口上启动多个 socket,只要它们绑定的 IP 地址不同。对于服务器来说,通常绑定的是0.0.0.0(所有 IP 地址),设置SO_REUSEADDR后,即使端口处于TIME_WAIT状态,也可以立即重启服务器。

代码实现:

cpp 复制代码
int opt = 1;
setsockopt(_listensockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

方法二:设置 SO_REUSEPORT 选项(Linux 3.9+)

SO_REUSEPORT是 Linux 3.9 内核引入的新特性,它允许多个进程绑定到完全相同的 IP 地址和端口号,内核会负责在多个进程之间进行负载均衡。这对于实现高性能的多进程服务器非常有用。

代码实现:

cpp 复制代码
int opt = 1;
setsockopt(_listensockfd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));

最佳实践

在实际开发中,通常会同时设置这两个选项:

cpp 复制代码
int opt = 1;
setsockopt(_listensockfd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));

二. 内核视角看 TCP 连接:数据结构的奥秘

我们很多人都只知道调用socket()bind()listen()accept()这些 API,但不知道这些 API 背后内核做了什么。实际上,一个 TCP 连接在内核中是由一系列复杂的数据结构维护的。

2.1 从文件描述符到 TCP 连接:层层嵌套的结构体

在 Linux 中,一切皆文件。socket 也不例外,它也是一种文件。当我们调用socket()函数时,内核会创建一系列的数据结构,并返回一个文件描述符。

这些数据结构之间是层层嵌套的关系,就像俄罗斯套娃一样:

bash 复制代码
文件描述符 -> struct file -> struct socket -> struct sock -> struct inet_sock -> struct inet_connection_sock -> struct tcp_sock

1. struct file:文件结构体

每个打开的文件在内核中都对应一个struct file结构体,它包含了文件的所有信息,如文件路径、打开模式、文件操作函数表等。

c 复制代码
struct file {
    struct path f_path;          // 文件路径
    const struct file_operations *f_op;  // 文件操作函数表
    atomic_long_t f_count;       // 引用计数
    unsigned int f_flags;        // 文件标志
    fmode_t f_mode;              // 文件访问模式
    loff_t f_pos;                // 文件当前位置
    void *private_data;          // 私有数据,指向struct socket
    // ... 其他字段
};

其中,private_data字段指向对应的struct socket结构体。

2. struct socket:套接字结构体

struct socket是套接字的通用表示,它包含了套接字的类型、状态、操作函数表等信息。

c 复制代码
struct socket {
    socket_state state;          // 套接字状态
    short type;                  // 套接字类型(SOCK_STREAM, SOCK_DGRAM等)
    unsigned long flags;         // 套接字标志
    struct file *file;           // 指向对应的文件结构体
    struct sock *sk;             // 指向具体的协议栈结构体
    const struct proto_ops *ops; // 协议操作函数表
    // ... 其他字段
};

3. struct sock:通用套接字结构体

struct sock是所有网络套接字的基类,它包含了所有套接字共有的属性和方法。

c 复制代码
struct sock {
    struct sock_common _sk_common;  // 通用套接字信息
    unsigned int sk_shutdown:2;     // 关闭状态
    int sk_rcvbuf;                  // 接收缓冲区大小
    int sk_sndbuf;                  // 发送缓冲区大小
    struct sk_buff_head sk_receive_queue; // 接收队列
    struct sk_buff_head sk_write_queue;   // 发送队列
    // ... 其他字段
};

4. struct inet_sock:IPv4 套接字结构体

struct inet_sock继承自struct sock,添加了 IPv4 协议特有的属性,如源 IP 地址、目的 IP 地址、源端口、目的端口等。

c 复制代码
struct inet_sock {
    struct sock sk;                // 继承自struct sock
    __be32 daddr;                  // 目的IP地址
    __be32 saddr;                  // 源IP地址
    __be16 dport;                  // 目的端口
    __be16 sport;                  // 源端口
    // ... 其他字段
};

5. struct inet_connection_sock:面向连接的套接字结构体

struct inet_connection_sock继承自struct inet_sock,添加了面向连接的套接字(如 TCP)特有的属性,如连接队列、重传定时器、延迟 ACK 定时器等。

c 复制代码
struct inet_connection_sock {
    struct inet_sock icsk_inet;    // 继承自struct inet_sock
    struct request_sock_queue icsk_accept_queue; // 全连接队列
    struct timer_list icsk_retransmit_timer;     // 重传定时器
    struct timer_list icsk_delack_timer;         // 延迟ACK定时器
    __u32 icsk_rto;                // 重传超时时间
    const struct tcp_congestion_ops *icsk_ca_ops; // 拥塞控制算法
    // ... 其他字段
};

6. struct tcp_sock:TCP 套接字结构体

struct tcp_sock继承自struct inet_connection_sock,添加了 TCP 协议特有的属性,如序列号、确认号、窗口大小、拥塞控制参数等。这就是我们常说的 "TCP 连接"。

c 复制代码
struct tcp_sock {
    struct inet_connection_sock inet_conn; // 继承自struct inet_connection_sock
    u32 rcv_nxt;                  // 期望收到的下一个序列号
    u32 snd_nxt;                  // 下一个要发送的序列号
    u32 snd_una;                  // 未被确认的第一个序列号
    u32 snd_wnd;                  // 发送窗口大小
    u32 rcv_wnd;                  // 接收窗口大小
    u32 srtt;                     // 平滑往返时间
    // ... 其他字段
};

2.2 C 语言实现的多态:统一的套接字接口

从上面的结构体定义可以看出,Linux 内核使用 C 语言实现了面向对象的多态特性。所有的套接字结构体都以struct sock作为第一个成员,这样就可以用一个struct sock *指针指向任何类型的套接字(TCP、UDP、UNIX 域套接字等)。

当我们调用操作函数时,内核会通过函数指针调用具体协议的实现:

cpp 复制代码
// TCP的协议操作函数表
const struct proto_ops inet_stream_ops = {
    .family = PF_INET,
    .bind = inet_bind,
    .connect = inet_stream_connect,
    .accept = inet_accept,
    .sendmsg = inet_sendmsg,  // 调用tcp_sendmsg
    .recvmsg = inet_recvmsg,  // 调用tcp_recvmsg
    .listen = inet_listen,
    .shutdown = inet_shutdown,
    // ... 其他函数
};

// UDP的协议操作函数表
const struct proto_ops inet_dgram_ops = {
    .family = PF_INET,
    .bind = inet_bind,
    .connect = inet_dgram_connect,
    .accept = sock_no_accept,  // UDP不支持accept
    .sendmsg = inet_sendmsg,   // 调用udp_sendmsg
    .recvmsg = inet_recvmsg,   // 调用udp_recvmsg
    .listen = sock_no_listen,  // UDP不支持listen
    // ... 其他函数
};

这种设计使得上层应用可以使用统一的接口操作不同类型的套接字,极大地提高了代码的复用性和可扩展性。

2.3 全连接队列:accept 背后的机制

当我们调用listen()函数时,内核会为监听 socket 创建一个全连接队列(icsk_accept_queue)。当客户端的三次握手完成后,内核会将新建立的 TCP 连接放入这个队列中。

当我们调用accept()函数时,内核会从全连接队列的头部取出一个连接,创建一个新的 socket 和文件描述符,返回给应用程序。

如果全连接队列满了,内核会丢弃客户端的ACK报文,导致客户端重传ACK,从而影响连接建立的性能。因此,在高并发服务器中,需要合理设置全连接队列的大小(listen()函数的第二个参数)。


三. TCP 流量控制:不止是滑动窗口

TCP 的流量控制机制是为了防止发送方发送数据的速度超过接收方的处理能力,导致接收方缓冲区溢出,数据丢失。

3.1 流量控制的基本原理

TCP 的流量控制是通过滑动窗口 机制实现的。接收方在每次发送ACK报文时,都会在 TCP 首部的 "窗口大小" 字段中告知发送方自己当前接收缓冲区的剩余空间大小。发送方根据这个窗口大小来调整自己的发送速度。

  • 如果接收方缓冲区快满了,就会将窗口大小设置为一个较小的值,通知发送方减慢发送速度。
  • 如果接收方缓冲区满了,就会将窗口大小设置为 0,通知发送方停止发送数据。

3.2 窗口扩大因子:突破 64KB 的限制

TCP 首部的窗口大小字段是 16 位的,最大只能表示 65535 字节(64KB)。这在早期的网络中是足够的,但在今天的高速网络中,64KB 的窗口大小严重限制了 TCP 的传输性能。

为了解决这个问题,TCP 引入了窗口扩大因子 (Window Scale Factor)选项。这个选项在三次握手的SYN报文中协商,实际的窗口大小计算公式为:

bash 复制代码
实际窗口大小 = 窗口字段值 << 窗口扩大因子

例如,如果窗口字段值是 3000,窗口扩大因子是 2,那么实际窗口大小就是 3000 << 2 = 12000 字节。

窗口扩大因子的最大值是 14,因此 TCP 的最大窗口大小可以达到 65535 << 14 = 1GB。

3.3 窗口探测与窗口更新

当接收方将窗口大小设置为 0 后,发送方会停止发送数据。但如果接收方的缓冲区有了空闲空间,如何通知发送方呢?

TCP 采用了两种机制:

  • 窗口更新通知:当接收方的缓冲区有了空闲空间后,会主动发送一个窗口更新报文给发送方,告知新的窗口大小。
  • 窗口探测:发送方会定期发送一个窗口探测报文(只包含一个字节的数据),询问接收方当前的窗口大小。

这两种机制同时工作,确保发送方能够及时知道接收方的窗口变化,恢复数据传输。


四. 实战:TCP 服务器的常见问题与优化(想看的可以看看)

4.1 端口复用的正确配置

在编写 TCP 服务器时,一定要在创建 socket 后立即设置端口复用选项:

cpp 复制代码
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(int argc, char *argv[]) 
{
    if (argc != 2) {
        printf("Usage: %s <port>\n", argv[0]);
        return 1;
    }

    int port = atoi(argv[1]);

    // 1. 创建socket
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenfd < 0) 
    {
        perror("socket error");
        return 1;
    }

    // 2. 设置端口复用(关键!)
    int opt = 1;
    if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt)) < 0) 
    {
        perror("setsockopt error");
        close(listenfd);
        return 1;
    }

    // 3. 绑定地址
    struct sockaddr_in serveraddr;
    memset(&serveraddr, 0, sizeof(serveraddr));
    serveraddr.sin_family = AF_INET;
    serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);
    serveraddr.sin_port = htons(port);

    if (bind(listenfd, (struct sockaddr *)&serveraddr, sizeof(serveraddr)) < 0) 
    {
        perror("bind error");
        close(listenfd);
        return 1;
    }

    // 4. 开始监听
    if (listen(listenfd, 1024) < 0) 
    {
        perror("listen error");
        close(listenfd);
        return 1;
    }

    printf("Server listening on port %d\n", port);

    // 5. 事件循环
    while (1) 
    {
        struct sockaddr_in clientaddr;
        socklen_t len = sizeof(clientaddr);
        int connfd = accept(listenfd, (struct sockaddr *)&clientaddr, &len);
        if (connfd < 0) 
        {
            perror("accept error");
            continue;
        }

        printf("New connection from %s:%d\n", inet_ntoa(clientaddr.sin_addr), ntohs(clientaddr.sin_port));

        // 处理客户端请求...
        // 处理完成后,一定要关闭connfd!
        close(connfd);
    }

    close(listenfd);
    return 0;
}

4.2 避免 CLOSE_WAIT 泄露的代码规范

  • 在所有代码路径上都要调用close():无论客户端是正常断开还是异常断开,无论处理请求成功还是失败,都要确保关闭 socket。
  • 使用 RAII 机制:在 C++ 中,可以使用智能指针或 RAII 类来管理 socket,确保在对象析构时自动关闭 socket。
  • 定期检查连接状态:对于长连接服务器,可以设置心跳机制,定期检查客户端是否存活,及时关闭不活跃的连接。

结尾:

html 复制代码
🍓 我是草莓熊 Lotso!若这篇技术干货帮你打通了学习中的卡点:
👀 【关注】跟我一起深耕技术领域,从基础到进阶,见证每一次成长
❤️ 【点赞】让优质内容被更多人看见,让知识传递更有力量
⭐ 【收藏】把核心知识点、实战技巧存好,需要时直接查、随时用
💬 【评论】分享你的经验或疑问(比如曾踩过的技术坑?),一起交流避坑
🗳️ 【投票】用你的选择助力社区内容方向,告诉大家哪个技术点最该重点拆解
技术之路难免有困惑,但同行的人会让前进更有方向~愿我们都能在自己专注的领域里,一步步靠近心中的技术目标!

结语:本文深入解析了 TCP 协议的核心机制,理解这些底层机制,不仅能帮助你写出更健壮、更高性能的 TCP 服务器,还能让你在面试中脱颖而出。TCP 协议虽然复杂,但只要抓住 "可靠性" 和 "性能" 这两个核心目标,就能理解它的设计思想。

✨把这些内容吃透超牛的!放松下吧✨ ʕ˘ᴥ˘ʔ づきらど