4. TCP 客户端与服务端实现:单进程迭代服务器、客户端实现、代码调试与测试

好,咱们今天来点真家伙。前面几章把 TCP 协议和 Socket API 的理论讲了个七七八八,现在该动手了。这一章的目标很明确:写一个能跑起来的单进程迭代服务器,再写一个客户端跟它对话。说白了,就是让你亲眼看着数据从客户端发出去,经过网络栈,被服务器收进来,再原路返回。

我个人习惯,学网络编程一定要先跑通这个最小闭环。哪怕你以后做高并发、做异步框架,这个基础模型的理解深度,直接决定了你排查问题的速度。我在项目里见过太多人,一上来就怼 epoll、怼多线程,结果连最基本的 connect 超时都说不清楚——嗯,咱们不干那种事。

4.1 单进程迭代服务器的核心模型

什么叫「单进程迭代服务器」?说白了就是:一个进程,一次只服务一个客户端,等这个客户端断开连接了,才去 accept 下一个。它不并发,不 fork,不搞线程池,就是最朴素的 while 循环。

核心流程:

  1. 创建 socket → 绑定地址 → 监听 → 进入循环
  2. 调用 accept 阻塞等待客户端连接
  3. 连接建立后,循环 recv/send 处理该客户端的数据
  4. 客户端断开后,关闭连接 socket,回到步骤 2

你想想看,这个模型虽然简单,但它把 TCP 服务端最核心的几个状态都暴露出来了:LISTEN、ESTABLISHED、CLOSE_WAIT、TIME_WAIT。我当年第一次调通这个模型的时候,用 netstat 观察连接状态变化,那种感觉就像第一次看清了齿轮怎么咬合——很踏实。

单进程迭代服务器流程图 单进程迭代服务器核心流程 socket() 创建套接字 bind() 绑定地址和端口 listen() 进入监听状态 accept() 阻塞等待连接 recv() / send() 循环处理 说明:客户端断开后,关闭连接 socket,回到 accept() 等待下一个连接

4.2 服务端代码实现

来,直接上代码。我习惯把错误处理写得比较啰嗦,因为在实际项目中,你花在查「为什么连不上」上的时间,远比写业务逻辑多。

// tcp_server.c - 单进程迭代服务器
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.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 = sizeof(client_addr);
    char buffer[BUFFER_SIZE];
    ssize_t bytes_read;

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

    // 2. 设置地址复用,避免 TIME_WAIT 导致 bind 失败
    int opt = 1;
    if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) {
        perror("setsockopt failed");
        close(server_fd);
        exit(EXIT_FAILURE);
    }

    // 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 failed");
        close(server_fd);
        exit(EXIT_FAILURE);
    }

    // 4. 监听
    if (listen(server_fd, 5) < 0) {
        perror("listen failed");
        close(server_fd);
        exit(EXIT_FAILURE);
    }

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

    // 5. 主循环:迭代处理客户端
    while (1) {
        client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &client_len);
        if (client_fd < 0) {
            perror("accept failed");
            continue;
        }

        char client_ip[INET_ADDRSTRLEN];
        inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, INET_ADDRSTRLEN);
        printf("New client connected: %s:%d\n", client_ip, ntohs(client_addr.sin_port));

        // 6. 处理单个客户端的数据
        while ((bytes_read = recv(client_fd, buffer, BUFFER_SIZE - 1, 0)) > 0) {
            buffer[bytes_read] = '\0';
            printf("Received: %s", buffer);

            // 回显数据
            send(client_fd, buffer, bytes_read, 0);
        }

        if (bytes_read == 0) {
            printf("Client disconnected: %s:%d\n", client_ip, ntohs(client_addr.sin_port));
        } else {
            perror("recv error");
        }

        close(client_fd);
    }

    close(server_fd);
    return 0;
}

我的一点经验:SO_REUSEADDR 这个选项,新手经常忽略。你调试时 Ctrl+C 杀掉服务器,端口会进入 TIME_WAIT 状态,如果不设置这个选项,紧接着重启就会 bind 失败。我曾经因为这个浪费了半小时,后来就养成习惯了——每个服务端 socket 都加上它。

4.3 客户端代码实现

客户端相对简单:创建 socket,connect 到服务器,然后循环发送数据并接收回显。我故意让客户端从标准输入读取,这样你可以手动敲数据测试。

