一、起点:单进程版死在哪里
单进程版的结构是 accept 一次、Service 里 read-write 死循环。问题不在 read 写错,而在结构:
while (1) {
connfd = accept(listenfd, ...); // 拿一个连接
Service(connfd, addr); // 进去就不出来了
}
只要第一个客户端不主动断开,Service 里的循环就一直转,accept 这一行永远等不到第二次执行。第二个客户端照样能连上(三次握手由内核完成),但没人理它。
改造方向因此非常明确:把「拿连接」和「服务这个连接」拆到两条执行流上 。拆的方式有三种------多进程、多线程、线程池。三条路的差别只在「谁来执行 Service」,其余代码几乎不动。
二、★ 两个前置问题(进程版的地基)
改造前必须先回答两个问题,答案都来自前面讲过的东西。
2.1 进程退出后,它打开的文件会怎样
答案:由内核自动关闭。
细节在引用计数上:
| 情形 | 结果 |
|---|---|
| 只有本进程打开这个文件 | 进程退出 → 引用计数减到 0 → 文件关闭 |
| 多个进程打开同一个文件 | 退出时引用计数减 1,减到 0 才真正关闭 |
这就是「文件的生命周期随进程」这句话的实际含义。讲管道时用到的也是同一条规则。
它解释了一个实测现象:客户端按 Ctrl+C 被杀掉,服务端立刻 感知到对方退出。原因不是服务端收到了什么通知,而是客户端进程一死,它的 fd 全部被内核关掉,内核随即向对端发出结束信号,服务端的 read 于是返回 0。
2.2 子进程能看到父进程打开过的 fd 吗
答案:能。
fork 的动作是给子进程复制一份 PCB,其中包含文件描述符表 。但表里存的是指针,指向内核里的 struct file 对象------所以这次复制是浅拷贝 :父子各有一张表,表里的格子指向同一批 struct file。
由此得到两个直接后果:
- 子进程能看到父进程曾经获得的 fd,并且能直接用它做读写;
- 这批
struct file的引用计数翻倍,父子都不关就永远到不了 0。
这正是管道能工作的原因:先打开文件,再 fork,子进程继承 fd,两端各自关掉不需要的方向,剩下的那条就是单向信道。
三、多进程版第一层:fork 之后各关各的
进入 accept 成功之后,当前进程已经拿到 connfd。接下来 fork 一次,分成三支:
fork 返回值 |
身份 | 该做什么 |
|---|---|---|
< 0 |
创建失败 | 打 FATAL 日志,进程退出 |
== 0 |
子进程 | 关掉 listenfd,执行 Service(connfd, addr),做完 exit |
> 0 |
父进程 | 关掉 connfd,回到 accept 继续等下一个人 |
3.1 fork 失败意味着什么
fork 失败不是代码写错,而是服务器内部资源已经不够:进程数达到上限,或者内存不够分配新的地址空间。
所以这一支的处置是「打日志 + 退出」,而且日志级别要拉到最高。一个服务器连进程都创建不出来,说明它已经扛不住了,继续运行只会恶化------这就是平时所说的「服务器压力太大挂了」在代码层面的样子。
3.2 ★ 双方各关掉自己不需要的 fd
fork 之后,父子双方都能看到两个 fd :listenfd 和 connfd。但两者的职责完全不同,所以要各关一个:
| 角色 | 保留 | 关掉 | 理由 |
|---|---|---|---|
| 父进程 | listenfd |
connfd |
它只负责 accept,不做 IO |
| 子进程 | connfd |
listenfd |
它只负责这一条连接的 IO,不该碰监听 |
可以不关吗? 可以,程序照样跑。但有两个代价:
- 语义不对。分工的意义就在于「父进程只拉客、子进程只服务」,两边都攥着对方的 fd,这个分工在代码上就模糊了。
- 可能踩到未定义行为。fd 是数字,拿错了不会报错,会直接读错数据或写错地方。关掉不该握着的那个,等于从源头消掉一类 bug。
💡 关掉 connfd 之后,那条 struct file 的引用计数从 2 变 1,只剩子进程持有。等子进程退出(或在 Service 里 close),计数归零,连接才真正释放。
四、★★ 僵尸问题:父进程到底要不要等
4.1 两难
子进程做完事退出,会留下一个僵尸(zombie)------PCB 还在,只等父进程读取退出状态。而本课的服务器是常驻内存的死循环 ,来一个客户端就 fork 一次,于是「来一个僵一个」,可用资源越来越少。
所以父进程必须回收 。回收只能靠 wait / waitpid。但 wait 是阻塞的:
父进程:fork → wait(子) → ... ← 卡在这里
一旦父进程卡在 wait,它就没法回到 accept。而子进程要服务到客户端断开才退出------于是整个服务器又变回「一次只能服务一个人」,多进程白改。
这就是本节的核心矛盾:不回收会漏资源,回收就退化成串行。
4.2 四种做法
| 方案 | 做法 | 评价 |
|---|---|---|
① signal(SIGCHLD, SIG_IGN) |
告诉内核「子进程退出自动回收,不用通知」 | ★ 最推荐,一行代码解决 |
| ② 自定义信号捕捉 | 在 handler 里调 waitpid |
可行,但要处理信号处理函数的重入问题,绕 |
③ waitpid 加 WNOHANG |
非阻塞轮询 | ✖ 不靠谱,见下 |
| ④ 两次 fork | 父等子、子快死、孙干活 | ★ 本课采用 |
方案 ① 为什么最好 :把「回收」这件事完全交给内核,父进程一行都不用改,accept 循环不受任何影响。代价是拿不到子进程的退出状态------对服务器来说这个状态通常也没用,所以不成问题。
方案 ③ 为什么不靠谱 :WNOHANG 的意思是「有僵尸就收,没有就立刻返回」。听起来不错,但存在一个时间差问题:真正阻塞的那一刻,恰好没有子进程退出,于是 waitpid 空转一圈返回,父进程继续往下走------等到子进程真的退出时,已经没有人在等它了。轮询要么太密(白耗 CPU),要么太疏(漏回收),两头不讨好。
4.3 ★★ 两次 fork 的结构
方案 ④ 的思路是把「干活的人」和「需要被回收的人」分开:
父进程 ──fork──▶ 子进程 ──fork──▶ 孙进程(真正服务的人)
│ │
└─ waitpid(子) ◀────┘ 立即 exit
具体动作:
| 角色 | 动作 |
|---|---|
| 父进程 | close(connfd) → fork → waitpid(子) → 回到 accept |
| 子进程 | 再 fork 一次 → 自己立刻 exit |
| 孙进程 | close(listenfd) → Service(connfd, addr) → 干完退出 |
关键在于:父进程等的是一个「fork 完就 exit」的子进程 ,waitpid 几乎不会阻塞。而真正干活的孙进程已经变成孤儿,被 1 号进程(init / systemd)领养;它退出时由 1 号进程直接回收,压根不走父进程这条路。
两条回收路径互不干扰,僵尸没有产生的机会。

