14、多线程并发服务器:使用pthread_create()创建线程处理多个客户端连接
说实话,多线程并发服务器这块,我当年刚入行时也踩过不少坑。那时候公司有个项目,需要同时处理几百个客户端连接,我一开始用的是多进程方案——fork()一把梭,结果内存开销大得吓人。后来老工程师跟我说:「你小子试试多线程啊,轻量多了。」嗯,从那以后,我就跟pthread杠上了。
为什么需要多线程?
你想想看,一个单线程的服务器,一次只能服务一个客户端。如果某个客户端发完请求后磨磨蹭蹭不读响应,后面的客户端就得排队等着。这在生产环境里根本没法用。
多线程方案的核心思路是:主线程负责accept(),每来一个客户端连接,就创建一个新线程去处理。这样各个客户端之间互不干扰,谁慢谁自己扛,不影响别人。
核心要点:多线程服务器 = 主线程监听 + 工作线程处理。每个客户端连接对应一个独立线程。
pthread_create() 的基本用法
先看函数原型,这个我闭着眼都能写出来:
#include <pthread.h>
int pthread_create(pthread_t *thread,
const pthread_attr_t *attr,
void *(*start_routine)(void *),
void *arg);
参数说明:
- thread:线程ID的指针,创建成功后这里会填上线程ID
- attr:线程属性,传NULL就用默认属性
- start_routine:线程要执行的函数,格式必须是
void* func(void*) - arg:传给线程函数的参数
返回值:成功返回0,失败返回错误码。
个人习惯:我一般把客户端socket的文件描述符通过arg参数传给线程函数。注意要传指针,别直接传int值——虽然32位系统上能凑合用,但64位系统上int和指针长度不一样,会出问题。
一个完整的多线程并发服务器
下面这个例子,是我早期项目里提炼出来的骨架代码。它实现了最基本的「来一个客户端,开一个线程」模型。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <pthread.h>
#define PORT 8888
#define MAX_CLIENTS 100
// 处理客户端通信的线程函数
void *handle_client(void *arg) {
int client_fd = *(int *)arg;
free(arg); // 释放动态分配的内存
pthread_detach(pthread_self()); // 分离线程,自动回收资源
char buffer[1024];
int bytes_read;
while ((bytes_read = read(client_fd, buffer, sizeof(buffer) - 1)) > 0) {
buffer[bytes_read] = '\0';
printf("收到消息: %s\n", buffer);
// 回显给客户端
write(client_fd, buffer, bytes_read);
}
printf("客户端断开连接\n");
close(client_fd);
return NULL;
}
int main() {
int server_fd, client_fd;
struct sockaddr_in server_addr, client_addr;
socklen_t client_len = sizeof(client_addr);
// 1. 创建socket
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket创建失败");
exit(1);
}
// 2. 绑定地址
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失败");
exit(1);
}
// 3. 监听
if (listen(server_fd, MAX_CLIENTS) < 0) {
perror("listen失败");
exit(1);
}
printf("服务器启动,监听端口 %d...\n", PORT);
// 4. 循环接受连接
while (1) {
client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);
if (client_fd < 0) {
perror("accept失败");
continue;
}
printf("新客户端连接: %s:%d\n",
inet_ntoa(client_addr.sin_addr),
ntohs(client_addr.sin_port));
// 动态分配内存,保存client_fd
int *pclient = malloc(sizeof(int));
*pclient = client_fd;
pthread_t tid;
if (pthread_create(&tid, NULL, handle_client, pclient) != 0) {
perror("线程创建失败");
close(client_fd);
free(pclient);
}
}
close(server_fd);
return 0;
}
代码里的几个关键点
这段代码看着简单,但里面有几个细节,我曾经都吃过亏:
- 动态分配内存传参:我把client_fd包装成malloc出来的int指针传给线程。为什么?因为如果直接传&client_fd的地址,主线程下次循环时这个变量的值就变了,新线程拿到的可能是错的。
- pthread_detach():线程函数里第一件事就是detach自己。这样线程结束后系统会自动回收资源,主线程不用pthread_join()去等它。省心。
- free(arg):传进去的指针用完了要释放,不然内存泄漏。这个坑我踩过——有一次线上服务器跑了三天,内存涨了2个G,查了半天才发现是这里忘了free。
我曾经犯过的错:一开始我图省事,直接把client_fd强转成void*传进去:pthread_create(&tid, NULL, handle_client, (void*)client_fd)。这在32位系统上没问题,但64位系统上int是4字节,指针是8字节,编译会报警告,而且高4字节是垃圾值。后来线程里拿到的fd值完全不对,连接全挂了。切记:传指针,不要传值。
多线程服务器的架构图
下面这张图,是我自己画的多线程服务器工作流程。你看一眼就能明白整体逻辑:
这张图里,主线程一直在accept()循环里转,每接到一个客户端就开一个工作线程。工作线程处理完就自己结束,资源自动回收。说白了就是「主线程只管接客,工作线程负责伺候」。
多线程 vs 多进程:怎么选?
我经常被问到这个问题。直接上对比表:
| 对比项 | 多线程 | 多进程 |
|---|---|---|
| 创建开销 | 低(共享地址空间) | 高(需要复制页表) |
| 内存占用 | 低(共享全局变量) | 高(每个进程独立内存) |
| 同步难度 | 高(需要加锁) | 低(进程间隔离) |
| 崩溃影响 | 一个线程崩溃 → 整个进程挂 | 一个进程崩溃 → 不影响其他进程 |
| 适用场景 | 高并发、轻量级任务 | 需要强隔离、稳定性要求高 |
我个人习惯是:内部服务用多线程,对外服务用多进程。内部服务可控,线程挂了能快速定位;对外服务要稳,一个客户端搞崩了不能影响其他人。
避坑指南:多线程服务器的常见问题
嗯,这里我总结几个实战中经常遇到的问题:
- 线程数量无限制增长:如果客户端频繁连接断开,线程会不断创建销毁。建议设置最大线程数,超过就拒绝连接或放入队列等待。
- 共享数据竞争:多个线程同时操作全局变量时,记得加互斥锁。我见过一个项目,日志打印函数里没加锁,结果两个线程同时写日志,输出全乱套了。
- 文件描述符泄漏:每个线程里close()别忘了。如果线程异常退出,fd没关,服务器跑几天就会报"Too many open files"。
- 信号处理:多线程程序里信号处理很麻烦。建议主线程屏蔽所有信号,工作线程不处理信号,统一由专门的信号线程处理。
一个小技巧:如果你不想手动管理线程生命周期,可以考虑用线程池。提前创建一批线程,有任务来了就丢给空闲线程处理。这样避免了频繁创建销毁的开销。不过线程池的实现比较复杂,我们后面章节会专门讲。
总结
多线程并发服务器,说白了就是「主线程接客,工作线程干活」的模型。pthread_create()是核心API,但真正用好它,需要处理好参数传递、资源回收、同步互斥这些细节。
我刚开始写多线程程序时,总觉得不就是开个线程嘛,有什么难的?结果线上出了好几次事故。后来慢慢养成了习惯:每创建一个线程,都要问自己三个问题——参数传对了没?资源谁回收?共享数据锁了没?
这三个问题想清楚了,你的多线程服务器基本就稳了。