// tcp_client.c - TCP 客户端
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define SERVER_IP "127.0.0.1"
#define PORT 8888
#define BUFFER_SIZE 1024

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

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

    // 2. 配置服务器地址
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(PORT);
    if (inet_pton(AF_INET, SERVER_IP, &server_addr.sin_addr) <= 0) {
        perror("invalid address");
        close(sock_fd);
        exit(EXIT_FAILURE);
    }

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

    printf("Connected to server %s:%d\n", SERVER_IP, PORT);

    // 4. 数据收发循环
    while (1) {
        printf("Enter message (or 'quit' to exit): ");
        if (fgets(buffer, BUFFER_SIZE, stdin) == NULL) {
            break;
        }

        // 去掉换行符
        buffer[strcspn(buffer, "\n")] = '\0';

        if (strcmp(buffer, "quit") == 0) {
            break;
        }

        bytes_sent = send(sock_fd, buffer, strlen(buffer), 0);
        if (bytes_sent < 0) {
            perror("send failed");
            break;
        }

        bytes_received = recv(sock_fd, buffer, BUFFER_SIZE - 1, 0);
        if (bytes_received < 0) {
            perror("recv failed");
            break;
        }

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

    close(sock_fd);
    printf("Connection closed.\n");
    return 0;
}

4.4 编译与调试

代码写完了,怎么跑起来?我建议你开两个终端窗口,一个跑服务器,一个跑客户端。这样能直观看到两边同时输出的日志。

编译步骤

# 编译服务器
gcc -o tcp_server tcp_server.c

# 编译客户端
gcc -o tcp_client tcp_client.c

运行与测试

  1. 终端1:启动服务器
    ./tcp_server
    Server listening on port 8888...
  2. 终端2:启动客户端
    ./tcp_client
    Connected to server 127.0.0.1:8888
    Enter message (or 'quit' to exit):
  3. 输入数据测试
    Enter message (or 'quit' to exit): hello
    Server echoed: hello
    Enter message (or 'quit' to exit): world
    Server echoed: world

注意:如果你在客户端输入中文,可能会遇到编码问题。recv 是按字节收的,中文 UTF-8 编码占 3 个字节,如果 buffer 大小不是 3 的倍数,可能出现半个汉字的情况。生产环境中建议使用 protobuf 或 JSON 等结构化协议,不要裸发字符串。

4.5 常见问题与调试技巧

跑通是第一步,但跑通之后你可能会遇到各种奇怪的问题。我把自己踩过的坑列出来,你遇到了可以直接对照排查。

现象 可能原因 排查方法
bind 失败:Address already in use 端口被占用,或上次连接处于 TIME_WAIT 用 netstat -tlnp | grep 8888 查看;加上 SO_REUSEADDR
connect 超时 服务器没启动,或防火墙拦截 用 telnet 127.0.0.1 8888 测试端口是否可达
recv 返回 -1 连接被对端重置(RST) 检查是否在 send 后立即 close 了 socket
客户端输入后无响应 send 的数据没带换行符,服务器 recv 阻塞 确认 fgets 或 send 的数据长度正确

调试利器:我强烈建议你学会用 strace 和 tcpdump。strace 可以追踪系统调用,看 socket、bind、connect 每一步的返回值;tcpdump 可以抓包,看 TCP 三次握手和四次挥手的具体报文。这两个工具用熟了,网络编程基本没有你查不了的问题。

4.6 单进程模型的局限性

嗯,这里要说点实话。单进程迭代服务器虽然简单直观,但它的性能瓶颈很明显:

  • 串行处理:一个客户端没处理完,其他客户端只能排队等 accept
  • 阻塞 I/O:如果客户端发完数据后不主动断开,服务器会一直卡在 recv 上
  • 无并发能力:无法同时服务多个客户端,适合教学和原型验证

我在早期做 IoT 网关时,曾经用这个模型接几十个设备,结果一个设备断连异常,整个服务就卡死了。后来才改成多进程 + select 模型。不过话说回来,没有这个基础,你后面理解多路复用、事件驱动也会很吃力。所以,先把这一步走扎实。

本章小结:你学会了单进程迭代服务器的完整实现,包括 socket 创建、bind、listen、accept 以及客户端的 connect 和收发数据。你还掌握了基本的调试方法和常见问题的排查思路。这个模型虽然简单,但它是所有复杂网络服务的地基。