第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(它也是作业):

编者拓展|1995 年的 malloc 综述:Segregated free lists 早在 1964 年就提出了。讲师引用了 Dynamic Storage Allocation: A Survey and Critical Review 里的一段话:「理解真实的程序行为,永远是建立内存管理理论的第一步。」「停止无意义的『科研实践』,去做真正有价值的事情。」

Slab:每个线程本地持有等大对象的空闲链表,分配/回收 O(1)

📷 Slab:每个线程本地持有等大对象的空闲链表,分配/回收 O(1)
回到原视频 (00:07:30)


一、线程的代价

(参考时间: 00:13)

线程不是免费的:

「我上了这么多年课,甚至都没有提过这个问题」——Linux 下一个线程到底占多少资源?

创建大量线程、统计系统资源变化,估算单线程开销

📷 创建大量线程、统计系统资源变化,估算单线程开销
回到原视频 (00:16:00)

讲师让 AI 写了一个脚本:创建 1 万个线程,统计系统资源的变化再除以线程数——算出每个线程约 16.9 KB(内核数据结构 + 栈的按需部分)。

实测:Linux 下一个(最小)线程约占 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 的直接实现」。只要编译器能封存函数的运行时状态(类似闭包),就能做到:

threads = [T_worker(i) for i in range(1000000)]
while True:
    random.choice(threads).send(None)
Python generator:一个线程里维护 100 万个执行流(协程)

📷 Python generator:一个线程里维护 100 万个执行流(协程)
回到原视频 (00:27:00)

100 万个协程并发运行:切换是「主动」的,不进入操作系统

📷 100 万个协程并发运行:切换是「主动」的,不进入操作系统
回到原视频 (00:30:30)

C 版协程:setjmp/longjmp 保存现场 + 汇编切换栈指针(AI 生成的汇编写法)

📷 C 版协程:setjmp/longjmp 保存现场 + 汇编切换栈指针(AI 生成的汇编写法)
回到原视频 (00:33:00)

编者拓展|无栈协程的本质是「闭包」:任何能实现闭包的编程语言,都能在函数执行到某处时把当前作用域的变量放进内存、记下 PC,从而「封存」函数状态。C++20 coroutine 和 Python generator 都是无栈实现;而讲师那个 C 实现真的为每个线程分配了栈。

2.2 协程的致命缺点

(参考时间: 00:35)

「一个协程等待,一百万個都等待。」 因为操作系统里只有一个执行流:

mutex + yield:一个协程等待,全部阻塞(AA 死锁)

📷 mutex + yield:一个协程等待,全部阻塞(AA 死锁)
回到原视频 (00:37:00)

编者说明:所以「你不能在协程里使用互斥锁」——互斥锁是操作系统为线程设计的。要解决这个问题,需要异步 I/O。

2.3 异步 I/O:把「等待」变成「可调度」

(参考时间: 00:38)

站在操作系统设计者的视角,这些设计就很自然了。

O_NONBLOCK + EAGAIN:让协程在等待时能切换出去

📷 O_NONBLOCK + EAGAIN:让协程在等待时能切换出去
回到原视频 (00:40:00)

epoll:同时监听大量 fd,谁来我就继续

📷 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 的结构:

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 监听阻塞事件

📷 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 监听

📷 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。

于是有了一个「坑很多」的语言:

3.2 从 Web 1.0 到 Web 2.0

(参考时间: 00:56)

Web 1.0:Yahoo 时代的网页(table 布局、切图)

📷 Web 1.0:Yahoo 时代的网页(table 布局、切图)
回到原视频 (00:57:30)

modernize.js:一段脚本把「土味页面」瞬间改造成现代网页

📷 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 选了基于事件的并发模型:

$.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,计算图动态生成

📷 事件编程 + 回调: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 */)

但 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` 在代码里「画」计算图

📷 Promise:用 `.then` / `.all` / `.catch` 在代码里「画」计算图
回到原视频 (01:13:30)

async/await:同步的写法表达异步流程,编译成链式 Promise

📷 async/await:同步的写法表达异步流程,编译成链式 Promise
回到原视频 (01:19:00)

3.5 从前端到全栈:另一个应用生态

(参考时间: 01:19)

ES6(ECMAScript 2015) 一统第三方库「军阀混战」的局面,开源生态起飞。于是操作系统之上又长出了另一个应用生态:


总结


附:官方参考与延伸阅读

本讲的讲义见课程主页,以下链接按书中出现顺序整理:

主题 链接
线程的代价(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/。视频画面与字幕版权归原作者所有。本书为课堂内容的学习性重构。