云计算百科
云计算领域专业知识百科平台

Linux C 多线程 TCP 并发服务器:从 `accept()` 到工作线程的完整解析

文章目录

  • Linux C 多线程 TCP 并发服务器:从 `accept()` 到工作线程的完整解析
    • 一、为什么需要多线程 TCP 服务器
  • 二、先区分四个重要概念
    • 2.1 主线程
    • 2.2 工作线程
    • 2.3 监听套接字 `listenfd`
    • 2.4 通信套接字 `connfd`
  • 三、多线程服务器的完整运行流程
    • 第一个客户端连接
    • 第二个客户端连接
  • 四、线程之间共享什么,独占什么
    • 4.1 每个线程独立拥有的资源
    • 4.2 多个线程共享的资源
  • 五、文件描述符共享到底是什么意思
    • 多进程和多线程的文件描述符区别
  • 六、能不能重复使用主线程中的 `connfd` 变量
    • 6.1 错误写法:传递同一个局部变量的地址
    • 6.2 正确写法:每个线程使用独立参数
  • 七、原始示例代码中存在的问题
    • 7.1 `read()` 后不能直接调用 `strlen()`
    • 7.2 `write()` 不保证一次写完
    • 7.3 客户端断开后没有调用 `close()`
    • 7.4 全局数组存在数据竞争
    • 7.5 `pthread_t` 不能假设可以设置为 `-1`
    • 7.6 `accept()` 的地址长度应使用 `socklen_t`
    • 7.7 没有处理 `pthread_create()` 失败
    • 7.8 线程分离不等于自动释放所有资源
  • 八、完整的多线程 TCP 并发服务器
  • 九、代码中的参数所有权
    • 如果线程创建失败
    • 如果线程创建成功
  • 十、为什么不再使用全局数组
  • 十一、`recv()` 为什么可能一次读不完整
  • 十二、为什么工作线程阻塞不会影响主线程
  • 十三、分离线程的作用
  • 十四、编译和运行
    • 使用 `nc` 测试
  • 十五、必须记住的核心结论
    • 1. `listenfd` 和 `connfd` 不是同一个套接字
    • 2. 每次 `accept()` 都返回一个新的通信套接字
    • 3. 主线程只负责接收连接
    • 4. 每个工作线程负责一个客户端
    • 5. 可以重复使用主线程中的局部变量
    • 6. 不能把同一个局部变量地址传给多个线程
    • 7. `recv()` 返回多少字节,就处理多少字节
    • 8. 工作线程必须关闭通信套接字
  • 十六、一连接一线程模型的局限
  • 总结

Linux C 多线程 TCP 并发服务器:从 accept() 到工作线程的完整解析

一、为什么需要多线程 TCP 服务器

普通的单线程 TCP 服务器通常按照下面的顺序运行:

创建监听套接字

绑定 IP 和端口

监听连接

accept() 接收客户端

read()/write() 与客户端通信

关闭连接

继续接收下一个客户端

这种写法存在一个明显问题:

当服务器正在与某个客户端通信时,无法及时处理其他客户端的连接和数据。

例如,服务器已经接受了客户端 A 的连接,然后阻塞在:

read(connfd, buf, sizeof(buf));

如果客户端 A 一直不发送数据,服务器就会一直等待客户端 A。此时客户端 B 即使发起连接,也无法得到及时处理。

因此,可以将监听和通信拆分到不同线程:

主线程:
负责 accept(),不断接收新的客户端连接

工作线程:
每个工作线程负责一个客户端
负责 recv()/send() 或 read()/write()

这就是最基础的:

一个客户端连接对应一个工作线程。


二、先区分四个重要概念