💡 这也是「各回各家各找各妈」这句俗话在进程世界里的准确含义:子进程退出归父进程管,孙进程退出归 1 号进程管。只要能保证每个会死的进程都有一个「会立刻醒来」的父进程,就不会有僵尸堆积。
五、实测:进程数确实跟着连接数走
改完之后直接观察。三个命令:
| 命令 | 用途 |
|---|---|
| `ps axj | grep tcpserver` |
| `ps -aL | grep tcpserver` |
netstat -natp |
看连接的四元组与状态 |
5.1 多进程版的观察结果
启动服务器、连上一个客户端,ps axj 显示两个进程:一个是服务器本体,另一个是 fork 出来的子进程(父进程号指向服务器)。
客户端 Ctrl+C 退出后,再查一次------只剩一个进程。子进程不见了,因为它做完事退出,被系统自动回收了。
课堂上让几十个同学同时连上,进程数随连接数一路涨;所有人同时退出,进程数又一路降回去。这就是多进程版最直观的证据:每一条连接对应一条执行流。
5.2 多线程版的观察结果
换成线程之后,ps axj 里只有一行 (一个进程);加 -L 才看得到多个线程。所有线程的 PID 相同,只是 LWP 不同。
这是进程与线程最直观的外部区别:对外只算一个进程,对内有多条执行流。
六、★ 换到线程:规则反过来了
改造到线程时,同样要先回答两个问题。答案和进程版一半相同、一半相反。
| 问题 | 进程版 | 线程版 |
|---|---|---|
| 能看到别人打开的 fd 吗 | 能(fork 拷贝一份表) |
能(表本来就共享) |
| 敢关掉自己用不到的 fd 吗 | 敢,各关各的 | ✖ 不敢,一关全体受影响 |
6.1 为什么能看见
创建线程时只新建一个 PCB(内核里的 task_struct),没有新建文件描述符表。这张表属于进程,被进程内的所有线程共用。
所以进程打开一个文件拿到的 fd,任何线程都能直接拿来读写,连传递都不需要。
6.2 为什么不敢关
因为只有一张表。某个线程 close(fd) 之后,这张表里的那一格就空了,其他线程再用这个数字就是无效 fd。
线程之间不存在「某个线程专属的 fd」这种区分------fd 是进程级资源,不是线程级资源。这一点与进程版形成了干净的对照:fork 拷贝出来的表是私有的,关掉只影响自己;线程共用的表是公共的,关掉会影响所有人。
由此得到一个直接结论:多线程版里,谁打开的 fd 就由谁负责关,绝不能像多进程那样「各关自己不要的那个」 。listenfd 由主进程开、由主进程留着,connfd 也从主线程的 accept 出来,所以两个都不在子线程里关。
七、多线程版实现:ThreadData 三件套
7.1 卡点在函数签名
线程函数(pthread_create 的第三个参数)签名是固定的:
c
void *(*start_routine)(void *);
只有一个 void* 参数 。但服务一个连接需要三样东西:connfd、对端地址、以及调用 Service 的那个 TcpServer 对象。一个指针装不下三样。
多进程版没有这个问题:fork 之后子进程直接继承了整个地址空间,this、sockfd、addr 全都在眼前。线程版必须自己想办法把这三样东西递过去。
7.2 办法:打包成结构体
cpp
class ThreadData {
public:
int sockfd; // 服务谁
InetAddr addr; // 对方是谁
TcpServer *tsvr; // 谁在服务
ThreadData() {}
};
三个字段各自的作用:
| 字段 | 来自 | 用途 |
|---|---|---|
sockfd |
accept 的返回值 |
Service 的第一个参数 |
addr |
accept 的出参 |
Service 的第二个参数 |
tsvr |
this |
调 Service 这个成员函数必须的宿主对象 |
第三个字段最容易被忽略 。Service 是 TcpServer 的成员函数,调用它必须有一个对象。线程函数是 static 的(否则签名不匹配),里面没有 this,所以要靠 tsvr 把它带进来。
7.3 创建与回收
cpp
ThreadData *td = new ThreadData();
td->sockfd = sockfd;
td->addr = addr;
td->tsvr = this;
pthread_t tid;
pthread_create(&tid, nullptr, Routine, td);
pthread_detach(tid);
三个细节:
- 必须
new在堆上 。accept是死循环,栈上的对象下一轮就被覆盖;更要紧的是线程是异步的,函数返回时新线程可能还没开始跑,栈帧一撤就变成悬空指针。 - 必须
detach。不detach就得join,而join会阻塞主线程------主线程卡在原地,又回不到accept,多线程版等于白写。这与多进程版里「wait导致串行化」是同一个坑,只是换了个名字。 - 必须
delete。放在Routine的末尾,不能放在主线程。放主线程就成了「刚创建就被释放」,线程还没读完数据,那块内存已经交回去了。
7.4 线程函数
cpp
static void *Routine(void *args)
{
ThreadData *td = static_cast<ThreadData *>(args);
td->tsvr->Service(td->sockfd, td->addr);
delete td;
return nullptr;
}
三步:还原指针 → 干活 → 释放。还原必须用 static_cast 且类型与 new 时严格一致,void* 到具体类型的转换编译器不会替人检查。

