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

文章目录

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

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

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

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

text 复制代码
创建监听套接字
    ↓
绑定 IP 和端口
    ↓
监听连接
    ↓
accept() 接收客户端
    ↓
read()/write() 与客户端通信
    ↓
关闭连接
    ↓
继续接收下一个客户端

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

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

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

c 复制代码
read(connfd, buf, sizeof(buf));

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

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

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

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

这就是最基础的:

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


二、先区分四个重要概念

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

  1. 主线程;
  2. 工作线程;
  3. 监听套接字;
  4. 通信套接字。

它们之间的关系如下:

text 复制代码
服务器进程
│
├── 主线程
│     └── 使用 listenfd 调用 accept()
│
├── 工作线程 1
│     └── 使用 connfd1 与客户端 1 通信
│
├── 工作线程 2
│     └── 使用 connfd2 与客户端 2 通信
│
└── 工作线程 3
      └── 使用 connfd3 与客户端 3 通信

2.1 主线程

主线程主要负责:

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

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


2.2 工作线程

工作线程主要负责:

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

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


2.3 监听套接字 listenfd

服务器调用:

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

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

c 复制代码
bind(listenfd, ...);
listen(listenfd, ...);

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

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

接收新的 TCP 连接请求。

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

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


2.4 通信套接字 connfd

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

c 复制代码
int connfd = accept(listenfd, ...);

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

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

例如:

text 复制代码
listenfd = 3

客户端 A 连接后:

text 复制代码
connfd1 = 4

客户端 B 连接后:

text 复制代码
connfd2 = 5

客户端 C 连接后:

text 复制代码
connfd3 = 6

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

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

因此:

c 复制代码
accept()

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

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

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


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

假设服务器已经完成:

c 复制代码
socket();
bind();
listen();

接下来主线程执行:

c 复制代码
for (;;) {
    int connfd = accept(listenfd, ...);

    // 创建工作线程
}

第一个客户端连接

客户端 A 连接服务器:

c 复制代码
connfd = accept(listenfd, ...);

假设返回:

text 复制代码
connfd = 4

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

text 复制代码
主线程:
    继续调用 accept()

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

第二个客户端连接

主线程再次调用:

c 复制代码
connfd = accept(listenfd, ...);

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

text 复制代码
connfd = 5

主线程创建线程 B:

text 复制代码
主线程:
    继续调用 accept()

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

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

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

c 复制代码
recv(4, ...);

线程 B 仍然可以调用:

c 复制代码
recv(5, ...);

主线程也仍然可以调用:

c 复制代码
accept(3, ...);

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


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

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

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

每个线程独立拥有:

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

例如:

c 复制代码
void *worker(void *arg)
{
    char buf[4096];
    int count;
}

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

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

c 复制代码
buf
count

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


4.2 多个线程共享的资源

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

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

例如:

c 复制代码
int global_count;

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


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

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

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

c 复制代码
int connfd = 4;

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

text 复制代码
4 ──> 某个已经建立的 TCP socket

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

c 复制代码
recv(4, ...);
send(4, ...);
close(4);

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

text 复制代码
listenfd:
    由主线程管理

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

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

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

c 复制代码
close(connfd);

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

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

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

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

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

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


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

可以。

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

