4、TCP Socket编程流程:服务端与客户端的完整通信流程,三次握手与四次挥手在Socket中的体现

TCP(Transmission Control Protocol)是面向连接的、可靠的字节流传输协议。在Socket编程中,TCP的通信流程严格遵循“连接建立—数据传输—连接释放”三阶段模型。理解服务端与客户端在Socket API调用上的对应关系,以及三次握手、四次挥手在内核协议栈与用户态API之间的映射,是编写健壮网络程序的基础。本章将从API调用时序、状态迁移、异常处理三个维度,拆解TCP Socket的完整生命周期。

服务端 (Server) 客户端 (Client) socket() bind() listen() accept() 阻塞 socket() connect() 三次握手 (Three-Way Handshake) ① SYN (seq=x) ② SYN+ACK (seq=y, ack=x+1) ③ ACK (seq=x+1, ack=y+1) 数据传输 (Data Transfer) — 双向全双工 write() / send() ⟷ read() / recv() 四次挥手 (Four-Way Wavehand) ① FIN (seq=u) — 主动关闭方 ② ACK (ack=u+1) — 被动关闭方 ③ FIN (seq=w) — 被动关闭方 ④ ACK (ack=w+1) — 主动关闭方

上图展示了TCP Socket从创建到关闭的完整时序。服务端通过 socket()→bind()→listen()→accept() 四步进入监听状态,客户端通过 socket()→connect() 发起连接。三次握手发生在 connect() 与 accept() 之间,四次挥手则发生在任一方调用 close() 之后。下面逐一拆解每个阶段的原理与编程要点。

4.1 核心原理:Socket API与TCP状态机的映射

TCP协议栈在内核中维护了一个状态机,Socket API是用户态操作该状态机的唯一入口。理解API调用如何驱动状态迁移,是排查网络问题的关键。

API调用服务端/客户端触发TCP状态变化阻塞行为
socket()双方创建文件描述符,初始状态CLOSED立即返回
bind()服务端(客户端可选)绑定IP和端口,状态不变立即返回
listen()服务端CLOSED → LISTEN立即返回
connect()客户端SYN_SENT → ESTABLISHED阻塞直到握手完成或超时
accept()服务端从已完成队列取连接,返回新fd阻塞直到有连接到达
close()任一方FIN_WAIT_1 → FIN_WAIT_2 → TIME_WAIT立即返回,内核异步完成挥手

值得注意的是,accept() 并不参与三次握手。握手由内核协议栈自动完成,accept() 只是从“已完成连接队列”中取出一个已经建立好的连接。这意味着即使服务端来不及调用 accept(),内核也能完成多个客户端的握手,只是队列长度受 listen() 的 backlog 参数限制。

4.2 操作流程:服务端与客户端的标准编程骨架

以下代码展示了TCP通信的最小完整实现。服务端使用多线程处理并发连接,客户端模拟发送与接收。

/* 服务端代码 (server.c) */
#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>

void* handle_client(void* arg) {
    int client_fd = *(int*)arg;
    char buffer[1024] = {0};
    // 接收客户端数据
    int n = read(client_fd, buffer, sizeof(buffer) - 1);
    if (n > 0) {
        printf("收到: %s\n", buffer);
        // 回显
        write(client_fd, buffer, n);
    }
    close(client_fd);
    return NULL;
}

int main() {
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_port = htons(8080),
        .sin_addr.s_addr = INADDR_ANY
    };
    // 设置端口复用,避免TIME_WAIT导致重启失败
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    
    bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(server_fd, 5);  // backlog=5
    
    while (1) {
        struct sockaddr_in client_addr;
        socklen_t len = sizeof(client_addr);
        int client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &len);
        pthread_t tid;
        pthread_create(&tid, NULL, handle_client, &client_fd);
        pthread_detach(tid);
    }
    close(server_fd);
    return 0;
}
/* 客户端代码 (client.c) */
#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string.h>
#include <unistd.h>