💡 本课这一版故意用原生 pthread 而不是封装好的线程类 。原因是:std::thread 支持可变参数模板,pthread_create(t1, fn, a, b, c) 这样写就能直接传三个参数,ThreadData 这一整套就不需要了。用原生接口才能把「一个 void* 怎么装三样东西」这个真实约束暴露出来。
八、★ 长服务与短服务
改造完两版之后,课堂提出一个更根本的问题:这两版真的适合所有场景吗?
判断标准只有一条:客户端不主动退,这次服务会不会一直占着。
8.1 两种服务
| 维度 | 长服务 | 短服务 |
|---|---|---|
| 生命周期 | 连上就一直在,直到客户端主动退 | 请求一次、应答一次、立刻断开 |
| 一次连接 | 一条长期占用的执行流 | 一个很快结束的任务 |
| 典型场景 | SSH 远程登录、游戏对局、长连接翻译 | 登录、注册、搜索、抢票、打开网页 |
| 合适的模型 | ★ 多进程 / 多线程 | ★ 线程池 |
SSH 是长服务的标准例子 :登录之后可以在命令行里随便敲,输出源源不断地回来,直到主动断开或服务端把 sshd 杀掉。
抢票是短服务的标准例子:点一次刷一次,服务器给个结果就把连接关了,下次要刷新得重新请求。登录、注册、搜索同理------拿到结果,本次连接就结束。
游戏是两者混合:放技能、放闪现这种高频操作走一条长连接(长服务),而某些低频操作可能另开连接(短服务)。
8.2 现实分布
真实网络请求里,短服务占绝大多数,约七成以上。因为绝大多数用户行为都是「要一个结果」:登录要结果、搜索要结果、打开网页要的是首页。
这带来一个直接推论:「来一个连接就创建一个进程 / 线程」在真实服务器上并不常见。原因下一节讲。

