7、多线程并发服务器:使用pthread_create()创建线程处理多客户端连接
说实话,多客户端并发处理这块,是很多C语言网络编程新手的第一道坎。我记得自己刚入行那会儿,写了个单线程的echo服务器,结果一上线就被三个客户端同时连接给搞崩了。嗯,从那以后我就明白了——单线程服务器,只配活在教科书里。
今天咱们聊聊怎么用pthread_create()搞一个真正能扛得住多客户端连接的并发服务器。说白了,就是来一个客户端,我们就创建一个线程去伺候它。
7.1 为什么需要多线程?
你想想看,一个单线程的服务器,accept()完一个连接后,如果去处理recv()、send(),那其他客户端就只能排队等着。这在生产环境里根本没法用。
我个人的习惯是:主线程只负责accept(),业务逻辑全扔给子线程。这样主线程永远不阻塞,新连接来了就能立刻响应。
核心思想: 主线程 = 接待员,子线程 = 服务员。接待员只管领位,服务员负责点菜上菜。
7.2 多线程服务器的基本架构
咱们先画个图,把整体逻辑理清楚。我每次写这种代码前,都会先在脑子里过一遍这个流程。
7.3 核心代码实现
好了,图看完了,咱们直接上代码。这是我在项目中反复打磨过的一个模板,你直接拿去改改就能用。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <pthread.h>
#define PORT 8888
#define BUFFER_SIZE 1024
// 客户端信息结构体
typedef struct {
int client_fd;
struct sockaddr_in client_addr;
} client_info_t;
// 子线程处理函数
void* handle_client(void* arg) {
client_info_t* info = (client_info_t*)arg;
char buffer[BUFFER_SIZE];
char client_ip[INET_ADDRSTRLEN];
// 获取客户端IP和端口
inet_ntop(AF_INET, &info->client_addr.sin_addr, client_ip, sizeof(client_ip));
printf("[线程 %lu] 新客户端连接: %s:%d\n",
pthread_self(), client_ip, ntohs(info->client_addr.sin_port));
// 循环接收数据
while (1) {
memset(buffer, 0, BUFFER_SIZE);
int recv_len = recv(info->client_fd, buffer, BUFFER_SIZE - 1, 0);
if (recv_len <= 0) {
// 客户端断开连接
printf("[线程 %lu] 客户端 %s 已断开\n", pthread_self(), client_ip);
break;
}
printf("[线程 %lu] 收到: %s", pthread_self(), buffer);
// 回显数据(实际项目这里替换成你的业务逻辑)
send(info->client_fd, buffer, strlen(buffer), 0);
}
// 关闭连接,释放资源
close(info->client_fd);
free(info);
printf("[线程 %lu] 线程退出\n", pthread_self());
return NULL;
}
int main() {
int server_fd;
struct sockaddr_in server_addr;
// 1. 创建socket
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket 创建失败");
exit(EXIT_FAILURE);
}
// 2. 设置地址复用(防止"Address already in use")
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 3. 绑定地址
memset(&server_addr, 0, sizeof(server_addr));
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 失败");
close(server_fd);
exit(EXIT_FAILURE);
}
// 4. 监听
if (listen(server_fd, 10) < 0) {
perror("listen 失败");
close(server_fd);
exit(EXIT_FAILURE);
}
printf("服务器启动,监听端口: %d\n", PORT);
// 5. 主循环:accept + 创建线程
while (1) {
struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr);
// 接受客户端连接
int client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &addr_len);
if (client_fd < 0) {
perror("accept 失败");
continue;
}
// 封装客户端信息
client_info_t* info = (client_info_t*)malloc(sizeof(client_info_t));
if (!info) {
perror("malloc 失败");
close(client_fd);
continue;
}
info->client_fd = client_fd;
info->client_addr = client_addr;
// 创建子线程处理
pthread_t tid;
if (pthread_create(&tid, NULL, handle_client, info) != 0) {
perror("pthread_create 失败");
free(info);
close(client_fd);
continue;
}
// 分离线程(线程结束后自动回收资源)
pthread_detach(tid);
printf("主线程: 已创建线程 %lu 处理新客户端\n", tid);
}
close(server_fd);
return 0;
}
7.4 关键点解析
代码看完了,咱们聊聊几个容易踩坑的地方。我曾经在这些问题上浪费过整整一个下午。
7.4.1 为什么用 malloc 传递参数?
你想想看,如果我在主线程里定义一个局部变量 client_info_t info,然后传给子线程。主线程继续循环,下一次accept()就会覆盖这个变量。子线程拿到的是脏数据。
所以我的做法是:每次accept()后,malloc一块新内存,把数据拷贝进去。子线程用完后再free掉。这样主线程和子线程之间就没有数据竞争了。
小技巧: 如果你不想用malloc,也可以用pthread_create的第四个参数传一个整型(比如文件描述符),但要注意类型转换。我个人还是推荐malloc,结构清晰,不容易出错。
7.4.2 pthread_detach() 的作用
这里有个细节:我调用了 pthread_detach(tid)。为什么要这么做?
默认情况下,线程退出后它的资源不会被自动回收,需要其他线程调用 pthread_join() 来回收。但我们的服务器场景里,主线程不可能去等每个子线程结束——那不就又变成串行了吗?
所以,detach的意思就是:告诉系统,这个线程结束后你自动帮我清理。这样主线程就彻底解放了。
注意: 一旦detach,就不能再pthread_join了。如果你需要获取线程的返回值,就不要detach,而是把线程ID存起来,在合适的时机join。
7.4.3 线程安全与资源管理
多线程编程最头疼的就是资源竞争。在这个例子里,每个子线程操作的是自己独有的client_fd,所以不存在竞争。但如果你有全局变量(比如统计在线人数),那就得加锁了。
我曾经在一个项目里,多个线程同时写日志文件,结果日志全混在一起了。后来加了个互斥锁才解决。
| 资源类型 | 是否需要同步 | 建议方案 |
|---|---|---|
| client_fd(每个线程独有) | 不需要 | 直接使用 |
| 全局计数器(如在线人数) | 需要 | pthread_mutex_t 加锁 |
| 共享数据结构(如任务队列) | 需要 | 互斥锁 + 条件变量 |
| 日志文件 | 需要 | 建议用独立日志线程 |
7.5 编译与运行
代码写好了,怎么编译?记住要链接pthread库:
gcc -o multi_thread_server multi_thread_server.c -lpthread
然后启动服务器:
./multi_thread_server
用telnet或者nc测试:
# 开三个终端分别执行
telnet 127.0.0.1 8888
nc 127.0.0.1 8888
你会看到每个客户端连接都被分配了一个独立的线程ID,互不干扰。
7.6 性能与局限性
说实话,这种"来一个客户端创建一个线程"的模式,在小规模场景下非常好用。但如果你要处理成千上万的并发连接,就得考虑线程创建的开销了。
我个人的经验是:几百个并发以内,这种模式完全够用。超过这个量级,建议用线程池或者epoll + 非阻塞IO。
一句话总结: pthread_create() 多线程服务器,是理解并发编程的绝佳起点。它简单、直观、够用。等你真正遇到性能瓶颈了,再去研究更高级的方案也不迟。
好了,这一章的内容就到这里。代码你拿去跑一跑,有问题随时调试。记住,多线程编程的核心就四个字:资源隔离。每个线程管好自己的那一亩三分地,世界就太平了。