3. TCP客户端与服务端:TCP三次握手与四次挥手、bind/listen/accept/connect函数详解、简单的回射服务器

说实话,TCP 这块内容,是网络编程里最绕不开的硬骨头。我当年刚入行时,被三次握手和四次挥手折磨得够呛——背了无数遍状态图,一到写代码就懵。后来在项目里踩过几次坑,才真正理解这些机制到底在干什么。

今天咱们就把 TCP 客户端和服务端的核心逻辑拆开揉碎。从握手挥手,到那几个关键函数,最后撸一个简单的回射服务器。嗯,跟着我走一遍,你也能写出健壮的 TCP 程序。

3.1 TCP 三次握手:连接是怎么建立的?

先问一个问题:客户端和服务端之间,怎么知道对方准备好了?

TCP 用三次握手来解决这个问题。说白了,就是双方互相确认「我在,你也在,咱们可以聊了」。

三次握手流程:

  1. 客户端 → 服务端:SYN — 「我想跟你建立连接,这是我的初始序列号 x」
  2. 服务端 → 客户端:SYN + ACK — 「收到,我准备好了,这是我的初始序列号 y,我确认收到了你的 x」
  3. 客户端 → 服务端:ACK — 「好,我确认收到了你的 y,咱们开始通信吧」

为什么是三次,不是两次?我举个例子你就明白了。假设只有两次握手:客户端发 SYN,服务端回 ACK,连接就算建立了。但如果这个 SYN 在网络中延迟了,客户端超时重传了一个新的 SYN,服务端又回了 ACK。这时候第一个延迟的 SYN 到了,服务端会误以为客户端又要建立新连接,白白浪费资源。三次握手能避免这种「历史重复连接」的问题。

我在项目中遇到过一个问题:客户端频繁重连时,服务端出现大量 TIME_WAIT 状态的连接。后来排查发现,是客户端没有正确处理三次握手中的 ACK 丢失场景。嗯,这个后面讲四次挥手时会细说。

3.2 TCP 四次挥手:连接是怎么关闭的?

有建立就有关闭。四次挥手比握手复杂一点,因为 TCP 连接是全双工的——双方可以同时收发数据。关闭时,每一方向都要单独关闭自己的发送通道。

四次挥手流程:

  1. 主动关闭方 → 被动关闭方:FIN — 「我这边数据发完了,我要关闭发送通道」
  2. 被动关闭方 → 主动关闭方:ACK — 「收到你的 FIN,但我还有数据要发,你先等着」
  3. 被动关闭方 → 主动关闭方:FIN — 「我这边数据也发完了,可以关了」
  4. 主动关闭方 → 被动关闭方:ACK — 「收到,我关闭连接」

注意第二步和第三步之间,被动关闭方可能还在发送数据。这就是为什么挥手需要四次,而不是三次。

我曾经踩过一个坑:服务端主动关闭连接后,没有处理 TIME_WAIT 状态。结果端口被占用,导致服务端无法立即重启。后来我在代码里加了 SO_REUSEADDR 选项,才解决这个问题。

避坑指南:主动关闭方在发送最后一个 ACK 后,会进入 TIME_WAIT 状态,持续 2MSL(约 1-4 分钟)。这是为了确保被动关闭方收到了 ACK。如果 ACK 丢失,被动关闭方会重发 FIN,主动关闭方需要重新响应。

3.3 核心函数详解:bind/listen/accept/connect

这几个函数是 TCP 编程的基石。我习惯把它们分成两组:服务端用的(bind、listen、accept)和客户端用的(connect)。

3.3.1 bind() — 绑定地址和端口

#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

bind 的作用,就是把 socket 和一个具体的 IP 地址、端口号绑定在一起。服务端必须调用 bind,否则客户端不知道往哪连。

有几个细节要注意:

  • 端口号小于 1024 的需要 root 权限。我刚开始写程序时,老用 80 端口,结果 bind 总是失败,后来才发现是权限问题。
  • 可以绑定 INADDR_ANY(0.0.0.0),表示监听所有网络接口。这在服务器有多块网卡时特别有用。
  • 如果端口被占用,bind 会返回 -1,错误码 EADDRINUSE。这时候可以用 SO_REUSEADDR 选项来重用地址。

3.3.2 listen() — 开始监听

int listen(int sockfd, int backlog);

listen 把 socket 从主动模式变成被动模式,开始等待客户端连接。backlog 参数指定了连接队列的最大长度。

你想想看,如果客户端连接来得太快,服务端来不及处理,这些连接就会排队。backlog 就是队列的上限。超过这个数,客户端会收到 ECONNREFUSED

我的经验:backlog 不要设太大,一般 128 或 256 就够了。设太大反而会掩盖服务端处理能力不足的问题。我曾经在一个高并发项目里把 backlog 设成 1024,结果服务端崩溃时,队列里积压了大量连接,恢复后全部超时,雪上加霜。

3.3.3 accept() — 接受连接

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