九、★ 为什么最终要落到线程池
9.1 创建本身有成本
| 创建对象 | 要做的事 |
|---|---|
| 进程 | 建 PCB、建地址空间、建页表、打开文件、各类资源申请 |
| 线程 | 建 PCB、在原生线程库里建结构体、底层 clone 系统调用 |
线程比进程轻,但绝不等于零成本。短服务的连接可能只活几十毫秒,为了这几十毫秒去创建一条执行流,开销占比就很可观了。
9.2 数量有上限
线程再多也是有上限的。一个连接一条线程的模型,在连接数暴涨时会直接被打穿------创建失败、内存耗尽、调度开销压垮 CPU。
9.3 线程池的做法
预先创建固定数量的线程,让它们阻塞在任务队列上等着。来了连接,只往队列里投递一个任务,谁空闲谁取走。
| 对比项 | 来一个建一个 | 线程池 |
|---|---|---|
| 创建时机 | 每次连接都要创建 | 启动时一次性创建 |
| 每个连接的开销 | 创建 + 执行 | ★ 只有执行 |
| 数量控制 | 随连接数暴涨 | ★ 恒定 |
| 代价 | 无峰值保护 | 超出池容量要排队 |
9.4 ★ 为什么线程池配短服务
线程池的线程数是固定的(本课给 15 个)。如果服务是长服务,那么这 15 个线程会被 15 个客户端长期占住,第 16 个人就只能排队------并发上限被池容量钉死。
如果是短服务 ,每个任务只占用线程极短的一瞬间,15 个线程在单位时间内可以轮转处理成百上千个请求。池容量不再是并发上限,而是吞吐能力的调节旋钮。
长服务则相反:一条连接必须独占一条执行流,所以应该用「来一个建一个」的多进程/多线程------人数上限由系统资源决定,而不是由某个固定数字决定。sshd 采用的就是多进程:来一个客户端创建一个进程,有多少人登录就撑多少条执行流。
💡 一句话总结选型:连接会长期占着 → 一条执行流一条连接;连接转瞬即逝 → 固定一批执行流轮转。
十、线程池版实现
10.1 任务类型
线程池要处理的是「任务」,而任务本质上是「一段可以稍后执行的代码」,用 std::function<void()> 表示:
cpp
using task_t = std::function<void()>;
线程池实例是单例,取实例之后投递:
cpp
using task_t = std::function<void()>;
ThreadPool<task_t>::GetInstance()->Push(
std::bind(&TcpServer::Service, this, sockfd, addr)
);
10.2 std::bind 做了什么
Service 是成员函数,需要三个实参:宿主对象 this、sockfd、addr。std::bind 把这三样一起绑定进一个可调用对象,于是得到一个无参 的 void():
| 原始 | 绑定后 |
|---|---|
this->Service(sockfd, addr) |
一个可以随手调用的 void() |
这正是线程池要的形式:池子里的线程只管「取出来调用」,不需要知道任何参数细节。
10.3 与多线程版的对比
| 维度 | 多线程版 | 线程池版 |
|---|---|---|
| 执行流创建 | 每个连接 pthread_create |
启动时创建 N 个,之后不再创建 |
| 参数传递 | ThreadData 打包 + void* |
std::bind 绑定,不需要结构体 |
| 分离/回收 | 必须 pthread_detach |
不需要,线程一直活着 |
| 执行流数量 | 随连接数涨落 | 恒定 |
| 客户端退出后 | 线程少一个 | ★ 线程数不变,继续等下一个任务 |
最后一条是实测的关键观察:客户端全部退出后,线程池里的 15 个线程一个不少。因为它们本来就不是为某一条连接存在的,它们只是在等队列里的下一个任务。
10.4 本课这版的局限
任务里走的还是长服务(Service 里有 read-write 死循环),所以 15 个线程最多只能同时服务 15 个人,第 16 个人要等。这与 9.4 的结论完全一致------线程池配长服务本来就别扭。
真正的线程池用法要配合短服务:任务里读一次、写一次、关连接,然后立刻返回。这样 15 个线程才能轮转起来。
十一、一个先记下来的现象:bind 失败
实测中撞到一件事,课堂上明确说「今天不讲,但先记住」:
11.1 现象
| 步骤 | 动作 |
|---|---|
| ① | 多个客户端已经连上服务器 |
| ② | Ctrl+C 把服务器进程杀掉 |
| ③ | 立刻重新启动 ./tcpserver 8080 |
| ④ | bind 失败 :Address already in use |
11.2 已知的部分

