加载中...

本篇要回答的问题:怎么实现多任务?为什么会演化出进程、线程、协程三套执行体?它们到底有何不同、各自为何而来?

前面把"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 人
WeChat 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 请求,标准网络 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 -1find 都数成 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 步)?

公告栏
这是我的个人知识库。
记录技术,也记录生活 —— 读过的、试过的、想明白的,都堆在这儿。
最新文章
网站资讯
文章数目 :
5
已运行时间 :
本站总字数 :
15.7k
本站访客数 :
本站总访问量 :
最后更新时间 :
全局知识图谱
当前页面 已访问 文章 标签
ESC 关闭 · 滚轮缩放 · 拖拽移动 · Ctrl+G 开关