accept 从连接队列里取出一个已完成三次握手的连接,返回一个新的 socket 文件描述符。这个新 socket 专门用于和客户端通信,原来的监听 socket 继续等待新连接。

accept 是阻塞的——如果没有新连接,它会一直等。当然,你可以用非阻塞模式或者 select/epoll 来避免阻塞。

3.3.4 connect() — 发起连接

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

connect 是客户端用的。它发起三次握手,如果成功返回 0,失败返回 -1。

connect 也是阻塞的,默认会等待 75 秒(不同系统略有差异)才超时。我在写客户端程序时,一般会设置 SO_SNDTIMEO 来控制超时时间,避免用户等太久。

3.4 简单的回射服务器

理论说完了,咱们写个实际能跑的程序。回射服务器(echo server)是最简单的 TCP 服务:客户端发什么,服务端原样返回什么。

3.4.1 服务端代码

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>

#define PORT 8888
#define BUFFER_SIZE 1024

int main() {
    int server_fd, client_fd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_len;
    char buffer[BUFFER_SIZE];
    ssize_t n;

    // 1. 创建 socket
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd < 0) {
        perror("socket");
        exit(1);
    }

    // 2. 设置地址重用(避免 TIME_WAIT 问题)
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    // 3. 绑定地址
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(PORT);

    if (bind(server_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
        perror("bind");
        close(server_fd);
        exit(1);
    }

    // 4. 开始监听
    if (listen(server_fd, 128) < 0) {
        perror("listen");
        close(server_fd);
        exit(1);
    }

    printf("Echo server listening on port %d...\n", PORT);

    // 5. 循环接受连接
    while (1) {
        client_len = sizeof(client_addr);
        client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &client_len);
        if (client_fd < 0) {
            perror("accept");
            continue;
        }

        printf("New client connected\n");

        // 6. 回射数据
        while ((n = read(client_fd, buffer, BUFFER_SIZE)) > 0) {
            write(client_fd, buffer, n);
        }

        printf("Client disconnected\n");
        close(client_fd);
    }

    close(server_fd);
    return 0;
}

3.4.2 客户端代码

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define PORT 8888
#define BUFFER_SIZE 1024

int main() {
    int sock_fd;
    struct sockaddr_in server_addr;
    char buffer[BUFFER_SIZE];
    ssize_t n;

    // 1. 创建 socket
    sock_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (sock_fd < 0) {
        perror("socket");
        exit(1);
    }

    // 2. 设置服务器地址
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(PORT);
    inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);

    // 3. 连接服务器
    if (connect(sock_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
        perror("connect");
        close(sock_fd);
        exit(1);
    }

    printf("Connected to server\n");

    // 4. 发送数据并接收回射
    while (1) {
        printf("Enter message: ");
        if (fgets(buffer, BUFFER_SIZE, stdin) == NULL) {
            break;
        }

        write(sock_fd, buffer, strlen(buffer));

        n = read(sock_fd, buffer, BUFFER_SIZE);
        if (n <= 0) {
            break;
        }

        buffer[n] = '\0';
        printf("Echo: %s", buffer);
    }

    close(sock_fd);
    return 0;
}

3.5 核心流程可视化

下面这张图把整个 TCP 通信流程串起来了。从 socket 创建,到三次握手建立连接,再到数据收发,最后四次挥手关闭连接。你对照着代码看,会更清晰。

TCP 客户端与服务端通信流程 客户端 服务端 网络 1. socket() 2. connect() 3. send() 4. recv() 5. close() 1. socket() 2. bind() 3. listen() 4. accept() 5. recv() 6. send() 7. close() SYN (seq=x) SYN+ACK (seq=y, ack=x+1) ACK (seq=x+1, ack=y+1) 数据请求 数据响应 FIN (seq=m) ACK (ack=m+1) FIN (seq=n) ACK (ack=n+1) 三次握手 数据传输 四次挥手

3.6 几个容易踩的坑

代码写完了,图也画了,我再分享几个实际项目中遇到的坑:

  • bind 失败:端口被占用是最常见的。用 netstat -tlnp | grep 端口号 查一下谁在用。记得加 SO_REUSEADDR
  • accept 返回 -1:如果客户端在三次握手完成前断开了连接,accept 可能返回 ECONNABORTED。这时候不要 panic,继续 accept 就行。
  • read 返回 0:表示对方关闭了连接。很多新手会忽略这个,导致死循环。记住,read 返回 0 就是对方说拜拜了。
  • write 被信号中断:write 返回 -1 且 errno 是 EINTR 时,重试即可。这不是真正的错误。

一个小技巧:调试 TCP 程序时,用 strace 跟踪系统调用,能清楚看到每一步发生了什么。比如 strace -e trace=network ./server,所有 socket 相关的调用都会打印出来。我排查连接问题时,这招屡试不爽。

好了,TCP 客户端和服务端的基础就讲到这里。代码你可以直接拿去跑,跑通了再回头看看三次握手和四次挥手的流程,会有更深的理解。


公众号:蓝海资料掘金营,微信deep3321