本篇要回答的问题:怎么实现多任务?为什么会演化出进程、线程、协程三套执行体?它们到底有何不同、各自为何而来?
前面把"CPU + 存储(内存/外存)+ 输入输出"基础内容都覆盖了,从本讲起进入多任务。多任务需求随处可见(边工作边听音乐、后台监控)。许式伟用"执行体"统称进程/线程/协程:可被 CPU 赋予执行权的对象,至少含"下一个执行位置 + 运行状态"。
多任务与执行体
物理层面实现多任务有两条路;只有单核时则靠分时系统:
| 层面 | 方法 | 适用 |
|---|---|---|
| 物理多任务 | 多颗 CPU | 服务器(追求计算力密度,常两者并用) |
| 物理多任务 | 单 CPU 多核心 | 桌面端(在意体积) |
| 单核模拟 | 分时系统:把 CPU 时间切成时间片轮流给软件 | 时间片很小,感觉同时运行 |
分时系统三个核心问题:
| 问题 | 答案 |
|---|---|
| 任务怎么抽象 | 进程 / 线程 / 协程,统称"执行体" |
| 任务状态有什么、怎么保存恢复 | 执行体上下文 = 一堆寄存器的值 |
| 何时发生任务切换 | 保存当前寄存器→恢复另一任务寄存器→交执行权 |
关键判断:执行体的上下文就是一堆寄存器的值——内存(实模式同地址空间、保护模式靠地址映射表,而切映射表也是切寄存器)也归结为寄存器。切换执行体 = 保存并恢复一堆寄存器,无论进程、线程还是协程都如此。
进程与线程
| 执行体 | 定位 | 关键特征 |
|---|---|---|
| 进程 | OS 从安全角度的隔离单位 | 不同进程间最低授权原则 |
| 线程 | 同一软件内的多任务 | 处在相同地址空间,彼此可信任 |
关键判断:UNIX 创建进程用 fork(先 clone 再分支,父子各干各的),使用便利,但许式伟认为这是 UNIX 设计中最糟糕的 API,没有之一(Linux 还继承了)。原因:进程本是最基本的隔离单元,最怕摘不清,fork 偏偏藕断丝连。Windows 的 CreateProcess 把要继承的句柄一一点名,清晰得多。
理解 fork 的历史背景:早期 OS 只有进程没有线程,进程承担了一部分本属于线程的需求(“我需要父进程的环境”),从线程视角能稍微理解当年设计者的考量。
补:先建立画面(原文缺的一层)
原文这节 80% 篇幅在骂 fork,没讲清"进程/线程到底是什么"。补一层画面,2026-08-28 在本机实测。
一句话:**进程是"拥有资源"的单位,线程是"被调度执行"的单位。**一个进程至少含一个线程;CPU 真正调度的是线程,进程自己不"跑",它只是装资源的盒子。
| 谁独占什么 | 进程 | 线程 |
|---|---|---|
| 地址空间(页表) | ✅ 独占 | 共享所属进程的 |
| 文件句柄表 | ✅ 独占 | 共享 |
| 权限身份(uid) | ✅ 独占 | 共享 |
| 寄存器组 | — | ✅ 独占 |
| 栈 | — | ✅ 独占 |
| TLS | — | ✅ 独占 |
写字楼模型
| 楼里的东西 | 计算机里的东西 | 本机实测 |
|---|---|---|
| 上了锁的办公室 | 进程 | 807 间 |
| 房间里的员工 | 线程 | 4943 人 |
| 能干活的工位 | CPU 核心 | 只有 10 个 |
| 房间里的文件柜 | 内存 / 地址空间 | — |
| 员工手上的门禁卡 | 文件句柄 | — |
| 物业 | 操作系统 | — |
4943 人抢 10 个工位 → 任何一刻绝大多数人都在等着被叫号上工位。这就是分时的实感:不是"同时在跑",是轮换快到察觉不到。
查看命令:ps -A(进程)· ps -M <pid>(某进程的线程)· ps -A -M(全机线程)· sysctl -n hw.ncpu(核心数)
三个实验:把"共享/不共享"跑出来
实验一 · 线程共享同一份数据
box = {"n": 0}
def add_one(name):
box["n"] += 1
print(f"{name} 看到 box = {box['n']}")
# 两个线程依次跑 add_one
# 线程A 看到 box = 1
# 线程B 看到 box = 2 ← B 看得见 A 干的事
# 主线程最后 box = 2 ← 两次 +1 都算数
实验二 · 进程各活各的
box = {"n": 0}
for name in ["子进程A", "子进程B"]:
if os.fork() == 0:
box["n"] += 1
print(f"{name} 看到 box = {box['n']}"); os._exit(0)
else: os.wait()
# 子进程A 看到 box = 1 ← 它以为自己成功了
# 子进程B 看到 box = 1 ← 不是 2!它没看见 A
# 父进程最后 box = 0 ← 两次 +1 一次都没进来
实验三 · 同一个内存地址,三个进程读出三个值(最颠覆的一个)
buf = ctypes.create_string_buffer(b"----", 8)
addr = ctypes.addressof(buf) # 0x1016cb018
# 两个子进程各往同一个 addr 写 AAAA / BBBB,再读回来:
# 子进程 pid=70344 读 0x1016cb018 -> AAAA
# 子进程 pid=70345 读 0x1016cb018 -> BBBB
# 父进程 pid=70343 读 0x1016cb018 -> ----
关键判断:三个进程说的是同一个"柜号",打开的是三个不同的柜子。所谓隔离不是有人在门口检查,而是根本走不到隔壁去——你说"3 号柜",物业只会把你带到你自己房间的 3 号柜。
由此直接推出两条日常经验:线程之间要加锁(同屋共用文件柜,同时改会打架);进程之间不用锁(碰不着,真要传东西得走前台登记 = IPC)。
为什么两套都要:Edge 自己就是答案
本机实测各应用的形态差异极大:
| 应用 | 进程数 | 主进程内线程数 | 形态 |
|---|---|---|---|
| DingTalk | 1 | 214 | 一间大办公室,214 个人 |
| Microsoft Edge | 10 | 64 | 10 间办公室,主间 64 人 |
| 15 | 123 | — | |
| VS Code | 16 | 17 | — |
Edge 的双层结构就是这一讲的完整答案:
- 每个网页开独立进程——网页是陌生人写的代码,不可信;一个标签页崩了不能带崩浏览器,恶意页面不能读到隔壁标签的网银 → 要墙 → 用进程
- 一个进程内再开几十个线程(渲染 / 网络 / 解码 / GC)——这些是自家代码,互相信任,还必须共享同一棵 DOM 树 → 要廉价共享 → 用线程
关键判断:**隔离与协作是一对矛盾。进程解决"我不信你",线程解决"我们是自己人却还得共用一份数据"。**一套执行体满足不了两个方向相反的需求,所以必须有两套。
成本上也差一个量级:切进程要换页表 → TLB 全部失效 → 慢;切线程只换寄存器和栈指针 → 快。这正是后面协程"继续降切换成本"那条路的起点。
回到 fork:一个亲眼可见的"藕断丝连"
跑实验二时踩到一个坑:子进程的 print 输出凭空消失了,加 flush=True 才出来。原因是 fork 把父进程的 stdout 缓冲区也一并复印走了,子进程 os._exit() 跳过刷新,那份复印件就烂在肚子里。
关键判断:这就是"默认全继承"的代价——你根本不知道自己给出去了什么。开新办公室的正常做法是指定新房间能拿哪几张门禁卡(Windows
CreateProcess);fork 的做法是把整间办公室连人带柜子带门禁卡整个复印一份,再让你自己回想该 close 掉哪些(FD_CLOEXEC就是事后补的补丁)。白名单 vs 黑名单,这是 default-unsafe。
补一条原文没提、最能体现其危险的现代坑:在多线程程序里 fork,只有调用 fork 的那个线程会出现在子进程里,其他线程凭空蒸发——若它们当时正持有锁,那把锁在子进程里将永远锁着。所以今天推荐 posix_spawn / vfork + exec。
那段历史话怎么读
原文:“早期操作系统中没有线程的概念……所以进程实际上承担了一部分来自线程的需求:我需要父进程的环境。”
展开:1970 年代 UNIX 只有进程一种执行体。但程序员的真实需求是"再开一条执行流,并且它得看得见我现在的状态"——这本质是线程的需求。当时只有进程可用,只好让进程尽量像线程:把父进程的一切都带过去。fork 的"全继承"语义,就是在用进程模拟线程。
后来线程被发明,该需求被正当接管,fork 的全继承便只剩副作用。所以许式伟的批判是事后视角:放在 1975 年是合理权宜,放在今天是历史包袱。
协程与 goroutine
协程不是内核提供的,是用户态线程,功能比线程弱,为何还要它?为了实现高性能网络服务器。
网络服务器充斥大量并行 IO 请求,标准网络 IO 有四项成本:
| 成本 | 说明 |
|---|---|
| 系统调用开销 | 查中断向量表、改执行权限 |
| 数据多次拷贝 | 先到 OS 缓存再到用户内存 |
| 阻塞→调度 | 无数据时阻塞、重新获执行权的时间成本 |
| 线程成本 | 标准 IO 是同步调用,要并行只能多开线程(空间+时间成本) |
关键判断:误区"系统调用很慢"应被纠正——系统调用只多查了中断向量表、改了执行权限,并未发生调度,归根结底还是一次函数调用的成本。
主流提升吞吐用 epoll(Linux)/IOCP(Windows):登记 IO 请求,统一查谁先完成。它们其实系统调用次数更多、内存拷贝也没减少——真正有意义的是减少了线程数量。但不用同步 IO 就得写异步 IO,而异步 IO 编程很反人类(逻辑被回调函数碎片化,让人怀念同步 IO)。
为什么要减少线程:成本拆解
| 成本类型 | 拆解 |
|---|---|
| 时间成本 | ①切换本身(寄存器存恢复,余地有限)②调度开销(从大量就绪体里选谁)③同步互斥成本 |
| 空间成本 | ①执行状态 ②TLS(线程局部存储)③堆栈 |
空间成本是第一根稻草:Linux 线程默认数 MB(最大是堆栈,又因安全不能太小)。1MB×1000 线程 = GB 级,消耗太快。调度与同步互斥成本虽单位不大,但 IO 请求次数太多盖不住。
协程的两个目的与完备协程
协程为两件事而来:①回归同步 IO 编程模式;②降低执行体的空间与时间成本。
关键判断:大部分协程库只是半吊子(只实现创建+切换,缺调度、同步互斥通讯、系统调用包装)。最难的是堆栈:太小不够用,太大并发数低,理想是自动适应。一个完备的协程库可理解为用户态的操作系统,协程就是其中的"进程"。
完备协程仅 Erlang、Go 做到了。Go 的 goroutine 关键设计:
| 设计 | 说明 |
|---|---|
| 堆栈起步极小(4K)可按需自动增长 | 解决堆栈两难 |
| 干掉 TLS 支持 | 让执行体更精简 |
| 提供同步/互斥/通讯(含 channel) | 完整的执行体协同手段 |
| 包装几乎所有重要系统调用(尤其 IO) | 用户态完整 IO 子系统 |
补:协程到底是什么(原文缺的一层)
原文说协程是"用户态线程",但没讲清两件事:它和进程/线程是什么关系、不靠内核那谁来切换它。补一层,2026-08-28 本机实测。
三者是包含关系,不是并列关系
进程 ← 操作系统造的(房子)
└ 线程 ← 操作系统造的(员工)。真正能上 CPU 干活的,只有它
└ 协程 ← 你自己在代码里造的(员工手上的任务卡)。必须借线程才能动
接上一节的写字楼模型:协程 = 员工手上的一叠待办任务卡。员工只有一个,手上却有 1 万张卡;拿起一张干一会儿,卡上写着"等快递"就放回去、换下一张。老板(操作系统)从头到尾只看见一个员工在忙,根本不知道有 1 万张卡。
三张身份证(实测 os.getpid() / threading.get_native_id()):
| 进程号 | 线程号 | 说明 | |
|---|---|---|---|
| 两个进程 | 不同 | 不同 | 两栋房子 |
| 两个线程 | 相同 | 不同 | 同栋房子里两个人 |
| 两个协程 | 相同 | 相同 | 同一个人手上的两张卡 |
协程A 进程号=73507 线程号=15910707
协程B 进程号=73507 线程号=15910707 ← 完全一样
关键判断:协程在操作系统的账本上根本不存在。它不是与进程、线程并列的第三种执行体,它就是同一个线程在轮流干几件事。协程无法独立存在,必须寄生在某个线程里。
协程的原子定义:可暂停、可续跑的函数
抛开所有花哨说法,就一句:
- 普通函数:调用了一口气跑到尾,返回后现场全毁(局部变量、执行到哪一行,全没了)
- 协程:能停在半路,把现场保住,以后从那儿接着跑
def coro():
total = 0
total += 10; print(f"第1步 total={total}"); yield # 停在这
total += 20; print(f"第2步 total={total}"); yield # 从这接着跑
total += 30; print(f"第3步 total={total}")
# 第1步 total=10 →(主程序插进来干别的)→ 第2步 total=30 → 第3步 total=60
# ↑ 局部变量跨越暂停活了下来
关键判断:协程 = 可暂停、可续跑的函数。剩下的一切(调度器、异步 IO、goroutine)都是拿这个能力搭出来的。有了它,就能在一个线程里轮流推进 1 万个任务——干一点→停→换下一个——快到你以为它们在同时跑。这就是并发。
不靠内核,那谁来切换?你自己的程序
原文没答这题。答案是:你的代码里有个调度器,7 行就够:
def run(*tasks):
q = deque(tasks)
while q:
t = q.popleft() # ←「调度」:选谁获得执行权
try:
next(t) # ←「切换」:交出 CPU,跑到下一个 yield 才回来
q.append(t) # 没跑完,排队尾
except StopIteration:
pass
这就是原文"完备的协程库可以理解为用户态的操作系统"的最简陋版本。内核完全不知道这几个协程存在——它只看见一个线程。切换一次 = 换个栈指针,连系统调用都不是,所以才 33 ns。
抢 vs 让:这条最要命
| 谁调度 | 怎么切 | 后果(实测) | |
|---|---|---|---|
| 进程 / 线程 | 操作系统 | 抢占式:时间片到了直接夺走,没得商量 | 死循环线程饿不死别人 |
| 协程 | 你的程序 | 协作式:必须自己 yield 交出来 | 贪心协程能饿死同线程所有人 |
关键判断:这条直接解释了原文"完备协程库必须包装几乎所有系统调用"的真正含义——**如果卡片上写着"在这儿站着等快递",员工就真的站那儿不动了,剩下 9999 张卡全部停摆。**所以 Go 必须把每个
read/write偷偷改写成"登记一下,然后放下这张卡去拿下一张"。**少包一个,就有一个协程能把整个线程拖死。**这才是"半吊子协程库"的致命处,不是缺功能那么轻描淡写。
"用户态线程"这名字怎么读
- “线程” —— 指用途:让你能写并发代码,用起来像线程
- “用户态” —— 指实现:由你的程序实现和调度,内核不参与
Go 的真实做法叫 M:N 调度:runtime 只开少量真线程(默认 = CPU 核数,本机是 10),把成千上万个 goroutine 分配到这几个线程上轮流跑。10 个员工,10 万张卡。
省下的钱从哪来(四个实测)
| 实测(本机 10 核 / Python 3.12) | 为什么 |
|---|---|
线程开到 6143 个就崩(can't start new thread) |
每个员工要一套工位、柜子、门禁(内核对象 + 512KB 栈) |
| 协程 1 万个只要 9.1 MB / 12 ms | 多一张卡就是多一张纸(Go 里 4KB 起步) |
| 协程切换 33 ns | 换张卡是自己手上的动作 |
| 线程切换 3218 ns(慢 97 倍) | 换个员工要惊动物业(进内核、走调度器) |
一个原文没讲的细节:崩在 6143 个线程时内存才用了 213 MB,远没吃光,平均每线程实际驻留仅 36 KB(栈是预留地址空间,页懒分配,没碰就不占)。所以天花板来自地址空间与内核对象,不只是内存。"空间成本是第一根稻草"要这样理解才准确。
顺带:这一节其实在解一道选择题
原文的逻辑链没明说,其实是:一台服务器要同时伺候 1 万个客户端,怎么写?
| 路线 | 怎么做 | 好写吗 | 撑得住吗 |
|---|---|---|---|
| A · 一连接一线程 | 同步 read/write,阻塞就阻塞 | ✅ | ❌ 6143 就崩 |
| B · epoll + 少量线程 | 异步回调 | ❌ 逻辑碎片化 | ✅ |
| C · 协程 | 看起来同步,实际不占线程 | ✅ | ✅ |
协程存在的全部理由,就是"我全都要"。
epoll 的形象理解 = 餐厅呼叫铃:同步 IO 是每张桌子站一个服务员(1000 桌要 1000 人);epoll 是桌上装呼叫铃,一个服务员坐中间,谁按铃他去谁那儿。它没让任何单次操作变快——正是原文说的"系统调用次数更多、内存拷贝也没减少",它只是让你不需要 1000 个服务员。
代价是这个服务员不能站着等,必须把"给这桌上菜"拆成一堆碎片:
# 路线 A:同步。逻辑是一条直线,一眼看懂
def handle(conn):
req = conn.read() # 等
user = db.query(req.uid) # 等
data = cache.get(user.key) # 等
conn.write(render(data))
# 路线 B:回调。同一件事被 IO 切成四段,逻辑碎了
def handle(conn):
conn.read(lambda req:
db.query(req.uid, lambda user:
cache.get(user.key, lambda data:
conn.write(render(data))))) # ← 出错处理往哪放?变量往哪存?
本该在栈上自然保存的状态,现在得你手动摊开来管。这就是"异步 IO 编程反人类、逻辑被回调碎片化"的准确含义。
goroutine 四条设计 = 四个坑各填一个
| goroutine 的设计 | 填的哪个坑 |
|---|---|
| 栈起步 4 KB、按需自动增长 | 堆栈死结:太小不够用、太大开不了百万个(4KB 比线程 512KB 小 128 倍) |
| 干掉 TLS | 砍掉执行体的固定开销 |
| 提供同步 / 互斥 / channel | 补上"半吊子"缺的通讯 |
| 包装几乎所有系统调用 | 补上最致命那条:绝不让一个协程拖垮整个线程 |
关键判断:Go 不是发明了协程,Go 是第一批把这四个坑全填上的(另一个是 Erlang)。这才是原文"这世界上有完备的协程库么?有,两个语言干了这事儿"的含义。
澄清:系统调用其实不慢
原文专门辟了这个谣,容易被跳过。慢的从来不是"进内核"这个动作,慢的是"因为没数据而阻塞、进而触发调度"。实测的 3218 ns 线程切换,贵在调度、不贵在系统调用。这也正好解释了为什么 epoll 明明系统调用次数更多却依然更快——它避开的是调度,不是系统调用。
一张总表
| 进程 | 线程 | 协程 | |
|---|---|---|---|
| 谁造的 | 操作系统 | 操作系统 | 你的程序 |
| 谁调度 | 操作系统(抢占) | 操作系统(抢占) | 你的程序(协作) |
| 内核知道吗 | 知道 | 知道 | 不知道 |
| 能独立跑吗 | 装线程的盒子 | ✅ 唯一能上 CPU 的 | ❌ 必须借线程 |
| 独占什么 | 地址空间、句柄表 | 寄存器、栈 | 寄存器、(小)栈 |
| 一个卡死 | 只死自己 | 不影响别人 | 可能拖垮整个线程 |
| 解决什么问题 | 我不信你 | 自己人共享数据 | 海量并发还想写同步代码 |
一句话:**进程是房子,线程是房子里的人,协程是人手上的任务卡。**只有人能干活;房子提供隔离;卡片让一个人看起来像在同时办一万件事——代价是卡片得自觉,谁赖着不放手,其他卡全等着。
架构师的批判性思维
原文这节看起来最像鸡汤,其实是全课的题眼:整篇文章都在示范一种读系统的方式,这一节才是把示范翻译成方法。
许式伟在这一讲之内骂了三层——骂 API(fork 是"最糟糕的 API,没有之一"、“设计事故”)、骂执行体(思考题直接问"线程的设计是否成功")、骂生态(大部分协程库"只是一个半吊子")。所以该问的不是"要不要批判性思维"(谁都知道要),而是:他到底怎么批的。
先接受一个结论:内核必然丑,这不是谁的错
原文最容易滑过去的是这句:“对 CPU 而言,统一的、接口一致的输入输出设备,到了操作系统这里,它需要依据每一种设备的需求特性,抽象出对应的更加用户友好的使用接口。”
| 面对的是 | 结果 | |
|---|---|---|
| 计算机体系结构 | 已被抹平的世界——所有 IO 设备在 CPU 眼里都长一样(几个寄存器,读、写) | 简洁优雅 |
| 操作系统内核 | 没被抹平的真实世界——键盘、磁盘、网卡、声卡、鼠标,每个需求都不同 | 庞大复杂 |
关键判断:一个系统丑不丑,很大程度上不取决于设计者的水平,而取决于它站在"抹平"的前面还是后面。你写的库很优雅,可能是因为把脏活留给了调用方;你维护的系统很丑,可能是因为它正在替所有人吃掉现实的复杂性。
由此推出一条日常可用的:"这破系统重写一遍肯定更漂亮"几乎永远是错觉——新系统还没开始吃现实。等它吃满五年,长得和旧的一样丑。
抽象是一次性下注,接口是永久负债
原文"这个工作既繁重,又需要极强的预见性"——"预见性"是这一节最重的三个字。抽象的残酷在于:你必须在需求还不存在的时候,替它决定形状。
fork 就是一次下注:
| 时间 | 状态 |
|---|---|
| 1975 | 下注:“新执行流需要继承父亲的一切”。当时唯一的执行体是进程,赌注赢了 |
| 1990s | 线程被发明,该需求被正当接管。赌注输了 |
| 今天 | 但已经收不回来了 |
第二重残酷:输掉的赌注不会被删除,只会被弃用。原文顺带提的"有一些执行体间的通讯机制在逐渐消亡,退出历史舞台"就是证据——System V 消息队列、信号量这类东西没人用了,但删不掉,还得在内核里养着。
两块化石(2026-08-28 本机实测):
| 化石 | 起因 | 今天的代价 |
|---|---|---|
Unix 文件名可含除 / 和 \0 外任意字符(含换行) |
1970s | 目录里明明 2 个文件,ls -1 和 find 都数成 3 行;for f in $(ls) 这类到处可见的写法全都有隐患。补救是 50 年后打的补丁:-print0 / xargs -0 / IFS= read -r -d '' |
HTTP 头 Referer 少拼一个 r(正确是 referrer) |
RFC 1945 (1996) | 发现时全世界已实现完毕,一改就一起崩。新标准只能绕着走:JS 里是 document.referrer(拼对),HTTP 头还是 Referer(拼错)。同一个概念两个拼法,程序员记一辈子 |
关键判断:**接口一旦发布,就从"设计"变成了"负债"。**这是架构师最该被吓到的一件事。
六步法:把他骂 fork 的过程拆开,这才是方法本身
原文没直说方法,但他自己完整演了一遍。逆向出来是六步:
| 步 | 动作 | 原文对应 |
|---|---|---|
| 1 | 先承认它解决了什么 | “这样创建进程很讨巧,不用传递一堆参数,使用上非常便利” |
| 2 | 找它违背了自己声称的原则 | “进程是最基本的隔离单元,我们怕的就是摘不清楚,但 fork 偏偏要藕断丝连” |
| 3 | 举反例,证明存在更好解 | “这一点 Windows 要清晰很多,哪些句柄要用一一明确点名” |
| 4 | 用真实工程经验校验 | “我个人那么多年工程经验表明,几乎从来不会通过传文件句柄来通讯” |
| 5 | 敢下判断 | “彻头彻尾的一次过度设计……严重一点说,是设计事故” |
| 6 | 回到历史现场,理解它为什么会那样 | “早期没有线程……进程实际上承担了一部分来自线程的需求” |
关键判断:第 1 步和第 6 步,是把"批判"和"抱怨"分开的东西。
第 4 步尤其值钱:**他是用"这个功能实际上没人用"否掉 fork 的,不是用"我觉得不优雅"。**一个接口设计得再有道理,如果十年里没人真的走那条路径,它就是过度设计——这是可验证、能拿数据说话的批判。
批判 vs 抱怨
| 抱怨 | 批判 | |
|---|---|---|
| 起手 | 这玩意儿真烂 | 它当初解决了什么问题 |
| 依据 | 我不爽 / 我不熟 | 它违背了自己声称的原则 |
| 有替代方案吗 | 没有,就是烂 | 有,而且指得出来(Windows、Go) |
| 承认约束吗 | 不承认 | 承认(当年根本没有线程可用) |
| 产出 | 情绪 | 一条能复用的判断 |
判断标准:你的批判能不能变成一条"下次我该怎么做"。
- ❌ “fork 很烂” → 没有任何用处
- ✅ “默认全继承是 default-unsafe;凡涉及权限传递的接口,都应该是白名单而非黑名单” → 明天就能用在自己的 API 上
许式伟骂 fork 骂了 500 字,真正的产出就是最后这一条;前面 490 字是论证过程,不是结论。
迁移:AI 应用架构正处在 1975 年
你现在做的事,和 1975 年设计 fork 的人处境完全一样。
Agent / Tool / Memory / Context / MCP——这些抽象全是最近两三年造出来的,全部处在"赌注已下、尚未揭晓"的阶段,而且已经开始有接口、有生态、有兼容包袱了。今天写下的每一个字段名,都在替三年后的人做决定。
六步法现在就能拿来拷问手上的东西:
| 用哪一步 | 拷问 |
|---|---|
| 第 2 步(违背自己声称的原则) | 一个号称"沙箱隔离"的 Agent 执行环境,是不是也在默认全继承环境变量、API Key、当前工作目录?——这就是 fork 的形状,只是换了层皮 |
| 第 4 步(实际被怎么用) | 给 Tool 定义了十个参数字段,真实调用日志里用到几个?没被用过的就是过度设计,趁还没人依赖赶紧删 |
| 第 6 步(回到历史现场) | 某个别扭的设计,是设计者蠢,还是当时模型上下文只有 8K、只能那么干?若是后者,今天该重新下注了 |
收口
你每天用的 Unix、HTTP、Python、Git,是人类历史上规模最大、被压力测试最久的一批架构决策——而且成败已经揭晓,代码还全部公开。这是免费的、无可替代的学习材料。
但它只对一种人免费:**用一个东西的时候,会问"它为什么是这样"的人。**绝大多数人一辈子只问"这个 API 怎么调"。区别就在这一个问题上。
总结
多任务需求复杂,催生了三套执行体。切换执行体本质都是保存/恢复一堆寄存器。进程是安全隔离单元(fork 是糟糕设计),线程是同地址空间的多任务,协程是为高性能网络服务器而生、可视作"用户态操作系统里的进程"。
先把"是什么"回答清楚
| 概念 | 一句话说明 |
|---|---|
| 执行体 | 可被 CPU 赋予执行权的对象,统称进程/线程/协程 |
| 分时系统 | 单核靠切时间片轮流执行,制造"同时运行"错觉 |
| 进程 | OS 安全隔离单位,最低授权 |
| fork | UNIX 创建子进程的 API,clone+分支,被批为最糟设计 |
| 线程 | 同一软件内、同地址空间、彼此可信的多任务 |
| 协程/goroutine | 可暂停、可续跑的函数;由你的程序自己调度,内核不知其存在,必须寄生在线程里 |
| 用户态线程 | 「线程」指用途(能写并发),「用户态」指实现(自己调度,内核不参与) |
| epoll/IOCP | 异步 IO 机制,真正价值在减少线程数 |
一句话速记
切换执行体 = 存恢复一堆寄存器;进程管隔离、线程管同地址空间多任务、协程为网络服务器回归同步 IO 并狂降成本,完备协程库(Go 的 goroutine)就是个用户态操作系统。
几条值得记住的判断
- 进程是"拥有资源"的单位,线程是"被调度执行"的单位;隔离与协作是一对矛盾,所以必须有两套执行体(Edge:10 进程 × 每进程数十线程)。
- 隔离不靠门口检查,靠根本走不到隔壁——同一个地址在不同进程里指向不同物理页。
- fork 是 UNIX 最糟糕的 API:隔离单元却藕断丝连。
- "系统调用很慢"是误解——它没发生调度,成本约等于函数调用。
- epoll/IOCP 的真正价值不是少系统调用,而是少线程。
- 线程的空间成本(堆栈)是第一根稻草;goroutine 用可增长小堆栈破局。
- 接口一旦发布就从「设计」变成「负债」:输掉的赌注不会被删除,只会被弃用(fork、
Referer拼写、System V IPC)。 - 系统丑不丑,看它站在「抹平」的前面还是后面——所以「重写一遍肯定更漂亮」几乎永远是错觉。
- 批判要能变成一条「下次我该怎么做」,否则只是抱怨。骂 fork 500 字,产出只有一条:权限传递的接口应是白名单而非黑名单。
- 协程不是与进程、线程并列的第三种执行体,而是被线程「装着」的:两个协程的进程号和线程号完全相同。
- 抢 vs 让:线程被操作系统抢占(死循环饿不死别人);协程靠自己 yield(贪心协程能饿死同线程所有人)。
——由此才理解「必须包装所有系统调用」有多要命:漏包一个,一个协程就能拖垮整条线程。
思考题
如果线程的设计足够优良,是否就不再需要 Go 这种在用户态重造执行体和 IO 子系统?UNIX 的 fork、线程的设计是否成功?把你对这几个执行体设计成败的判断写下来。
自留题:拿「六步法」拷问一遍自己写的 Agent / Tool 接口——
哪个字段真实调用里从没被用过(第 4 步)?哪个「隔离」其实在默认全继承(第 2 步)?



