多进程、多线程与进程池线程池
多进程、多线程与进程池线程池
时间:2026/05/04
关键词:
fork、exec、僵尸进程、管道、共享内存、消息队列、pthread、互斥锁、条件变量、进程池、线程池
核心目标:理解 Linux 服务端常见并发模型,知道什么时候用进程、线程、进程池或线程池。
1. 为什么服务器需要并发模型
单连接阻塞式服务器只能处理一个客户端。
真实服务端必须同时面对:
- 多个连接
- 慢客户端
- CPU 密集业务
- 阻塞 I/O
- 超时和取消
常见并发方式:
- 多进程
- 多线程
- I/O 复用 + 单线程 Reactor
- I/O 线程 + worker 线程池
- 进程池 / 线程池
没有一种模型永远最好,关键看隔离性、开销和状态共享方式。
2. fork:创建子进程
1 |
|
返回值:
| 返回 | 位置 |
|---|---|
pid > 0 |
父进程,返回子进程 pid |
pid == 0 |
子进程 |
pid < 0 |
创建失败 |
fork 后:
- 子进程获得父进程地址空间的副本
- 现代系统通常用写时复制
- 文件描述符会被继承
服务端常见模式:
- 父进程负责监听和管理
- 子进程负责处理连接
- 或多个子进程共同 accept
3. exec:替换进程映像
exec 系列会用新程序替换当前进程:
1 | execl("/bin/ls", "ls", "-l", NULL); |
常见用途:
- CGI 子进程执行外部程序
- worker 进程启动其他服务
- 守护进程重启自身
注意:
exec成功后不会返回- 未设置
FD_CLOEXEC的 fd 会被继承到新程序 - 这也是为什么服务端要重视 close-on-exec
4. 僵尸进程
子进程退出后,父进程如果不回收它的退出状态,就会留下僵尸进程。
常用回收:
1 |
|
通常在 SIGCHLD 到来后回收。
但信号处理函数里不应做复杂逻辑,更稳妥的是:
- 设置标志
- 写 pipe/eventfd
- 在主循环里
waitpid
5. 进程间通信方式
常见 IPC:
| 方式 | 特点 |
|---|---|
| pipe | 简单字节流,适合父子进程 |
| Unix domain socket | 支持双向通信,可传 fd |
| 共享内存 | 速度快,但需要同步 |
| 信号量 | 进程间同步 |
| 消息队列 | 内核维护消息 |
| eventfd | 轻量计数通知 |
高性能服务端里常见组合:
- 共享内存存数据
- eventfd 或信号量做通知
- Unix domain socket 传递 fd
6. 在进程间传递文件描述符
Unix domain socket 可以通过辅助数据传 fd。
这很适合:
- master 进程 accept
- 把连接 fd 分发给 worker 进程
直觉是:
1 | master accept conn_fd |
传递的是“打开文件描述”的引用,不是把 socket 数据复制一份。
这种模型能让 master 集中管理监听,而 worker 专注处理连接。
7. 多进程模型的优缺点
优点:
- 进程间隔离强
- 一个 worker 崩溃不一定拖垮全部
- 适合利用多核
- 和权限隔离更自然
缺点:
- 进程创建和切换成本高于线程
- 共享状态复杂
- IPC 成本和代码复杂度更高
- 多进程共同 accept 需要考虑惊群和负载分配
适合:
- 强隔离服务
- CGI / worker 模型
- 多租户或不可信任务
8. pthread 基础
创建线程:
1 |
|
等待线程退出:
1 | pthread_join(tid, NULL); |
分离线程:
1 | pthread_detach(tid); |
工程建议:
- 能 join 就明确 join
- 不要让线程生命周期变成“没人知道它还在不在”
- 服务端更常用线程池,而不是每个请求创建一个线程
9. 互斥锁、条件变量与信号量
互斥锁保护共享状态:
1 | pthread_mutex_lock(&mutex); |
条件变量用于等待某个条件成立:
1 | pthread_mutex_lock(&mutex); |
注意必须用 while,不是 if。
原因是:
- 可能有虚假唤醒
- 被唤醒后条件也可能被其他线程抢先改变
信号量适合表达资源计数:
1 | sem_wait(&sem); |
10. 多线程环境里的常见问题
10.1 可重入函数
可重入函数可以在并发或信号打断场景下安全再次进入。
服务端要避免使用隐藏全局状态的旧接口,优先使用带 _r 或现代替代接口。
10.2 线程和信号
信号是发给进程的,但会由某个线程处理。
多线程服务里通常做法是:
- 所有工作线程阻塞相关信号
- 专门线程或 event loop 使用
signalfd统一处理
10.3 死锁
典型原因:
- 锁顺序不一致
- 持锁调用外部回调
- 持锁做阻塞 I/O
- 忘记异常或错误路径解锁
经验规则:
- 锁粒度小
- 锁顺序固定
- 不持锁做慢操作
- 能用消息传递就少共享状态
11. 为什么需要池
如果每来一个连接或请求都创建进程/线程,成本会很高:
- 创建销毁开销
- 栈和内核资源开销
- 调度抖动
- 高峰期不可控
池的思想是:
预先创建一组 worker,任务来了就投递,worker 循环处理。
池能带来:
- 控制并发上限
- 减少创建销毁成本
- 让资源使用可预测
12. 进程池模型
典型模型:
1 | master |
分发方式:
- master accept 后传 fd 给 worker
- 多 worker 共享监听 socket,自行 accept
SO_REUSEPORT下多个进程各自监听同端口
优点:
- 隔离强
- worker 崩溃可重启
难点:
- IPC
- 负载均衡
- 共享状态
- 优雅退出
13. 线程池模型
典型线程池结构:
1 | 任务队列 |
基本组件:
- 任务队列
- mutex
- condition variable
- 停止标志
- worker 数组
服务端里更常见的是:
1 | I/O 线程解析请求 |
不要让 worker 随意直接操作 socket。
连接状态最好由固定 I/O 线程拥有。
14. 半同步/半异步与半同步/半反应堆
可以粗略理解:
- 异步层:负责 I/O 事件和连接接入
- 同步层:负责业务任务处理
- 队列:连接两层
半同步/半反应堆的常见形态:
1 | Reactor thread: |
这个模型比“每连接一个线程”更可控,也比“所有业务都在 I/O 线程”更稳。
15. 池化模型常见坑
15.1 任务队列无限长
请求处理不过来时,内存会先被任务队列吃掉。
必须有队列上限和拒绝策略。
15.2 CPU 任务和阻塞 I/O 混在一个池
慢 I/O 会占满线程,导致 CPU 任务也排队。
建议拆不同线程池。
15.3 worker 直接关闭连接
这会破坏连接 owner 模型。
更稳妥的是发消息给 I/O 线程,让它关闭。
15.4 没有优雅退出
线程池退出要考虑:
- 不再接收新任务
- 唤醒所有 worker
- 处理或丢弃队列剩余任务
- join worker
15.5 多进程没有处理子进程崩溃
master 要能发现 worker 退出,并按策略重启或降级。
16. 一页总结
这篇最值得记住的是:
- 多进程隔离强,但 IPC 和资源管理复杂
- 多线程共享方便,但要面对锁、竞态和死锁
- 僵尸进程必须用
waitpid回收 - 条件变量等待条件要用
while - 池化是为了控制并发上限和降低创建销毁成本
- 服务端常用 I/O 线程管连接,worker 池管业务
- 队列上限、超时、拒绝策略和优雅退出必须一起设计
如果只记一句:
并发模型不是线程越多越好,而是让连接归属、任务队列、资源上限和失败处理都可控。
17. 参考资料
Linux man-pages: fork
https://man7.org/linux/man-pages/man2/fork.2.htmlLinux man-pages: waitpid
https://man7.org/linux/man-pages/man2/waitpid.2.htmlpthreads manual
https://man7.org/linux/man-pages/man7/pthreads.7.htmlLinux man-pages: unix domain sockets
https://man7.org/linux/man-pages/man7/unix.7.html