c 复制代码
for (;;) {
    int connfd = accept(listenfd, NULL, NULL);

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

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

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

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


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

c 复制代码
int connfd;

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

这种写法存在竞争。

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

c 复制代码
&connfd

也就是同一个内存地址。

可能发生下面的情况:

text 复制代码
主线程:
    connfd = 4
    创建线程 A,传递 &connfd

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

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

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

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

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

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


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

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

c 复制代码
struct ClientInfo *client = malloc(sizeof(struct ClientInfo));

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

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

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

text 复制代码
线程 A ──> ClientInfo A ──> connfd = 4
线程 B ──> ClientInfo B ──> connfd = 5
线程 C ──> ClientInfo C ──> connfd = 6

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

c 复制代码
free(client);

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


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

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

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

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


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

原代码:

c 复制代码
char buf[1024];
int ret = read(info->fd, buf, sizeof(buf));

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

这是一个严重错误。

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

c 复制代码
'\0'

假设客户端发送:

text 复制代码
hello

read() 返回:

text 复制代码
ret = 5

缓冲区中只有:

text 复制代码
'h' 'e' 'l' 'l' 'o'

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

此时调用:

c 复制代码
strlen(buf)

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

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

正确做法是:

c 复制代码
ssize_t ret = read(fd, buf, sizeof(buf));

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

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


7.2 write() 不保证一次写完

原代码假设:

c 复制代码
write(fd, buf, ret);

一定会发送 ret 个字节。

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

例如:

text 复制代码
希望发送:4096 字节
实际发送:1500 字节

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


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

原代码只是执行:

c 复制代码
info->fd = -1;

但是没有:

c 复制代码
close(info->fd);

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

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

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

text 复制代码
Too many open files

正确顺序应当是:

c 复制代码
close(connfd);

然后再结束线程。


7.4 全局数组存在数据竞争

原代码中,主线程读取:

c 复制代码
if (infos[i].fd == -1)

工作线程同时写入:

c 复制代码
info->fd = -1;

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

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

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


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

原代码:

c 复制代码
infos[i].tid = -1;

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

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

c 复制代码
tid == -1

判断线程是否有效。

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


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

原代码:

c 复制代码
int len = sizeof(struct sockaddr);

更准确的写法应当是:

c 复制代码
socklen_t addr_len = sizeof(struct sockaddr_in);

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

c 复制代码
socklen_t addr_len = sizeof(client_addr);

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


7.7 没有处理 pthread_create() 失败

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

c 复制代码
int err = pthread_create(...);

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

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

需要注意:

c 复制代码
pthread_create()

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

因此不能简单使用:

c 复制代码
perror("pthread_create");

而应使用:

c 复制代码
fprintf(stderr, "pthread_create: %s\n", strerror(err));

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

调用:

c 复制代码
pthread_detach(tid);

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

但是它不会自动:

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

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


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

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

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

c 复制代码
#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;
}

九、代码中的参数所有权

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

主线程执行:

c 复制代码
struct ClientInfo *client = malloc(sizeof(struct ClientInfo));

此时资源属于主线程。

然后主线程调用:

c 复制代码
int err = pthread_create(&tid,
                         &thread_attr,
                         worker,
                         client);

如果线程创建失败

c 复制代码
if (err != 0) {
    close(connfd);
    free(client);
}

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


如果线程创建成功

线程创建成功后:

text 复制代码
ClientInfo 所有权 ──> 工作线程
connfd 管理权     ──> 工作线程

主线程不能再访问:

c 复制代码
client

也不能关闭:

c 复制代码
connfd

工作线程读取参数:

c 复制代码
int connfd = client->connfd;
struct sockaddr_in client_addr = client->addr;

然后释放动态内存:

c 复制代码
free(client);

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

c 复制代码
close(connfd);

完整生命周期如下:

text 复制代码
主线程 malloc()
    ↓
主线程 accept()
    ↓
主线程 pthread_create()
    ↓
参数所有权转移给工作线程
    ↓
工作线程读取参数
    ↓
工作线程 free()
    ↓
工作线程 recv()/send()
    ↓
工作线程 close()
    ↓
工作线程退出

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

原始代码使用了:

c 复制代码
struct SockInfo infos[128];

然后通过:

c 复制代码
infos[i].fd == -1

判断数组槽位是否空闲。

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

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

例如,不能简单执行:

c 复制代码
info->fd = -1;
close(info->fd);

因为此时实际上变成了:

c 复制代码
close(-1);

也不能轻易写成:

c 复制代码
close(info->fd);
info->fd = -1;

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

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

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

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


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

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

客户端执行:

c 复制代码
send(fd, "hello", 5, 0);
send(fd, "world", 5, 0);

服务器可能收到:

text 复制代码
第一次 recv():hello
第二次 recv():world

也可能收到:

text 复制代码
第一次 recv():helloworld

还可能收到:

text 复制代码
第一次 recv():he
第二次 recv():llow
第三次 recv():orld

TCP 只保证:

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

TCP 不保证:

text 复制代码
一次 send() 对应一次 recv()

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

c 复制代码
recv() 返回多少字节
    ↓
就回写多少字节

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

text 复制代码
固定长度

或者:

text 复制代码
消息长度 + 消息内容

或者:

text 复制代码
以换行符结尾

例如:

text 复制代码
4 字节长度字段
    +
N 字节消息体

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

工作线程可能阻塞在:

c 复制代码
recv(connfd, buffer, sizeof(buffer), 0);

主线程可能阻塞在:

c 复制代码
accept(listenfd, ...);

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

当线程 A 调用:

c 复制代码
recv(4, ...);

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

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

text 复制代码
线程 A:
    阻塞等待 connfd=4 的数据

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

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

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

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

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


十三、分离线程的作用

线程默认是可连接线程。

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

c 复制代码
pthread_join(tid, NULL);

回收线程资源。

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

c 复制代码
pthread_join(tid, NULL);

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

错误示例:

c 复制代码
pthread_create(&tid, NULL, worker, client);
pthread_join(tid, NULL);

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

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

c 复制代码
pthread_attr_setdetachstate(&attr,
                            PTHREAD_CREATE_DETACHED);

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

但仍需注意:

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

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


十四、编译和运行

将代码保存为:

text 复制代码
thread_server.c

使用下面的命令编译:

bash 复制代码
gcc thread_server.c -o thread_server \
    -std=c11 \
    -Wall \
    -Wextra \
    -Wpedantic \
    -pthread

运行服务器:

bash 复制代码
./thread_server

输出:

text 复制代码
服务器启动成功,监听端口:8989
listenfd=3

使用 nc 测试

打开第一个终端:

bash 复制代码
nc 127.0.0.1 8989

输入:

text 复制代码
hello client 1

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

再打开第二个终端:

bash 复制代码
nc 127.0.0.1 8989

输入:

text 复制代码
hello client 2

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

服务器可能输出:

text 复制代码
[线程 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. listenfdconnfd 不是同一个套接字

text 复制代码
listenfd:
    接收新连接

connfd:
    与某个客户端通信

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

text 复制代码
第一次 accept() ──> connfd 4
第二次 accept() ──> connfd 5
第三次 accept() ──> connfd 6

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

text 复制代码
主线程:
    accept()
    创建工作线程
    继续 accept()

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

text 复制代码
线程 A ──> 客户端 A
线程 B ──> 客户端 B
线程 C ──> 客户端 C

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

主线程中的:

c 复制代码
int connfd;

可以反复赋值。

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


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

错误:

c 复制代码
pthread_create(&tid, NULL, worker, &connfd);

正确:

c 复制代码
struct ClientInfo *client = malloc(sizeof(*client));
client->connfd = connfd;

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

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

错误:

c 复制代码
strlen(buffer)

正确:

c 复制代码
ssize_t received = recv(...);

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

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

c 复制代码
close(connfd);

仅仅设置:

c 复制代码
connfd = -1;

不会释放任何内核资源。


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

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

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

但每个线程都需要:

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

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

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

text 复制代码
线程池 + 任务队列

以及:

text 复制代码
epoll + 非阻塞 socket

或者:

text 复制代码
epoll + 线程池

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

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

text 复制代码
主线程 accept()
    +
每个工作线程 recv()/send()

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


总结

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

text 复制代码
监听套接字只负责接收连接
    ↓
accept() 返回通信套接字
    ↓
主线程为连接创建工作线程
    ↓
工作线程使用通信套接字收发数据
    ↓
客户端断开
    ↓
工作线程关闭套接字并退出

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

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

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

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

相关推荐
影视飓风TIM1 小时前
Linux基础入门笔记:命令、内核架构与压缩传输全梳理
linux·笔记·架构
敲上瘾1 小时前
Linux系统调用mmap文件映射与malloc简单模拟实现指南
linux·服务器·c语言·缓存
wdfk_prog8 小时前
嵌入式面试真题第 10 题:高优化等级下共享状态可见性、内存模型与系统级同步设计
java·linux·开发语言·面试·职场和发展·架构·c
x-cmd10 小时前
Mac 涨价后,本地 AI 还能千元入门吗?
linux·人工智能·macos·ai·agent·amd·本地ai入门
wdfk_prog10 小时前
嵌入式面试真题第 11 题:RTOS 优先级翻转与实时任务阻塞的通用治理
c语言·缓存·面试·职场和发展·架构
Sirius Wu11 小时前
OpenClaw Skill:Matplotlib 可视化技能 + 沙箱双层隔离完整详解
服务器·网络·人工智能·安全·ai·架构·aigc
J-Tony1111 小时前
【Redis】 Redis 是单线程还是多线程
服务器·数据库·mysql
YYYing.16 小时前
【C++大型项目之高性能服务器框架 (七) 】Socket与ByteArray模块
服务器·c++·后端·框架·高性能·c/c++
无相求码16 小时前
罪魁祸首:for 括号后面那个"隐形分号"
c语言