int main() {
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in server_addr = {
        .sin_family = AF_INET,
        .sin_port = htons(8080),
        .sin_addr.s_addr = inet_addr("127.0.0.1")
    };
    
    // 三次握手发生在此处
    if (connect(sock, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
        perror("connect");
        return 1;
    }
    
    char *msg = "Hello TCP";
    write(sock, msg, strlen(msg));
    
    char buffer[1024] = {0};
    read(sock, buffer, sizeof(buffer));
    printf("服务端回应: %s\n", buffer);
    
    close(sock);  // 四次挥手开始
    return 0;
}

编译运行:gcc server.c -o server -lpthread && gcc client.c -o client。先启动 ./server,再启动 ./client,即可看到回显效果。

4.3 案例拆解:一次完整的连接与断开过程

假设客户端连接服务端后立即发送“PING”,服务端回“PONG”,然后双方关闭连接。我们通过 tcpdump 抓包观察实际交互。

抓包命令:sudo tcpdump -i lo -nn -S port 8080

关键输出(已简化):

1  0.000000 127.0.0.1.50000 > 127.0.0.1.8080: Flags [S], seq 1000
2  0.000021 127.0.0.1.8080 > 127.0.0.1.50000: Flags [S.], seq 2000, ack 1001
3  0.000042 127.0.0.1.50000 > 127.0.0.1.8080: Flags [.], ack 2001
4  0.000100 127.0.0.1.50000 > 127.0.0.1.8080: Flags [P.], seq 1001:1005, ack 2001
5  0.000120 127.0.0.1.8080 > 127.0.0.1.50000: Flags [.], ack 1005
6  0.000150 127.0.0.1.8080 > 127.0.0.1.50000: Flags [P.], seq 2001:2005, ack 1005
7  0.000170 127.0.0.1.50000 > 127.0.0.1.8080: Flags [.], ack 2005
8  0.000200 127.0.0.1.50000 > 127.0.0.1.8080: Flags [F.], seq 1005
9  0.000220 127.0.0.1.8080 > 127.0.0.1.50000: Flags [.], ack 1006
10 0.000250 127.0.0.1.8080 > 127.0.0.1.50000: Flags [F.], seq 2005
11 0.000270 127.0.0.1.50000 > 127.0.0.1.8080: Flags [.], ack 2006

分析过程:

  1. 第1-3包:三次握手。客户端初始序列号seq=1000,服务端seq=2000。注意SYN包消耗一个序列号,因此ack=1001。
  2. 第4-7包:数据传输。客户端发送5字节“PING”(seq 1001:1005),服务端确认ack=1005;服务端回“PONG”(seq 2001:2005),客户端确认ack=2005。
  3. 第8-11包:四次挥手。客户端先发FIN(seq=1005),服务端确认ack=1006;服务端随后发FIN(seq=2005),客户端确认ack=2006。注意服务端的FIN和ACK分开发送,这是四次挥手的典型特征。

风险与代价:客户端在发送FIN后进入TIME_WAIT状态,持续2MSL(约2分钟)。如果服务端立即重启并绑定同一端口,可能因端口占用而失败。解决方案是服务端设置 SO_REUSEADDR,如代码所示。

4.4 常见误区与总结复盘

误区1:认为accept()参与三次握手。实际上握手由内核完成,accept()只是从队列取连接。如果accept()调用过慢,backlog队列满后,新连接会被内核丢弃或客户端收到RST。

误区2:忽略TIME_WAIT状态。主动关闭方(通常是客户端)会进入TIME_WAIT,这是为了确保最后一个ACK到达对端。高并发短连接场景下,服务端也可能成为主动关闭方,导致大量TIME_WAIT连接占用端口。

误区3:混淆close()与shutdown()。close()会立即释放文件描述符,但若socket被多个进程共享,引用计数不为0则不会触发FIN。shutdown()可以单独关闭读或写方向,更精细地控制挥手过程。

经验总结:

  • 服务端务必设置 SO_REUSEADDR,避免重启时端口被TIME_WAIT占用。
  • 客户端connect()的超时时间默认约75秒,可通过 setsockopt(SO_SNDTIMEO) 缩短。
  • 使用 netstat -tpn 或 ss -tn 查看连接状态,排查卡在SYN_SENT或FIN_WAIT_1的问题。
  • 对于长连接,建议应用层增加心跳机制,防止TCP连接因中间设备超时被静默断开。

掌握TCP Socket的完整生命周期,是编写可靠网络程序的基础。下一阶段的学习应聚焦于I/O多路复用(select/poll/epoll),以解决多连接并发场景下的资源效率问题。