进程、线程、协程的对比表到处都是:切换开销几个量级、抢占式还是协作式、能不能吃满多核。结论背下来很容易,但过一阵就忘,因为不知道它们从哪来。
这篇文章换一种思路:从 CPU 开始,把底层机制一块块搭起来——寄存器、内存、栈、中断、页表。搭完之后你会发现,进程、线程、协程的所有特性,全是这些机制的自然结果,没有一条需要背。
一、CPU 只会做一件事
CPU 的工作方式简单到让人失望:从内存取出一条指令,执行,再取下一条。每秒几十亿次,如此而已。它不理解你的程序,只是盲目执行一串字节。
那它怎么知道下一条指令在哪?靠一个专用寄存器:程序计数器,x86-64 上叫 rip。它存着下一条指令的地址。普通指令执行完,rip 自动指向下一条;跳转指令则直接改写 rip。if、for、函数调用,编译到最后统统是”改 rip”。
寄存器是 CPU 内部十几坨超高速存储格,可以分成两类记:
- 通用寄存器(
rax、rdi等):草稿纸,放正在计算的数据; - 专用寄存器:
rip(下一条指令在哪)、rsp(栈顶在哪)、rflags(上次运算的标志位)。
这里有一个贯穿全文的观察:所谓”一条执行流”,本质就是一套寄存器的值。 正在算的东西在通用寄存器里,跑到哪了在 rip 里,栈用到哪了在 rsp 里。内存里的数据不会自己跑掉,真正易失的现场只有寄存器。
所以暂停一条执行流,只需要把它的寄存器值拍照存下来;恢复时原样写回去,CPU 看一眼 rip 就从断点继续——这条执行流对”自己被暂停过”毫无知觉。游戏存档读档,就是这么回事。
一个 CPU 核只有一套寄存器,所以一个核同一时刻只能跑一条执行流。并发的全部秘密,就是在多套”存档”之间来回倒腾,一秒倒几百次,每个程序都以为自己独占 CPU。
二、内存是一排柜子
内存没有任何神秘之处:一排格子,每格 1 字节(8 个二进制位),每格有个全系统唯一的编号,这个编号就是地址。所谓”访问内存”,就是”往某编号的格子里读或写若干字节”。
地址常用十六进制写(0x 开头),原因很实际:1 字节恰好等于两位十六进制。看到 0x7ffe37c2a9b0 不用算它是多少,知道它是格子的编号就行。
在这幅图景下,几个”高级”概念瞬间祛魅:
变量。int x = 1000 做的事情:申请 4 个格子,起名叫 x,往里放 1000。这个名字是给程序员看的,编译之后就不存在了——机器只认”0x7ffc10 起的 4 字节”。
数组。int a[5] 是 20 个连续格子,a[2] 被翻译成”起始地址 + 2×4”。计算机内部没有”数组”,只有地址加法。顺便也解释了为什么数组下标从 0 开始:a[0] 不需要任何偏移。而 C 语言的数组越界之所以是灾难,就是因为 a[5] 会老老实实写进旁边别人的格子,没人拦。
指针。一个变量,里面存的值是另一个格子的编号。*p 就是”拿 p 里的数字当地址去访问”。没有更多了。
代码。这是最容易被忽略的一点:你编译出的机器指令也是字节,加载程序时同样被搬进内存。函数名在机器层面就是它第一条指令的地址——“调用函数”约等于”把 rip 改成那个地址”。
一个程序加载进内存后大致长这样:
高地址 │ 栈(函数调用、局部变量) │ 向下生长 │ │ │ 堆(动态分配,对象都在这) │ 向上生长 │ 数据段(全局变量) │低地址 │ 代码段(你的函数们,指令字节)│回头看 rip:它存的地址,就是代码段里的某个位置。执行流的”灵魂”一直在这一片格子里移动。
三、栈:函数调用的现场
现在 main 要调用 add(3, 4),rip 即将跳过去。想三秒就会发现问题:add 执行完,怎么知道该跳回 main 的哪一行?rip 已经被改掉了,“回来的地址”存在哪?还有,3 和 4 怎么传给 add,add 的局部变量放在哪才不会和 main 的互相覆盖?
答案全是一个数据结构:栈。
栈是内存里划出来的一块区域,规则只有一条:后进先出。rsp 寄存器永远指向栈顶。函数调用靠一对天生的搭档指令:
call 函数地址:把返回地址(call 的下一条指令的地址)压入栈,然后跳转;ret:从栈顶弹出一个地址,跳回去。
“回来去哪”的问题解决了——返回地址就明明白白躺在栈上。
函数还需要地方放参数和局部变量。放堆上不行,堆的申请释放太慢,而函数调用每秒发生百万次。解决办法:进入函数时把 rsp 往下移,腾出一块临时区域,叫栈帧;返回时 rsp 移回去,这块区域就自动作废。分配和释放一个栈帧,只是移动一个寄存器,近乎免费。
main → add → printf 嵌套调用时,三个栈帧一层层往下摞,每层的帧里都存着”回上一层”的地址,像面包屑。递归也一样,每层递归压一个新帧——这同时暴露了递归的代价:栈区域是有限的,无限递归会把栈撑爆,操作系统直接终止程序,这就是 stack overflow。
有个细节值得注意:函数返回后,栈帧里的数据并没有被清空,只是成了无主之地,下次任何函数调用都会覆盖它。C 语言里”返回局部变量的地址”是经典 bug,原因就在这——地址还有效,但指向的内容随时会被下一个调用者覆盖。
到这里可以把执行流的定义修正完整了:
执行流 = 一套寄存器 + 一块栈。
寄存器是”正在算什么、跑到哪了”,栈是”我是被谁调用的、我的局部变量是什么、我回谁那儿去”。两者合起来,才是一条完整可暂停、可恢复的执行流。
由此立刻推出一个重要结论:两条执行流绝不能共用一块栈。 如果线程 A 正在三层函数调用中,线程 B 也从同一个栈顶压帧,B 的帧会直接覆盖 A 的返回地址。A 恢复运行时 ret 弹出的是 B 的数据,rip 跳向垃圾地址,当场崩溃。
所以每个线程必须有自己独立的栈。Linux 给每条线程默认预留 8 MB 栈空间,开一万条线程就是几十 GB 的地址空间——这就是”线程开不多”的物理原因。记住这个数字,后面协程的部分要用。
四、中断:操作系统的夺权机制
前面攒齐了零件:执行流 = 寄存器 + 栈,暂停恢复 = 存档读档。但还有一个权力问题:存档读档这个动作,谁来执行?
CPU 同一时刻只能执行一条指令流。你的程序正在跑,就没有别人能插手——除非有个第三方能把 CPU 从你手里夺走。这个能力是硬件给的,叫中断。
机制大致是:CPU 每执行完一条指令,就检查一下有没有中断信号(键盘按下、网卡收到数据、定时器到点)。有的话,硬件自动把现场存起来,跳转到操作系统预先登记的处理代码,处理完再回来继续,毫无痕迹。中断是硬件强制的,程序无法拒绝。
其中最重要的是时钟中断:主板上的定时器每隔几毫秒拉一次铃。这意味着操作系统每隔几毫秒必然获得一次 CPU 控制权,风雨无阻。每次拿到控制权,它都可以决定:换人,还是继续?这个决定加换人的动作,就是调度。单核机器上”同时运行”上百个程序的真相,就是每个程序跑几毫秒就被打断,极速轮流,快到人类无感。
另一半权力问题靠 CPU 的特权级解决。CPU 有两种运行模式:
| 内核态 | 用户态 | |
|---|---|---|
| 谁在跑 | 操作系统内核 | 你的程序 |
| 能做什么 | 一切,包括特权指令 | 特权指令一律禁止,碰了就报错 |
内核别神化,它也是个程序,开机时常驻内存。区别只有一个:它以内核态跑,你的程序以用户态跑。
那用户程序要读文件、发网络包怎么办?唯一合法途径是系统调用:把参数装进寄存器,执行 syscall 指令,CPU 受控地切到内核态,内核办完事再把结果放回来。一次普通函数调用只要一两纳秒,一次系统调用起步上百纳秒——两次模式切换加现场保存,不便宜。这就是高性能服务都在拼命减少系统调用次数的原因。
现在可以解剖一次完整的线程切换了:
时钟中断,内核接管① 把当前线程的整套寄存器抄进内存中它的小本本② 调度器挑出下一个线程③ 把下一个线程的小本本写回寄存器 (rsp 换了,栈就跟着换了)④ 返回用户态,CPU 看 rip,继续跑下一个线程线程切换,就是把第一节说的存档和第三节的换栈,由内核在时钟中断里执行一遍。没有魔法。
对比之下,协程切换只是用户态的几条普通指令:存几个寄存器,改一下 rsp,跳转。不触发中断,不进内核,操作系统根本不知道发生过什么。所以它比线程切换快上百倍,但也因此得不到操作系统的任何帮助——这个代价后面会说。
最后是抢占式调度的准确含义:操作系统可以在你的任意两条机器指令之间打断你,什么时候切你说了不算。这直接解释了竞态条件为什么存在。balance = balance + 100 是三条指令:读进寄存器、加、写回。线程 A 读完还没写回就被切走,线程 B 完整跑完写回 1100,A 回来基于手里的旧值 1000 再算出 1100 写回——B 的更新凭空消失。共享内存加上随时可能发生的打断,竞态条件就不是意外,而是必然。锁的底层,就是用 CPU 的原子指令把”读-改-写”变成不可分割的一步,让打断无从插入。
五、虚拟内存:每个进程一套假地址
还剩最后一个问题:程序里用的地址,如果就是真实的物理地址,两个程序都想从 0x1000 开始加载怎么办?A 的野指针写到 B 的地盘怎么办?
答案是计算机史上最精妙的发明之一:从今天起,程序里出现的所有地址都是假的(虚拟地址),由 CPU 里的 MMU 负责翻译成真实地址。每个进程有自己的一张翻译表(页表),都以为自己独占从 0 开始的一整片空间。 A 和 B 都用 0x1000,被翻译到不同的物理格子,相安无事。
翻译按页进行,一页 4 KB。页表是棵多级的树,没用到的区域整棵子树都不用存在。有几个细节:
- 页表里除了映射关系,还有权限位(可读/可写/可执行)。往代码段写?缺页异常。这就是隔离的第二道防线;
- 页表由内核管理。
malloc(100MB)成功时物理内存一分没动,只是记了账,真正写入时才按需分配; fork()复制进程能瞬间完成,靠的是写时复制:子进程的页表先指向同样的物理页,谁真的要写才复制一份;- 两个进程的页表里可以故意映射同一个物理页,这就是共享内存——最快的进程间通信方式。
于是进程隔离的本质浮出水面:不是”禁止访问别人”,而是”无路可走”。 A 的页表里根本不存在任何指向 B 内存的条目,它猜任何地址过去都翻译不通,直接触发缺页异常被内核终止——你熟悉的段错误。Chrome 每个标签页一个进程,买的就是这份隔离。
最后一个角色:TLB。查页表要走多级树,最多四次内存访问,太慢。CPU 里有个专门的缓存记下最近用过的”虚拟页 → 物理页”翻译结果,命中率 99% 以上,是虚拟内存能实用的命门。
而进程切换最痛的一刀正落在这里。切线程不换页表(同一个进程,共享一套),TLB 完整保留;切进程要换页表(CR3 寄存器指向新表),TLB 里全是旧进程的映射,必须整体作废。新进程接下来一段时间里每次访存都要慢腾腾地重新查表,直到 TLB 重新填热。这份余震才是进程切换最大的开销。
至此,那条著名结论可以推导出来了:
| 切换时换什么 | TLB | 开销量级 | |
|---|---|---|---|
| 协程 | 几个寄存器 + rsp | 无关 | 纳秒级 |
| 线程 | 全套寄存器 + 栈(进内核) | 保留 | 微秒级 |
| 进程 | 全套寄存器 + 栈 + 页表 | 作废 | 微秒级起,余震更久 |
六、回到最初的问题
现在把五块拼图合上,重新看那三个词。
进程 = 一张页表 + 一份资源清单。 它划定领土边界(隔离的来源),记录打开的文件等家当。进程自己不会跑——跑的从来是线程。
线程 = 一套寄存器快照 + 一块栈,共享所在进程的页表。 “线程在运行”就是某个核的寄存器里装着它的快照;“线程在等待”就是快照躺在内存里排队。共享同一套页表,所以线程间通信就是直接读写,代价是失去隔离——一个线程的野指针能写坏整个进程,砸的是所有人共享的家。
协程 = 用户态自管的执行流。 语言运行时自己造栈(Go 的 goroutine 初始 2 KB,按需增长)或者干脆用几百字节的状态机(async/await),自己定切换点,内核全程不知情。轻(栈小,百万个无压力)和快(切换不进内核)都来自这份”不知情”,代价也同样来自它:内核看不见协程,只看得见线程。一处阻塞的系统调用会挂起整个线程,这个线程上其余九千九百个协程集体陪葬——所以协程库的一切 I/O 必须是非阻塞的,async 生态的”传染性”也源于此。
几个老问题现在也都能自己回答了:
协程为什么不能吃满多核? 内核按线程分配 CPU 核,它看不见协程。单线程事件循环再厉害也只有一个核。Go 的做法是运行时把百万 goroutine 铺到多个内核线程上,鱼与熊掌兼得。
Python 的 GIL? 一把进程级的大锁:任何线程要执行 Python 字节码都得先拿它,于是多线程在字节码层面串行。线程本身是真的内核级线程,等 I/O 时会释放 GIL,所以 I/O 密集用多线程或 asyncio 都行,CPU 密集只能多进程——各进程有各自的 GIL,才能真并行。
怎么选型? CPU 密集要并行,用多进程(Python)或多线程(无 GIL 语言);海量并发 I/O 用协程;并发量不大的 I/O,多线程足够还不用改代码;需要故障隔离,上多进程。
写在最后
整条因果链其实就一句话:
寄存器定义了执行流,栈让它能调用函数,中断让操作系统能夺权调度,页表划出了每个进程的国界——进程、线程、协程,只是这套机制上三种不同粒度的打包方式。
想继续深挖的话,两本书正好接在这条链的末端:《深入理解计算机系统》(CSAPP)把前半段展开成严谨的体系,《操作系统导论》(OSTEP)把后半段展开成整本书。有这篇的底子在,读它们会比想象中顺利。