第19讲 · 协程、Goroutine 与异步编程:轻量级并发的两条路
2026 南京大学《操作系统原理》第十九讲 · 课堂实录重构
| 主讲 | 蒋炎岩(Yanyan Jiang) |
| 视频 | https://www.bilibili.com/video/BV1dVRdBpEze/ |
| 课程主页 | https://jyywiki.cn/OS/2026/ |
| 本讲讲义 | https://jyywiki.cn/OS/2026/lect19.md |
| 讲义 / 幻灯片作者 | 蒋炎岩,https://jyywiki.cn/ |
| 讲义 / 幻灯片许可 | CC BY-NC 4.0 |
| 图片来源 | 书中插图为课堂演示与讲义渲染图;演示类截图为视频画面 |
对应讲次:第19讲(南京大学 操作系统原理)
制作模型:deepseek-flash(coding-agent / pi;据 ~/.pi/agent/settings.json)
编者说明:本书由课堂录像的语音字幕重构而成,术语、章节骨架与参考链接已按课程官方讲义(
lect19.md)校准。讲师的口语风格、类比与案例被尽量保留;编辑性的补充一律以「编者说明」或「编者拓展」标出。
引子:从 malloc 到「线程的代价」
(参考时间: 00:00)
前面几讲都在展开计算图这个核心概念。在讲并发之前,讲师先收尾了 malloc/free(它也是作业):
- 「脱离 workload 做优化就是耍流氓。」 实际系统不像算法题那样只看最坏情况,而是要在真实的概率分布下表现好(同时别让最坏情况太坏——否则会有复杂性攻击:精心构造的 key 全映射到哈希表同一个桶)。
- 对 malloc 的观察:大对象分配后,读写数量应当远大于它的大小(申请 16MB 只扫一遍就释放,就是 performance bug)。推论:越小的对象创建/分配越频繁——「几乎只要管好小对象就好了」,而小对象分配的 scalability 是主要瓶颈(和
sum++一样)。 - Fast/Slow Paths + Segregated Lists(Slab):每个 slab 里的对象一样大,每个线程本地持有一些 slabs;fast path 直接在线程本地分配完成(甚至没有全局的读者),slow path 才
mmap();回收是 O(1)。
编者拓展|1995 年的 malloc 综述:Segregated free lists 早在 1964 年就提出了。讲师引用了 Dynamic Storage Allocation: A Survey and Critical Review 里的一段话:「理解真实的程序行为,永远是建立内存管理理论的第一步。」「停止无意义的『科研实践』,去做真正有价值的事情。」
📷 Slab:每个线程本地持有等大对象的空闲链表,分配/回收 O(1)
回到原视频 (00:07:30)
一、线程的代价
(参考时间: 00:13)
线程不是免费的:
- 内存:线程栈(默认 8 MB,按需分配)、操作系统里各种数据结构(
/proc/[pid]/下的文件都是内核数据结构的投射); - 资源:pid(用完会循环);
- 切换开销:线程切换要进入内核,保存/恢复寄存器现场。
「我上了这么多年课,甚至都没有提过这个问题」——Linux 下一个线程到底占多少资源?
📷 创建大量线程、统计系统资源变化,估算单线程开销
回到原视频 (00:16:00)
讲师让 AI 写了一个脚本:创建 1 万个线程,统计系统资源的变化再除以线程数——算出每个线程约 16.9 KB(内核数据结构 + 栈的按需部分)。
📷 实测:Linux 下一个(最小)线程约占 16.9 KB 系统资源
回到原视频 (00:18:30)
编者说明:以前「搞清楚这件事很麻烦、收益也不大」,所以大家「凭本能」接受线程有代价。「但在今天,你想搞清楚任何东西,它的收益都是值得的。」
我们真正想要的:计算图想怎么写就怎么写,而几乎没有任何额外开销——就像写函数调用一样。这引出两条路:
| 方案 | 做法 | 例子 |
|---|---|---|
| I. 线程轻量化 | 让 spawn/join 的代价接近函数调用 |
Coroutine / goroutine |
| II. 异步编程 | 改变执行模型,用代码描述计算图 | Promise/Future、async/await |
# 方案 I
t1 = spawn(f); t2 = spawn(g); t3 = spawn(h);
join(t1); join(t2); join(t3);
# 方案 II
j1 = enqueue_job(f); j2 = enqueue_job(g); j3 = enqueue_job(h);
wait_job_complete(j1); wait_job_complete(j2); wait_job_complete(j3);
二、方案一:线程的轻量化(协程)
2.1 在用户空间实现「线程」
(参考时间: 00:25)
放弃中断驱动的上下文切换,只在主动 yield 时切换——「多线程 SimpleC 的直接实现」。只要编译器能封存函数的运行时状态(类似闭包),就能做到:
- Python generator、C++20 coroutine 都是无栈的实现;
- 也可以有栈:用
malloc分配栈空间,用一段汇编(x86-64/aarch64 都行)把栈顶指针挪到另一个栈上——「没有人能阻止我」。用setjmp/longjmp保存寄存器现场并跳回去,就实现了协程切换。
threads = [T_worker(i) for i in range(1000000)]
while True:
random.choice(threads).send(None)
📷 Python generator:一个线程里维护 100 万个执行流(协程)
回到原视频 (00:27:00)
📷 100 万个协程并发运行:切换是「主动」的,不进入操作系统
回到原视频 (00:30:30)
📷 C 版协程:setjmp/longjmp 保存现场 + 汇编切换栈指针(AI 生成的汇编写法)
回到原视频 (00:33:00)
编者拓展|无栈协程的本质是「闭包」:任何能实现闭包的编程语言,都能在函数执行到某处时把当前作用域的变量放进内存、记下 PC,从而「封存」函数状态。C++20 coroutine 和 Python generator 都是无栈实现;而讲师那个 C 实现真的为每个线程分配了栈。
2.2 协程的致命缺点
(参考时间: 00:35)
「一个协程等待,一百万個都等待。」 因为操作系统里只有一个执行流:
- 阻塞系统调用会卡住所有协程:T1 执行
read进入内核睡眠,T2 明明可以继续计算,却也跟着等; mutex_lock+yield= AA 型死锁:T1 拿走锁后yield,T3 再lock就拿不到钥匙 → 进入内核等待 → 因为只有一个操作系统线程,整个系统死锁。
📷 mutex + yield:一个协程等待,全部阻塞(AA 死锁)
回到原视频 (00:37:00)
编者说明:所以「你不能在协程里使用互斥锁」——互斥锁是操作系统为线程设计的。要解决这个问题,需要异步 I/O。
2.3 异步 I/O:把「等待」变成「可调度」
(参考时间: 00:38)
man 2 open:O_NONBLOCK——系统调用不与 I/O 完成同步;read可能返回nread或者EAGAIN,这时就可以yield了;man 7 epoll:可以同时监听大量文件描述符,任何一个有数据就返回——「可以像信号量一样同时 P 好多个信号量」;- 还有
eventfd、timerfd、io_uring……「设计成 fd 就可以被 epoll 监听了!」(fd 是操作系统对象,不受「每个线程一个 mutex」的限制)。
站在操作系统设计者的视角,这些设计就很自然了。
📷 O_NONBLOCK + EAGAIN:让协程在等待时能切换出去
回到原视频 (00:40:00)
📷 epoll:同时监听大量 fd,谁来我就继续
回到原视频 (00:41:00)
2.4 一个「编程语言 Trick」:恭喜你发明了 goroutine
(参考时间: 00:43)
「你不想学新 API」——那就让编译器帮你 hack。程序员脑子里最舒服的是线程 + blocking I/O:
sleep(1); // 编译器改写为:
// put_my_self_into_sleep(1); yield();
read(fd, buf, size); // 编译器改写为:
// while (read_async(fd, buf, size) == -EAGAIN) yield();
代码完全和写线程一样,但被编译器变成了高性能的轻量级线程。「恭喜你,你发明了 goroutine!」
goroutine 的结构:
- 每个 CPU 上有一个 Go Worker Thread(真正的操作系统线程),运行协程调度器;
- 每个 worker 循环从可运行队列里取一个 goroutine 执行;
- 遇到阻塞(
read没数据)就把它移出调度队列、标记它等待的 fd;调度器用 epoll 监听所有 fd,就绪了再放回队列。
graph TD
W1["Go Worker Thread 1<br>(OS 线程)"] --> Q["可运行队列<br>(一堆 goroutine)"]
W2["Go Worker Thread 2"] --> Q
Q -->|"取出并执行"| G["goroutine"]
G -->|"阻塞(read EAGAIN)"| E["移出队列,用 epoll 监听 fd"]
E -->|"fd 就绪"| Q
「像线程一样使用,像协程一样轻量」——而且它真的能跨 CPU 并行(不像纯用户态协程只能在一个 OS 线程上)。
📷 goroutine:多个 worker 线程 + 调度队列 + epoll 监听阻塞事件
回到原视频 (00:46:00)
2.5 Go 的哲学:用通道通信
Do not communicate by sharing memory; instead, share memory by communicating. —— Effective Go
共享内存是万恶之源:信号量/条件变量实现了同步,但数据传递完全靠手工(没上锁就错了)。
UNIX 时代早就有一个漂亮的机制:管道!
(cat *.txt; cat *.cpp) | wc -l
「这不就是一个计算图、生产者/消费者同步吗?」Go 的 channel 就是管道——把「同步 + 通信」合二为一。「Golang 真正继承了 UNIX Philosophy」——因为它的发明人是 Rob Pike、Ken Thompson、Russ Cox。
📷 Mandelbrot-Go:goroutine 按行分块计算,用 channel 汇报完成,monitor 用 select 监听
回到原视频 (00:52:00)
编者说明:讲师现场运行了 Go 版 Mandelbrot,并发现 AI 写的代码有一点小 bug——「AI 给我写代码是有 bug 的」。这很真实。
三、方案二:描述计算图(JavaScript 世界线)
3.1 另一条世界线:1995 年的 10 天
(参考时间: 00:54)
1995 年,Brendan Eich 加入 Netscape,被委以重任设计网页脚本语言。他花了 10 天,糅合了 C、Java、Scheme、Self——「人类命运的齿轮开始转动」。有意思的是他的主要兴趣是函数式编程,所以把 function is first class citizen 放进了 JavaScript。
于是有了一个「坑很多」的语言:
this是调用时动态绑定的(和 C++/Python/Java 完全不同,早期 React 要bind(this));- 行为诡异的类型转换(
[] == 0、'0' == false但[] != '0'……); - 于是有了那句调侃:JavaScript: The Good Parts 只有薄薄一本。
3.2 从 Web 1.0 到 Web 2.0
(参考时间: 00:56)
- HTTP 比想象的简单:就是一段文本请求、返回一段文本(
curl -i就能看);中国的初代三巨头(新浪、搜狐、网易)都诞生在这个时代;网页靠<font>、<table>和「切图工程师」撑起; - 1999 年,XMLHttpRequest(后称 AJAX,你没看错,是 XML 不是 JSON——因为当时 Java 后端广泛使用 XML)——网页终于可以「后台刷新」,随时请求后端、更新 DOM 树(MVC 分离);
- 2006 年,jQuery
$`:**A DOM Query Language**(现代浏览器里 `$就是document.querySelector)。
📷 Web 1.0:Yahoo 时代的网页(table 布局、切图)
回到原视频 (00:57:30)
📷 modernize.js:一段脚本把「土味页面」瞬间改造成现代网页
回到原视频 (01:03:30)
编者说明:
HTML + CSS + jQuery 构建应用的方便程度,远超过传统 GUI 编程(GTK、Qt、MFC 谁用谁知道)——以至于 Google 想用 ChromeOS(底层 Linux,暴露给你的就是一个浏览器)做操作系统。「虽然 ChromeOS 没成功,但微信小程序继承了它的遗志。」
3.3 JavaScript 的并发模型:事件 + 回调
(参考时间: 01:05)
JS 是零门槛的语言(HTML 里写一行就能跑),不能让用户去写线程/协程(data race、atomicity violation 分分钟教你做人)。于是 JS 选了基于事件的并发模型:
- 永远不与任何东西并行,没有任何 Blocking I/O;按顺序执行所有 event handler(run to completion);
- 网络请求、
sleep都只是往队列里插入一个新的事件(计算图中的一个动态节点)。
$.ajax({
url: '/api/user',
success: function(user) {
$.ajax({
url: `/api/user/${user.id}/friends`,
success: function(friends) {
1{friends[0].id}`, ...});
}, error: function(err) { ... }
});
}, ...
});
这描述了计算图(success / error 两条分支),但写起来是 「Callback hell(回调地狱)」——一个 A → B → C 的顺序逻辑,被硬生生拆成几个函数、层层缩进。
📷 事件编程 + 回调:run-to-completion,计算图动态生成
回到原视频 (01:08:00)
3.4 Promise 与 async/await
(参考时间: 01:11)
Promise(「承诺/契约」):生成「未来将会完成」的计算图节点。
Promise.all([
fetch(...).then(...), fetch(...).then(...), ...
]).then(/* succeeded */).catch(/* error handling */)
fetch立即返回一个 Promise(状态 Pending),未来会变成 Fulfilled;Promise.all()会立即返回一个新的 Promise——它是现成的join:.then后面的节点 happens-after 这些 Promise。
但 Promise.then 还是没解决顺序逻辑断裂。于是有了 async/await 语法糖:同步的写法表达异步的流程,剩下的让编译器去头疼。
async function fetchData(token) {
const response = await fetch(`/api/submissions/?token=${token}`);
return response.json();
}
await Promise.all([fetchData('1234'), fetchData('5678')]);
编者拓展|async/await 的「编译器魔法」:
async function f()→function f() { return new Promise(...) };await f()→Promise.resolve(f()).then(...)。编译器把await后面的代码一层层塞进.then,生成的代码其实就是那个「回调地狱」——只是你在写的时候保持了顺序逻辑。「编译器/编程语言设计在我,有什么办不到的呢?」
📷 Promise:用 `.then` / `.all` / `.catch` 在代码里「画」计算图
回到原视频 (01:13:30)
📷 async/await:同步的写法表达异步流程,编译成链式 Promise
回到原视频 (01:19:00)
3.5 从前端到全栈:另一个应用生态
(参考时间: 01:19)
ES6(ECMAScript 2015) 一统第三方库「军阀混战」的局面,开源生态起飞。于是操作系统之上又长出了另一个应用生态:
- 前端:Angular、React、Vue、Bootstrap、Tailwindcss;
- 后端:Express、Next、Nest;
- 桌面:Electron 和 VS Code;Ink 和 Claude Code(用 React 写命令行界面!);React Native;
- 底层:
asm.js和 WebAssembly(在浏览器里跑 C/C++); - 图形/科学:Mermaid、TensorFlow.js、Three.js……
总结
- 性能优化必须先理解 workload:
malloc的观察(小对象最频繁)→ Slab/Segregated Lists;脱离 workload 做优化就是耍流氓。 - 线程是有代价的:栈(默认 8MB)、内核数据结构、pid、切换开销(实测约 16.9 KB/线程)。我们想要像写函数调用一样描述计算图。
- 方案一:线程轻量化(协程 / goroutine)
- 用户态实现:
setjmp/longjmp+ 栈切换;无栈协程(闭包/generator); - 缺点:一个协程阻塞,全部阻塞;
mutex+yield= AA 死锁; - 解决:异步 I/O(
O_NONBLOCK/EAGAIN、epoll、eventfd/timerfd/io_uring,都是 fd); - 编程语言 Trick:编译器把
sleep/read改写成yield+ 异步 → goroutine(多 worker 线程 + 调度队列 + epoll)。 - Go 的哲学:
Do not communicate by sharing memory; instead, share memory by communicating——用 channel(管道) 把同步与通信合一,真正继承 UNIX Philosophy。 - 方案二:在语言里描述计算图(JavaScript)
- 历史:10 天设计的 JS → Web 1.0(HTTP/table)→ AJAX(1999)→ jQuery(2006)→ ChromeOS;
- 并发模型:事件驱动、run-to-completion、无 Blocking I/O;回调 → 回调地狱 → Promise → async/await;
- async/await 本质上是编译器把顺序逻辑翻译成链式 Promise。
- 两条路殊途同归:都在把「计算图」以对人类舒适的方式表达出来——一条从线程轻量化(Go),一条从编程语言描述(JS)。
附:官方参考与延伸阅读
本讲的讲义见课程主页,以下链接按书中出现顺序整理:
| 主题 | 链接 |
|---|---|
| 线程的代价(thread-cost) | https://jyywiki.cn/OS/demos/concurrency/thread-cost |
| 轻量级线程的实现(coroutine) | https://jyywiki.cn/OS/demos/concurrency/coroutine |
| Mandelbrot-Go | https://jyywiki.cn/OS/demos/concurrency/mandelbrot-go |
| Web 和事件编程(web) | https://jyywiki.cn/OS/demos/concurrency/web |
| Wilson et al., Dynamic Storage Allocation: A Survey | http://jyywiki.cn/OS/manuals/malloc-survey.pdf |
| Effective Go(Go 编程规范) | https://go.dev/doc/effective_go |
| JavaScript: The Good Parts | https://www.oreilly.com/library/view/javascript-the-good/9780596517748/ |
| HTTP 的演进(MDN) | https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Evolution_of_HTTP |
| Ink(用 React 写命令行) | https://github.com/vadimdemedes/ink |
| WebAssembly | https://webassembly.org/docs/faq/ |
| OSTEP:Operating Systems: Three Easy Pieces | https://pages.cs.wisc.edu/~remzi/OSTEP/ |
epoll(7) / open(2)(O_NONBLOCK) |
https://man7.org/linux/man-pages/man7/epoll.7.html |
io_uring |
https://man7.org/linux/man-pages/man7/io_uring.7.html |
阅读材料:在 AI 的帮助下阅读 malloc 综述、epoll(7) 手册;如有兴趣,可看 Effective Go 体会 Go 的并发哲学。
版权说明:本讲讲义与幻灯片作者为蒋炎岩,采用 Creative Commons BY-NC 4.0 许可,来源于 https://jyywiki.cn/OS/2026/。视频画面与字幕版权归原作者所有。本书为课堂内容的学习性重构。