- 失败发生在
bind,不是listen------说明端口还被占着。 - 客户端还连着的时候尤其明显。
- 临时解法:换一个端口号,或者等一会儿再启动。
- 根本解法与
setsockopt(SO_REUSEADDR)有关。
11.3 为什么现在不展开
要解释清楚必须引入 TCP 的状态机 (尤其是 TIME_WAIT 这条路径),而状态机还没讲。先把现象存住,讲到状态转换的那一节,这一幕能立刻对上。
十二、速查表
12.1 三种并发模型
| 多进程 | 多线程 | 线程池 | |
|---|---|---|---|
| 执行流创建 | 每个连接 fork |
每个连接 pthread_create |
启动时一次性创建 |
| fd 归属 | 各有一份表 | 全进程共享 | 全进程共享 |
| 要不要关 listenfd | 子进程要关 | 不要动 | 不要动 |
| 回收方式 | wait / 两次 fork |
pthread_detach |
不需要 |
| 隔离性 | ★ 强,进程崩了不影响别人 | 弱,一个线程崩全进程崩 | 弱 |
| 开销 | 大 | 中 | ★ 小 |
| 适合 | 长服务 | 长服务 | 短服务 |
12.2 关键接口
| 接口 | 作用 | 易错点 |
|---|---|---|
fork() |
复制当前进程 | 子进程要关 listenfd,父进程要关 connfd |
wait / waitpid |
回收子进程 | 阻塞,会让父进程回不到 accept |
signal(SIGCHLD, SIG_IGN) |
让内核自动回收 | 拿不到退出状态 |
pthread_create |
创建线程 | 函数签名固定为 void*(void*) |
pthread_detach |
分离线程 | 不分离就得 join,会阻塞 |
std::bind |
绑定成员函数与实参 | 生成无参可调用对象 |
ps axj / ps -aL |
看进程树 / 看线程 | 线程要靠 -L 才看得见 |
12.3 三处「差点写错」的地方
| 位置 | 错误写法 | 正确做法 |
|---|---|---|
ThreadData 的分配 |
栈上 ThreadData td; |
new 在堆上,Routine 里 delete |
| 线程分离 | 省略 pthread_detach |
必须分离,否则主线程阻塞 |
| 强转 | C 风格 (ThreadData*)args |
static_cast<ThreadData*>(args) |