16. 多线程网络编程:多线程TCP服务器模型、线程池+epoll模型、并发连接处理、性能调优
网络编程这块,说实话,是很多C语言开发者从「单机」迈向「分布式」的一道坎。我记得刚入行那会儿,写一个简单的echo服务器,单线程、阻塞IO,客户端一多就卡死。后来带我的老大哥丢给我一句话:「你想想看,一个服务员同时只能服务一个客人,那餐厅还怎么开?」
嗯,从那天起,我开始认真研究多线程网络编程。今天这一章,我们就来聊聊多线程TCP服务器、线程池+epoll模型,以及怎么把并发连接处理到极致。
16.1 多线程TCP服务器模型
最简单的多线程模型,就是「一个连接一个线程」。主线程负责accept,每来一个客户端,就创建一个新线程去处理。
代码大概长这样:
// 伪代码:每连接一线程模型
while (1) {
client_fd = accept(server_fd, ...);
if (client_fd > 0) {
pthread_t tid;
// 创建线程处理该客户端
pthread_create(&tid, NULL, client_handler, &client_fd);
pthread_detach(tid); // 分离,不等待
}
}
void *client_handler(void *arg) {
int fd = *(int*)arg;
// 处理读写...
close(fd);
return NULL;
}
这个模型简单直观,但问题也很明显。我在项目中遇到过一台服务器同时接入几千个客户端,线程数直接飙到几千。线程创建销毁的开销、上下文切换的代价,让CPU直接跪了。说白了,这种模型只适合连接数少、请求处理快的场景。
16.2 线程池+epoll模型
那怎么解决?线程池。说白了就是提前创建好一批线程,有任务来了就丢给空闲线程去处理,没有任务就等着。避免了频繁创建销毁的开销。
而epoll,是Linux下最高效的IO多路复用机制。它不像select/poll那样每次都要遍历所有fd,而是只返回有事件发生的fd。复杂度从O(n)降到了O(1)。
我个人的习惯是:epoll负责事件通知,线程池负责业务处理。两者结合,堪称绝配。
16.3 并发连接处理
并发连接处理,核心在于两点:
- 事件分发要快——epoll_wait返回后,要能快速定位到哪个fd有事件
- 业务处理要异步——不要在事件循环里做耗时操作
我曾经在一个项目中,把数据库查询直接放在了epoll的事件处理回调里。结果呢?一个慢查询拖垮了整个事件循环,其他所有连接都跟着遭殃。嗯,这个坑我踩过,你们就别踩了。
正确的做法是:
- epoll_wait 获取就绪事件
- 将任务封装成结构体,丢入线程池的任务队列
- 工作线程从队列取出任务,执行真正的读写和业务逻辑
- 处理完成后,如果需要继续监听,重新注册到epoll
这里我画了一张流程图,帮你理清整个逻辑:
16.4 性能调优
模型搭好了,不代表性能就上去了。调优才是真正的硬功夫。我总结了几条实战经验:
| 调优项 | 说明 | 建议值 |
|---|---|---|
| 线程池大小 | 不是越大越好,取决于CPU核心数和任务类型 | CPU密集型:N+1;IO密集型:2N(N为CPU核心数) |
| epoll事件大小 | epoll_wait一次最多返回的事件数 | 64~256,根据实际并发量调整 |
| 任务队列长度 | 防止任务堆积导致内存暴涨 | 建议设置上限,超过则拒绝新连接 |
| TCP参数 | 减少TIME_WAIT、调整缓冲区 | 开启SO_REUSEADDR,调整tcp_tw_reuse |
| 锁粒度 | 任务队列的锁竞争是瓶颈 | 使用无锁队列或分段锁 |
16.5 避坑指南
最后,分享几个我踩过的坑:
- 线程安全问题——任务队列、共享数据一定要加锁,或者用原子操作。我曾经因为忘记加锁,导致两个线程同时处理同一个连接,数据全乱了。
- 文件描述符泄漏——每个accept得到的fd,一定要确保在某个地方被close。线程池处理完任务后,记得关闭连接。
- 惊群问题——多个线程同时epoll_wait同一个fd,会导致惊群。解决办法是用EPOLLEXCLUSIVE标志,或者只在主线程做epoll。
- 信号处理——多线程环境下,信号的处理要格外小心。我建议用signalfd来统一处理信号,避免信号打断epoll_wait。
嗯,这一章的内容就到这里。多线程网络编程,说白了就是「事件驱动 + 线程池」的组合拳。模型选对了,调优做到位,你的服务器就能扛住成千上万的并发连接。