【C/C++】从 socket 到多线程 TCP Echo 服务器:fd、accept 与 recv/send 全流程

【C/C++】从 socket 到多线程 TCP Echo 服务器:fd、accept 与 recv/send 全流程

1. 这篇文章解决什么问题

在学习网络编程时,第一道坎不是 epoll,而是搞清楚服务端从"监听端口"到"处理客户端数据"到底经历了什么。这个项目里的 tcp_server_threads.c 是一个非常直接的 TCP Echo Server:客户端发什么,服务端就原样回什么。

它覆盖了 TCP 服务端最核心的 5 步:

  1. socket() 创建监听套接字。
  2. bind() 绑定 IP 和端口。
  3. listen() 让端口进入监听状态。
  4. accept() 从已建立连接队列中取出一个客户端连接。
  5. recv() / send() 对客户端 fd 读写数据。

2. fd 和连接不是一回事

README 中有一个很关键的点:fd 是进程级的 IO 句柄,而 TCP 连接状态在内核协议栈里维护。

accept() 之前,三次握手可能已经完成,内核里已经有连接状态;但应用层还没有拿到可读写的客户端 fd。accept() 返回之后,内核才给应用分配一个新的 fd,之后应用才能对这个 fd 调用 recv()send()

项目里的服务端监听端口是 8080:

c 复制代码
int serverfd = socket(AF_INET, SOCK_STREAM, 0);

struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(8080);

bind(serverfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
listen(serverfd, 5);

这里的 serverfd 是监听 socket,只负责接收新连接,不负责承载某个客户端的数据收发。真正和客户端通信的是 accept() 返回的 clientfd

3. accept 之后创建线程

多线程版的思路非常朴素:主线程只负责 accept(),每来一个客户端就创建一个线程处理。

c 复制代码
while (1)
{
    struct sockaddr_in client_addr;
    socklen_t client_len = sizeof(client_addr);
    int clientfd = accept(serverfd, (struct sockaddr *)&client_addr, &client_len);
    if (clientfd < 0)
    {
        perror("accept");
        continue;
    }

    pthread_t tid;
    int *pclient = malloc(sizeof(int));
    *pclient = clientfd;
    pthread_create(&tid, NULL, handle_client, pclient);
    pthread_detach(tid);
}

这里把 clientfd 放到堆内存里传给线程,是因为局部变量在下一轮循环可能被覆盖。线程函数处理完以后会 free(arg)

pthread_detach(tid) 的作用是让线程结束后自动回收资源,主线程不需要再 pthread_join()。这对 echo server 这种"连接来了就独立处理"的模型比较方便。

4. recv/send 实现 Echo

线程函数的逻辑就是持续收数据、打印、再写回客户端:

c 复制代码
void *handle_client(void *arg)
{
    int clientfd = *(int *)arg;
    char buffer[1024];
    ssize_t n;

    while ((n = recv(clientfd, buffer, sizeof(buffer) - 1, 0)) > 0)
    {
        buffer[n] = '\0';
        printf("Received from client: %s\n", buffer);
        send(clientfd, buffer, n, 0);
    }

    close(clientfd);
    free(arg);
    return NULL;
}

几个细节值得注意:

  • recv() 返回大于 0,表示读到了数据。
  • recv() 返回 0,通常表示对端正常关闭连接。
  • recv() 返回小于 0,表示发生错误。
  • send() 这里直接把收到的 n 字节写回去,所以它是一个 Echo Server。

5. 编译和测试

编译:

bash 复制代码
gcc tcp_server_threads.c -o threads -pthread

启动服务端:

bash 复制代码
./threads

另开一个终端,用 nc 测试:

bash 复制代码
nc 127.0.0.1 8080
hello tcp

客户端输入 hello tcp 后,服务端会打印收到的数据,客户端也会收到同样的响应。

6. 多线程模型的优缺点

优点很明显:

  • 代码直观,符合"一个连接一个处理流程"的思维。
  • 阻塞式 recv() / send() 也容易理解。
  • 很适合作为 TCP 服务端入门代码。

缺点也很明显:

  • 每个连接都创建线程,线程栈和调度成本都不低。
  • 并发连接一多,系统会被线程数量拖垮。
  • 适合 C10、C100 级别学习,不适合作为 C10K/C100K 的核心模型。

如果你在本地测试时遇到 bind failed,大概率是端口已经被占用,或者上一次进程还没完全退出。可以用:

bash 复制代码
ss -lntp | grep 8080

7. 小结

多线程 TCP Echo Server 是网络编程的第一块积木。理解它之后,再看 selectpollepoll 就会更自然:后面的模型本质上都是在解决同一个问题,如何不用"每个连接一个线程"的方式管理大量 fd。

学习链接: https://github.com/0voice