理解多线程 TCP 服务器之前,必须先区分下面四个对象:

  • 主线程;
  • 工作线程;
  • 监听套接字;
  • 通信套接字。
  • 它们之间的关系如下:

    服务器进程

    ├── 主线程
    │ └── 使用 listenfd 调用 accept()

    ├── 工作线程 1
    │ └── 使用 connfd1 与客户端 1 通信

    ├── 工作线程 2
    │ └── 使用 connfd2 与客户端 2 通信

    └── 工作线程 3
    └── 使用 connfd3 与客户端 3 通信

    2.1 主线程

    主线程主要负责:

    • 创建监听套接字;
    • 绑定服务器 IP 和端口;
    • 调用 listen() 进入监听状态;
    • 循环调用 accept();
    • 每接受一个客户端,就创建一个工作线程。

    主线程通常不直接与客户端交换业务数据。


    2.2 工作线程

    工作线程主要负责:

    • 接收客户端发送的数据;
    • 处理客户端请求;
    • 向客户端返回响应;
    • 检测客户端断开连接;
    • 关闭通信套接字;
    • 退出线程。

    每个工作线程一般只负责一个客户端连接。


    2.3 监听套接字 listenfd

    服务器调用:

    int listenfd = socket(AF_INET, SOCK_STREAM, 0);

    创建一个套接字,然后执行:

    bind(listenfd, ...);
    listen(listenfd, ...);

    这个套接字就成为监听套接字。

    监听套接字的主要作用是:

    接收新的 TCP 连接请求。

    它通常不会被用来调用 recv() 和 send() 传输业务数据。

    监听套接字内部关联的是连接请求队列,而不是某个具体客户端的数据通信通道。


    2.4 通信套接字 connfd

    当客户端完成 TCP 三次握手后,服务器调用:

    int connfd = accept(listenfd, ...);

    accept() 会返回一个新的文件描述符。

    这个新的文件描述符就是通信套接字,专门代表服务器与某个客户端之间已经建立的 TCP 连接。

    例如:

    listenfd = 3

    客户端 A 连接后:

    connfd1 = 4

    客户端 B 连接后:

    connfd2 = 5

    客户端 C 连接后:

    connfd3 = 6

    服务器进程中的文件描述符表可能是:

    文件描述符 3 ──> 监听套接字
    文件描述符 4 ──> 客户端 A 的 TCP 连接
    文件描述符 5 ──> 客户端 B 的 TCP 连接
    文件描述符 6 ──> 客户端 C 的 TCP 连接

    因此:

    accept()

    并不是把监听套接字转换成通信套接字,而是:

    保留原来的监听套接字,同时创建并返回一个新的通信套接字。

    主线程继续使用 listenfd 接收其他客户端,而工作线程使用 connfd 与对应客户端通信。


    三、多线程服务器的完整运行流程

    假设服务器已经完成:

    socket();
    bind();
    listen();

    接下来主线程执行:

    for (;;) {
    int connfd = accept(listenfd, ...);

    // 创建工作线程
    }

    第一个客户端连接

    客户端 A 连接服务器:

    connfd = accept(listenfd, ...);

    假设返回:

    connfd = 4

    主线程创建线程 A,并把文件描述符 4 交给线程 A:

    主线程:
    继续调用 accept()

    线程 A:
    使用文件描述符 4 与客户端 A 通信


    第二个客户端连接

    主线程再次调用:

    connfd = accept(listenfd, ...);

    客户端 B 连接后,假设返回:

    connfd = 5

    主线程创建线程 B:

    主线程:
    继续调用 accept()

    线程 A:
    使用文件描述符 4 与客户端 A 通信

    线程 B:
    使用文件描述符 5 与客户端 B 通信

    因此,即使线程 A 阻塞在:

    recv(4, ...);

    线程 B 仍然可以调用:

    recv(5, ...);

    主线程也仍然可以调用:

    accept(3, ...);

    这三个线程的阻塞互不影响。


    四、线程之间共享什么,独占什么

    同一个进程中的多个线程共享同一个进程地址空间,但每个线程也有自己的执行上下文。

    4.1 每个线程独立拥有的资源

    每个线程独立拥有:

    • 线程栈;
    • CPU 寄存器上下文;
    • 程序计数器;
    • 线程 ID;
    • 线程局部存储;
    • 调度状态。

    例如:

    void *worker(void *arg)
    {
    char buf[4096];
    int count;
    }

    这里的 buf 和 count 是局部变量,存放在线程自己的栈上。

    如果同时运行十个工作线程,那么每个线程都有自己独立的:

    buf
    count

    线程之间不会因为变量名称相同就互相覆盖。


    4.2 多个线程共享的资源

    同一个进程中的线程共享:

    • 全局变量;
    • 静态变量;
    • 堆内存;
    • 进程地址空间;
    • 文件描述符表;
    • 当前工作目录;
    • 信号处理配置;
    • 进程级资源。

    例如:

    int global_count;

    如果多个线程同时修改 global_count,就可能产生数据竞争,需要使用互斥锁、原子变量等同步机制。


    五、文件描述符共享到底是什么意思

    “线程共享文件描述符”这个说法容易让人误解。

    文件描述符本质上只是一个小整数:

    int connfd = 4;

    整数 4 本身只是进程文件描述符表中的索引。

    4 ──> 某个已经建立的 TCP socket

    由于所有线程共享同一个进程文件描述符表,所以任意线程只要知道数字 4,都可以调用:

    recv(4, ...);
    send(4, ...);
    close(4);

    但是在设计上,应当明确规定套接字的所有权:

    listenfd:
    由主线程管理

    connfd:
    创建工作线程成功后,由工作线程管理

    主线程把 connfd 交给工作线程后,就不应继续操作或关闭它。

    因为线程共享文件描述符表,如果主线程执行:

    close(connfd);

    工作线程中的同一个 connfd 也会失效。

    这与多进程服务器存在重要区别。

    多进程和多线程的文件描述符区别

    对比项多进程多线程
    地址空间 相互独立 共享
    文件描述符表 fork() 后分别拥有表项 所有线程共享同一个表
    父执行 close(connfd) 通常不影响子进程中的对应描述符 会导致工作线程中的描述符失效
    参数传递 通过 fork() 继承 通过线程参数传递

    因此,多线程服务器中必须明确:

    成功创建线程后,通信套接字由工作线程负责关闭。


    六、能不能重复使用主线程中的 connfd 变量

    可以。

    下面这种写法本身没有问题:

    for (;;) {
    int connfd = accept(listenfd, NULL, NULL);

    // 将 connfd 的值复制到独立内存中
    }

    主线程每次循环都可以重新使用 connfd 变量。

    真正的问题不是“变量被覆盖”,而是:

    工作线程拿到的是文件描述符值的独立副本,还是同一个变量的地址。


    6.1 错误写法:传递同一个局部变量的地址

    int connfd;

    while (1) {
    connfd = accept(listenfd, NULL, NULL);
    pthread_create(&tid, NULL, worker, &connfd);
    }

    这种写法存在竞争。

    主线程传递给所有工作线程的都是:

    &connfd

    也就是同一个内存地址。

    可能发生下面的情况:

    主线程:
    connfd = 4
    创建线程 A,传递 &connfd

    线程 A:
    还没有得到 CPU 执行

    主线程:
    再次调用 accept()
    connfd = 5
    创建线程 B,仍然传递 &connfd

    线程 A:
    开始读取 *arg
    得到的可能已经是 5

    于是线程 A 和线程 B 都可能操作文件描述符 5,而文件描述符 4 被丢失。

    问题的根源不是局部变量名称重复,而是:

    多个线程同时访问同一块参数内存。


    6.2 正确写法:每个线程使用独立参数

    可以为每个客户端动态分配一个结构体:

    struct ClientInfo *client = malloc(sizeof(struct ClientInfo));

    client->connfd = connfd;
    client->addr = client_addr;

    pthread_create(&tid, NULL, worker, client);

    每次 malloc() 都返回独立内存:

    线程 A ──> ClientInfo A ──> connfd = 4
    线程 B ──> ClientInfo B ──> connfd = 5
    线程 C ──> ClientInfo C ──> connfd = 6

    工作线程读取完参数后,再调用:

    free(client);

    这样就不需要使用全局数组,也不需要在线程之间同步数组槽位。


    七、原始示例代码中存在的问题

    原始代码的总体思路是正确的:

    • 主线程调用 accept();
    • 使用数组为每个客户端保存参数;
    • 创建分离线程处理客户端。

    但是代码中存在多个需要修正的问题。


    7.1 read() 后不能直接调用 strlen()

    原代码:

    char buf[1024];
    int ret = read(info->fd, buf, sizeof(buf));

    write(info->fd, buf, strlen(buf) + 1);

    这是一个严重错误。

    read() 读取的是原始字节,不会自动在数据末尾添加:

    '\\0'

    假设客户端发送:

    hello

    read() 返回:

    ret = 5

    缓冲区中只有:

    'h' 'e' 'l' 'l' 'o'

    后面不一定存在字符串结束符。

    此时调用:

    strlen(buf)

    会继续向后访问内存,直到偶然遇到 '\\0',可能导致:

    • 发送多余数据;
    • 越界读取;
    • 未定义行为;
    • 程序崩溃。

    正确做法是:

    ssize_t ret = read(fd, buf, sizeof(buf));

    if (ret > 0) {
    write(fd, buf, ret);
    }

    read() 返回多少字节,就处理多少字节。


    7.2 write() 不保证一次写完

    原代码假设:

    write(fd, buf, ret);

    一定会发送 ret 个字节。

    但 write() 或 send() 可能只发送一部分数据。

    例如:

    希望发送:4096 字节
    实际发送:1500 字节

    因此必须循环发送,直到全部数据写完。


    7.3 客户端断开后没有调用 close()

    原代码只是执行:

    info->fd = 1;

    但是没有:

    close(info->fd);

    把变量设置为 -1 并不会关闭内核中的 TCP 套接字。

    这会造成文件描述符泄漏。

    服务器运行时间较长后,可能出现:

    Too many open files

    正确顺序应当是:

    close(connfd);

    然后再结束线程。


    7.4 全局数组存在数据竞争

    原代码中,主线程读取:

    if (infos[i].fd == 1)

    工作线程同时写入:

    info->fd = 1;

    两个线程没有使用互斥锁或原子变量进行同步。

    一个线程读,另一个线程写同一个普通变量,这在 C 语言内存模型中属于数据竞争,行为未定义。

    即使某些机器上“看起来可以运行”,也不能认为它是正确的并发程序。


    7.5 pthread_t 不能假设可以设置为 -1

    原代码:

    infos[i].tid = 1;

    pthread_t 的实际类型由系统实现决定。

    它不一定是普通整数,因此不能依赖:

    tid == 1

    判断线程是否有效。

    在这段程序中也没有必要初始化 pthread_t 为 -1。


    7.6 accept() 的地址长度应使用 socklen_t

    原代码:

    int len = sizeof(struct sockaddr);

    更准确的写法应当是:

    socklen_t addr_len = sizeof(struct sockaddr_in);

    并且每次调用 accept() 之前,都应重新设置长度:

    socklen_t addr_len = sizeof(client_addr);

    因为 accept() 的第三个参数是一个输入输出参数。


    7.7 没有处理 pthread_create() 失败

    如果线程创建失败,通信套接字必须由主线程关闭:

    int err = pthread_create(...);

    if (err != 0) {
    close(connfd);
    free(client);
    }

    否则同样会产生文件描述符和堆内存泄漏。

    需要注意:

    pthread_create()

    失败时返回错误码,不一定设置 errno。

    因此不能简单使用:

    perror("pthread_create");

    而应使用:

    fprintf(stderr, "pthread_create: %s\\n", strerror(err));


    7.8 线程分离不等于自动释放所有资源

    调用:

    pthread_detach(tid);

    或者创建分离线程后,线程结束时会自动回收线程自身的管理资源。

    但是它不会自动:

    • 关闭通信套接字;
    • 释放 malloc() 分配的内存;
    • 释放业务对象;
    • 解锁互斥锁。

    这些资源仍然需要工作线程主动清理。


    八、完整的多线程 TCP 并发服务器

    下面给出一个可以直接编译运行的 Linux C 多线程 TCP 回显服务器。

    服务器接收到客户端发送的数据后,会将相同的数据返回给客户端。

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

    #define SERVER_PORT 8989
    #define LISTEN_BACKLOG 128
    #define BUFFER_SIZE 4096

    /*
    * 每个客户端连接对应一个 ClientInfo。
    *
    * 该结构体由主线程分配,
    * 所有权在 pthread_create() 成功后转移给工作线程。
    */

    struct ClientInfo {
    int connfd;
    struct sockaddr_in addr;
    };

    /*
    * 将指定长度的数据全部发送出去。
    *
    * 返回值:
    * 0 表示全部发送成功
    * -1 表示发送失败
    */

    static int send_all(int fd, const void *data, size_t length)
    {
    const char *buffer = (const char *)data;
    size_t total_sent = 0;

    while (total_sent < length) {
    ssize_t sent = send(fd,
    buffer + total_sent,
    length total_sent,
    MSG_NOSIGNAL);

    if (sent > 0) {
    total_sent += (size_t)sent;
    continue;
    }

    if (sent == 1 && errno == EINTR) {
    /*
    * send() 被信号中断,没有真正失败,
    * 重新发送即可。
    */

    continue;
    }

    if (sent == 0) {
    /*
    * 对于非零长度的阻塞式 TCP send(),
    * 通常不会返回 0。
    *
    * 这里进行防御性处理,避免死循环。
    */

    errno = EPIPE;
    }

    return 1;
    }

    return 0;
    }

    /*
    * 工作线程函数。
    *
    * 每个工作线程只处理一个客户端连接。
    */

    static void *worker(void *arg)
    {
    struct ClientInfo *client = (struct ClientInfo *)arg;

    /*
    * 先把需要的数据复制到当前线程自己的栈上。
    */

    int connfd = client->connfd;
    struct sockaddr_in client_addr = client->addr;

    /*
    * 主线程为该客户端动态分配的参数已经使用完毕。
    */

    free(client);
    client = NULL;

    char client_ip[INET_ADDRSTRLEN];

    if (inet_ntop(AF_INET,
    &client_addr.sin_addr,
    client_ip,
    sizeof(client_ip)) == NULL) {
    strcpy(client_ip, "unknown");
    }

    unsigned short client_port = ntohs(client_addr.sin_port);

    printf("[线程 %lu] 客户端已连接:%s:%hu,connfd=%d\\n",
    (unsigned long)pthread_self(),
    client_ip,
    client_port,
    connfd);

    char buffer[BUFFER_SIZE];

    for (;;) {
    ssize_t received = recv(connfd,
    buffer,
    sizeof(buffer),
    0);

    if (received > 0) {
    printf("[线程 %lu] 从 %s:%hu 接收到 %zd 字节\\n",
    (unsigned long)pthread_self(),
    client_ip,
    client_port,
    received);

    /*
    * TCP 是字节流协议。
    *
    * recv() 返回多少字节,就回写多少字节。
    * 不能使用 strlen(buffer)。
    */

    if (send_all(connfd,
    buffer,
    (size_t)received) == 1) {
    perror("send");
    break;
    }

    continue;
    }

    if (received == 0) {
    /*
    * recv() 返回 0,表示对端正常关闭了连接。
    */

    printf("[线程 %lu] 客户端正常断开:%s:%hu\\n",
    (unsigned long)pthread_self(),
    client_ip,
    client_port);
    break;
    }

    /*
    * received == -1
    */

    if (errno == EINTR) {
    /*
    * recv() 被信号中断,重新接收。
    */

    continue;
    }

    perror("recv");
    break;
    }

    /*
    * 通信套接字归当前工作线程管理。
    */

    close(connfd);

    printf("[线程 %lu] 工作线程退出,connfd=%d\\n",
    (unsigned long)pthread_self(),
    connfd);

    return NULL;
    }

    int main(void)
    {
    /*
    * 1. 创建监听套接字。
    */

    int listenfd = socket(AF_INET, SOCK_STREAM, 0);

    if (listenfd == 1) {
    perror("socket");
    return EXIT_FAILURE;
    }

    /*
    * 允许服务器重启后快速重新绑定相同端口。
    */

    int reuse = 1;

    if (setsockopt(listenfd,
    SOL_SOCKET,
    SO_REUSEADDR,
    &reuse,
    sizeof(reuse)) == 1) {
    perror("setsockopt");
    close(listenfd);
    return EXIT_FAILURE;
    }

    /*
    * 2. 准备服务器地址。
    */

    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));

    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(SERVER_PORT);
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);

    /*
    * 3. 绑定 IP 和端口。
    */

    if (bind(listenfd,
    (struct sockaddr *)&server_addr,
    sizeof(server_addr)) == 1) {
    perror("bind");
    close(listenfd);
    return EXIT_FAILURE;
    }

    /*
    * 4. 设置监听状态。
    */

    if (listen(listenfd, LISTEN_BACKLOG) == 1) {
    perror("listen");
    close(listenfd);
    return EXIT_FAILURE;
    }

    /*
    * 创建线程属性,并将线程设置为分离状态。
    *
    * 分离线程退出后,线程自身资源会自动回收,
    * 主线程不需要 pthread_join()。
    */

    pthread_attr_t thread_attr;

    int err = pthread_attr_init(&thread_attr);

    if (err != 0) {
    fprintf(stderr,
    "pthread_attr_init: %s\\n",
    strerror(err));

    close(listenfd);
    return EXIT_FAILURE;
    }

    err = pthread_attr_setdetachstate(&thread_attr,
    PTHREAD_CREATE_DETACHED);

    if (err != 0) {
    fprintf(stderr,
    "pthread_attr_setdetachstate: %s\\n",
    strerror(err));

    pthread_attr_destroy(&thread_attr);
    close(listenfd);
    return EXIT_FAILURE;
    }

    printf("服务器启动成功,监听端口:%d\\n", SERVER_PORT);
    printf("listenfd=%d\\n", listenfd);

    /*
    * 5. 主线程循环接收客户端连接。
    */

    for (;;) {
    /*
    * 为当前客户端动态分配独立参数。
    */

    struct ClientInfo *client =
    (struct ClientInfo *)malloc(sizeof(struct ClientInfo));

    if (client == NULL) {
    perror("malloc");

    /*
    * 内存不足时稍作等待,防止循环持续占用 CPU。
    */

    sleep(1);
    continue;
    }

    socklen_t addr_len = sizeof(client->addr);

    int connfd;

    /*
    * accept() 可能被信号中断。
    */

    do {
    connfd = accept(listenfd,
    (struct sockaddr *)&client->addr,
    &addr_len);
    } while (connfd == 1 && errno == EINTR);

    if (connfd == 1) {
    perror("accept");
    free(client);

    /*
    * 某些资源耗尽错误可能持续发生,
    * 稍作等待后继续接收。
    */

    if (errno == EMFILE || errno == ENFILE) {
    sleep(1);
    }

    continue;
    }

    client->connfd = connfd;

    /*
    * 6. 创建工作线程处理当前客户端。
    */

    pthread_t tid;

    err = pthread_create(&tid,
    &thread_attr,
    worker,
    client);

    if (err != 0) {
    fprintf(stderr,
    "pthread_create: %s\\n",
    strerror(err));

    /*
    * 线程创建失败,所有权没有转移给工作线程。
    * 因此必须由主线程清理资源。
    */

    close(connfd);
    free(client);
    continue;
    }

    /*
    * pthread_create() 成功后:
    *
    * 1. client 的所有权转移给工作线程;
    * 2. connfd 的管理权转移给工作线程;
    * 3. 主线程不能再访问 client;
    * 4. 主线程不能关闭 connfd;
    * 5. 主线程继续调用 accept()。
    */

    }

    /*
    * 当前程序是永久运行的服务器,
    * 正常情况下不会执行到这里。
    */

    pthread_attr_destroy(&thread_attr);
    close(listenfd);

    return EXIT_SUCCESS;
    }


    九、代码中的参数所有权

    这段代码中最重要的是资源所有权。

    主线程执行:

    struct ClientInfo *client = malloc(sizeof(struct ClientInfo));

    此时资源属于主线程。

    然后主线程调用:

    int err = pthread_create(&tid,
    &thread_attr,
    worker,
    client);

    如果线程创建失败

    if (err != 0) {
    close(connfd);
    free(client);
    }

    由于工作线程没有创建成功,所以资源仍由主线程负责释放。


    如果线程创建成功

    线程创建成功后:

    ClientInfo 所有权 ──> 工作线程
    connfd 管理权 ──> 工作线程

    主线程不能再访问:

    client

    也不能关闭:

    connfd

    工作线程读取参数:

    int connfd = client->connfd;
    struct sockaddr_in client_addr = client->addr;

    然后释放动态内存:

    free(client);

    最后在通信结束时关闭套接字:

    close(connfd);

    完整生命周期如下:

    主线程 malloc()

    主线程 accept()

    主线程 pthread_create()

    参数所有权转移给工作线程

    工作线程读取参数

    工作线程 free()

    工作线程 recv()/send()

    工作线程 close()

    工作线程退出


    十、为什么不再使用全局数组

    原始代码使用了:

    struct SockInfo infos[128];

    然后通过:

    infos[i].fd == 1

    判断数组槽位是否空闲。

    这种设计不是绝对不能使用,但它会引入额外问题:

    • 主线程和工作线程同时访问数组;
    • 需要互斥锁或原子变量;
    • 需要考虑槽位回收;
    • 需要考虑线程退出时机;
    • 需要考虑文件描述符关闭顺序;
    • 同时连接数被固定为 128;
    • 线程结束与槽位复用之间容易产生竞争。

    例如,不能简单执行:

    info->fd = 1;
    close(info->fd);

    因为此时实际上变成了:

    close(1);

    也不能轻易写成:

    close(info->fd);
    info->fd = 1;

    因为主线程可能在没有同步的情况下看到 -1,立即复用该结构,而旧线程可能还没有完全结束。

    如果确实需要维护在线客户端列表,可以使用:

    • 互斥锁;
    • 条件变量;
    • 原子状态;
    • 独立连接管理器;
    • 引用计数;
    • 线程池任务队列。

    但对于教学版“一连接一线程”服务器,动态分配独立参数更加清晰。


    十一、recv() 为什么可能一次读不完整

    TCP 是字节流协议,不保留应用层消息边界。

    客户端执行:

    send(fd, "hello", 5, 0);
    send(fd, "world", 5, 0);

    服务器可能收到:

    第一次 recv():hello
    第二次 recv():world

    也可能收到:

    第一次 recv():helloworld

    还可能收到:

    第一次 recv():he
    第二次 recv():llow
    第三次 recv():orld

    TCP 只保证:

    • 数据可靠到达;
    • 数据有序;
    • 不重复;
    • 不丢失,或者最终报告连接错误。

    TCP 不保证:

    一次 send() 对应一次 recv()

    当前示例是回显服务器,因此不需要区分业务消息边界:

    recv() 返回多少字节

    就回写多少字节

    如果实现聊天协议、文件传输协议或请求响应协议,则必须自行设计消息边界,例如:

    固定长度

    或者:

    消息长度 + 消息内容

    或者:

    以换行符结尾

    例如:

    4 字节长度字段
    +
    N 字节消息体


    十二、为什么工作线程阻塞不会影响主线程

    工作线程可能阻塞在:

    recv(connfd, buffer, sizeof(buffer), 0);

    主线程可能阻塞在:

    accept(listenfd, ...);

    虽然它们属于同一个进程,但它们是不同的执行流。

    当线程 A 调用:

    recv(4, ...);

    而文件描述符 4 暂时没有数据时,内核只会挂起线程 A。

    线程 B 和主线程仍然可以继续运行:

    线程 A:
    阻塞等待 connfd=4 的数据

    线程 B:
    处理 connfd=5 的数据

    主线程:
    等待 listenfd=3 上的新连接

    当客户端 A 发送数据后,网卡接收数据,TCP 协议栈处理报文,并把数据放入连接 4 对应的接收缓冲区。

    内核随后唤醒正在等待文件描述符 4 的线程 A。

    这就是多个客户端能够并发通信的原因。


    十三、分离线程的作用

    线程默认是可连接线程。

    可连接线程退出后,主线程需要调用:

    pthread_join(tid, NULL);

    回收线程资源。

    但是服务器不能在每次创建线程后立即调用:

    pthread_join(tid, NULL);

    否则主线程会等待该客户端线程结束,无法继续调用 accept()。

    错误示例:

    pthread_create(&tid, NULL, worker, client);
    pthread_join(tid, NULL);

    这实际上又退化成了串行服务器。

    因此,可以将线程设置为分离状态:

    pthread_attr_setdetachstate(&attr,
    PTHREAD_CREATE_DETACHED);

    分离线程退出时,系统自动回收线程自身的部分资源。

    但仍需注意:

    分离线程不能再次调用 pthread_join()。

    分离线程也不会自动关闭业务套接字和释放业务内存。


    十四、编译和运行

    将代码保存为:

    thread_server.c

    使用下面的命令编译:

    gcc thread_server.c -o thread_server \\
    -std=c11 \\
    -Wall \\
    -Wextra \\
    -Wpedantic \\
    -pthread

    运行服务器:

    ./thread_server

    输出:

    服务器启动成功,监听端口:8989
    listenfd=3


    使用 nc 测试

    打开第一个终端:

    nc 127.0.0.1 8989

    输入:

    hello client 1

    服务器会将数据原样返回。

    再打开第二个终端:

    nc 127.0.0.1 8989

    输入:

    hello client 2

    两个客户端可以同时保持连接并发送数据。

    服务器可能输出:

    [线程 140232445667072] 客户端已连接:127.0.0.1:53120,connfd=4
    [线程 140232437274368] 客户端已连接:127.0.0.1:53124,connfd=5
    [线程 140232445667072] 从 127.0.0.1:53120 接收到 15 字节
    [线程 140232437274368] 从 127.0.0.1:53124 接收到 15 字节


    十五、必须记住的核心结论

    1. listenfd 和 connfd 不是同一个套接字

    listenfd:
    接收新连接

    connfd:
    与某个客户端通信


    2. 每次 accept() 都返回一个新的通信套接字

    第一次 accept() ──> connfd 4
    第二次 accept() ──> connfd 5
    第三次 accept() ──> connfd 6


    3. 主线程只负责接收连接

    主线程:
    accept()
    创建工作线程
    继续 accept()


    4. 每个工作线程负责一个客户端

    线程 A ──> 客户端 A
    线程 B ──> 客户端 B
    线程 C ──> 客户端 C


    5. 可以重复使用主线程中的局部变量

    主线程中的:

    int connfd;

    可以反复赋值。

    关键是要在创建线程前,把文件描述符值复制到独立参数中。


    6. 不能把同一个局部变量地址传给多个线程

    错误:

    pthread_create(&tid, NULL, worker, &connfd);

    正确:

    struct ClientInfo *client = malloc(sizeof(*client));
    client->connfd = connfd;

    pthread_create(&tid, NULL, worker, client);


    7. recv() 返回多少字节,就处理多少字节

    错误:

    strlen(buffer)

    正确:

    ssize_t received = recv(...);

    send_all(fd, buffer, (size_t)received);


    8. 工作线程必须关闭通信套接字

    close(connfd);

    仅仅设置:

    connfd = 1;

    不会释放任何内核资源。


    十六、一连接一线程模型的局限

    “一连接一线程”模型结构简单,适合:

    • 学习 TCP 网络编程;
    • 客户端数量较少;
    • 内部管理程序;
    • 并发规模较小;
    • 客户端需要长时间阻塞等待。

    但每个线程都需要:

    • 独立线程栈;
    • 内核调度资源;
    • 线程控制结构;
    • 上下文切换开销。

    当连接数达到几千甚至几万时,为每个连接创建一个线程通常不再合适。

    生产环境中常见的模型包括:

    线程池 + 任务队列

    以及:

    epoll + 非阻塞 socket

    或者:

    epoll + 线程池

    它们可以使用少量线程处理大量客户端连接。

    不过在学习阶段,建议先彻底理解:

    主线程 accept()
    +
    每个工作线程 recv()/send()

    掌握线程参数传递、文件描述符所有权和资源释放后,再继续学习线程池与 epoll。


    总结

    多线程 TCP 服务器的核心并不复杂:

    监听套接字只负责接收连接

    accept() 返回通信套接字

    主线程为连接创建工作线程

    工作线程使用通信套接字收发数据

    客户端断开

    工作线程关闭套接字并退出

    真正容易出错的是线程之间的资源管理:

    • 不要把同一个局部变量地址传递给多个线程;
    • 不要使用 strlen() 处理 recv() 得到的原始数据;
    • 不要忘记处理部分发送;
    • 不要忘记关闭通信套接字;
    • 不要在没有同步的情况下读写共享数组;
    • 不要在线程创建成功后让主线程继续操作该连接;
    • 分离线程只会自动回收线程资源,不会自动释放业务资源。

    只要明确下面这条所有权规则,多线程服务器就会清晰很多:

    监听套接字属于主线程;通信套接字在线程创建成功后属于对应的工作线程。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » Linux C 多线程 TCP 并发服务器:从 `accept()` 到工作线程的完整解析
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!