文章目录
- [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 线程分离不等于自动释放所有资源)
- [7.1 `read()` 后不能直接调用 `strlen()`](#7.1
- [八、完整的多线程 TCP 并发服务器](#八、完整的多线程 TCP 并发服务器)
- 九、代码中的参数所有权
- 十、为什么不再使用全局数组
- [十一、`recv()` 为什么可能一次读不完整](#十一、
recv()为什么可能一次读不完整) - 十二、为什么工作线程阻塞不会影响主线程
- 十三、分离线程的作用
- 十四、编译和运行
-
- [使用 `nc` 测试](#使用
nc测试)
- [使用 `nc` 测试](#使用
- 十五、必须记住的核心结论
-
- [1. `listenfd` 和 `connfd` 不是同一个套接字](#1.
listenfd和connfd不是同一个套接字) - [2. 每次 `accept()` 都返回一个新的通信套接字](#2. 每次
accept()都返回一个新的通信套接字) - [3. 主线程只负责接收连接](#3. 主线程只负责接收连接)
- [4. 每个工作线程负责一个客户端](#4. 每个工作线程负责一个客户端)
- [5. 可以重复使用主线程中的局部变量](#5. 可以重复使用主线程中的局部变量)
- [6. 不能把同一个局部变量地址传给多个线程](#6. 不能把同一个局部变量地址传给多个线程)
- [7. `recv()` 返回多少字节,就处理多少字节](#7.
recv()返回多少字节,就处理多少字节) - [8. 工作线程必须关闭通信套接字](#8. 工作线程必须关闭通信套接字)
- [1. `listenfd` 和 `connfd` 不是同一个套接字](#1.
- 十六、一连接一线程模型的局限
- 总结
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 服务器之前,必须先区分下面四个对象:
- 主线程;
- 工作线程;
- 监听套接字;
- 通信套接字。
它们之间的关系如下:
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;
}
这里的 buf 和 count 是局部变量,存放在线程自己的栈上。
如果同时运行十个工作线程,那么每个线程都有自己独立的:
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. listenfd 和 connfd 不是同一个套接字
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()得到的原始数据; - 不要忘记处理部分发送;
- 不要忘记关闭通信套接字;
- 不要在没有同步的情况下读写共享数组;
- 不要在线程创建成功后让主线程继续操作该连接;
- 分离线程只会自动回收线程资源,不会自动释放业务资源。
只要明确下面这条所有权规则,多线程服务器就会清晰很多:
监听套接字属于主线程;通信套接字在线程创建成功后属于对应的工作线程。