当前位置: 首页 > news >正文

NJU OS 协程、Goroutine、异步编程

目录
  • 协程、Goroutine 与异步编程
    • 线程开销问题
    • 两条工程路线
    • 协程的基本执行模型
    • 协程与用户态并发
    • 阻塞系统调用的陷阱
    • 非阻塞 I/O 与 epoll
    • eventfdtimerfd 与统一事件模型
    • Goroutine 的工程化模型
    • Channel 与通信式同步
    • 异步编程的状态机视角
    • 计算图视角下的统一理解
    • 常见误解
      • 协程是否就是用户态并发
      • 协程是否天然并行
      • epoll 是否负责读写数据
      • async/await 是否就是多线程
    • 与系统性能的连接
    • 本节知识点总结

协程、Goroutine 与异步编程

第 19 讲的入口不是某个新 API,而是一个系统问题:

我们想像调用函数一样随时创建并发任务,
但 OS 线程的创建、栈空间、调度和切换都太贵。

第 18 讲已经把并行算法抽象成计算图:节点是本地计算,边是依赖或同步。第 19 讲继续追问:当计算图的节点很多、每个节点经常等待 I/O 时,是否还应该为每个节点创建一个 OS 线程?

答案通常是否定的。工程上有两条路线:

1. 让“线程”变轻:coroutine / goroutine / lightweight thread。
2. 改变执行模型:future / promise / async-await / event loop。

本笔记重点放在第一条路线和它与异步 I/O 的关系上;讲义最后的 JavaScript / Web 计算图模型暂不展开。

线程开销问题

线程的核心模型可以写成:

thread = 独立栈 + 独立寄存器上下文 + 内核调度实体 + 共享进程地址空间

它比进程轻,因为不复制整个地址空间;但它仍然不是免费的。一个 Linux pthread 至少会带来:

  • 用户栈:常见默认约 8 MB 虚拟地址空间,物理页通常按需分配。
  • 内核对象:调度实体、内核栈、线程元数据等。
  • TLS / TCB:线程本地存储和线程控制块。
  • 调度成本:阻塞、唤醒、上下文切换都要进入内核。
  • 缓存扰动:线程迁移、锁竞争和共享 cache line 会破坏局部性。

所以课程里说 10000 个线程大约对应 80 GB 虚拟栈空间,这不是夸张修辞,而是在提醒:

线程的抽象很好用,但“每个并发活动一个 OS 线程”无法无限扩展。

如果 workload 中大量任务只是等待网络、管道、定时器或磁盘事件,OS 线程的大栈和内核调度就会成为主要开销。

两条工程路线

课程把问题抽象成下面的愿望:

t1 = spawn(f);
t2 = spawn(g);
t3 = spawn(h);
join(t1);
join(t2);
join(t3);

理想状态下,spawn/join 的成本应该接近函数调用;现实中,OS 线程远比函数调用重。

因此有两条路线。

第一条是轻量化线程:

保留“像线程一样写”的模型,
但把调度、栈和切换尽量搬到用户态。

典型代表是:

  • Python generator。
  • C++20 coroutine。
  • 用户态栈切换库,如 ucontext / setjmp / longjmp / Boost.Context。
  • Go goroutine。

第二条是异步编程:

不再假装每个任务都是一个阻塞线程,
而是显式描述“现在等什么事件,事件完成后继续哪一段代码”。

典型代表是:

  • Future / Promise。
  • async/await
  • event loop。

这两条路线表面不同,底层都在处理同一个问题:

当任务进入等待状态时,不要让整个 OS 线程傻等。

协程的基本执行模型

协程可以先抓成一句话:

coroutine = 可以主动挂起并稍后恢复的执行单元

普通函数只有“调用”和“返回”。协程多了一个中间状态:

running -> yield/suspend -> suspended -> resume -> running

挂起时,运行时需要保存“以后从哪里继续”以及必要的局部状态。实现上通常有两类:

  • 有栈协程:每个协程有自己的用户态栈,切换时保存/恢复栈指针和寄存器。
  • 无栈协程:编译器把函数拆成状态机,局部变量变成状态对象的一部分。

Python generator 和 C++20 coroutine 更接近“无栈协程”的教学入口:每次 yieldco_yield 都是一个显式挂起点,调用者下次恢复时从挂起点之后继续执行。

重要的是:协程通常由用户态运行时调度,而不是由内核调度。它因此更轻:

OS 不需要为每个协程分配完整线程资源;
切换协程通常不需要陷入内核;
协程栈可以很小或由状态机替代。

但“轻”不等于“自动并行”。如果一个进程里只有一个 OS 线程在运行协程,那么这些协程只是并发推进,不会在多个 CPU 核上同时执行。

协程与用户态并发

把协程说成“用户态并发”方向是对的,但更精确的说法是:

协程是实现用户态并发的一种执行单元。

协程本身是“可暂停/可恢复的函数”;用户态并发是运行时把许多这样的函数组织起来,让它们在等待点交替推进。

调度方式通常是协作式的:

当前协程运行
-> 遇到 yield / await / 阻塞前的等待点
-> 主动交回控制权
-> 调度器选择另一个可运行协程

这和 OS 线程的抢占式调度不同。OS 可以在时钟中断、系统调用返回等位置强行切走线程;纯用户态协程如果不主动让出,其他协程就没有机会运行。

因此协程程序的关键不变量是:

所有可能长时间等待的操作,都必须变成“注册等待事件 + yield”。

否则,一个协程卡住,就可能把同一个 OS 线程上的所有协程一起卡住。

阻塞系统调用的陷阱

协程最大的陷阱是:操作系统看不到用户态运行时内部的一百万个协程,它只看到一个或少数几个 OS 线程。

如果某个协程直接调用阻塞系统调用:

read(fd, buf, n);   // 没有数据时阻塞
sleep(1);           // 线程睡眠

内核阻塞的是承载它的 OS 线程。结果是:

一个协程在内核里等待
-> 整个 OS 线程停止运行
-> 同线程上的其他协程也无法被调度

这就是讲义里的:

一个协程等待,1,000,000 个都等待。

另一个典型问题是同步原语和协作式调度组合不当。例如一个协程拿着锁之后 yield,调度器又恢复到需要同一把锁的协程,就可能形成进展性问题。协程运行时必须非常清楚哪些操作会让出控制权,哪些锁和资源在让出时仍被持有。

非阻塞 I/O 与 epoll

解决阻塞系统调用问题的基本方法是把 I/O 改成非阻塞:

open(..., O_NONBLOCK)

非阻塞 read 的语义是:

如果现在有数据:返回实际读到的字节数。
如果现在没数据:返回 -1,并设置 errno = EAGAIN / EWOULDBLOCK。

这给了协程运行时一个机会:

while (read(fd, buf, n) == -1 && errno == EAGAIN) {register_interest(fd, READABLE);yield();
}

但是只靠 O_NONBLOCK 还不够。运行时还需要知道“哪些 fd 现在可读/可写”。这就是 epoll 的位置。

epoll 的功能是:

让进程高效等待大量 fd 的就绪事件。

它不是让单次 read/write 更快,而是避免:

  • 为每个连接创建一个 OS 线程。
  • 反复线性扫描所有 fd。
  • 在没有数据的 fd 上空转。

典型事件循环是:

1. 协程尝试 read/write。
2. 如果返回 EAGAIN,把 fd 和等待事件注册到 epoll。
3. 当前协程 yield。
4. 调度器运行其他可运行协程。
5. epoll_wait 返回就绪 fd。
6. 运行时恢复等待这些 fd 的协程。

可以把它压成:

协程负责表达“我要等什么”;
epoll 负责告诉运行时“哪些等待条件已经可能成立”;
调度器负责恢复对应协程。

这也是高并发网络服务器能用少量线程处理大量连接的基础。

eventfdtimerfd 与统一事件模型

讲义提到 eventfdtimerfdio_uring,核心思想是把更多等待对象纳入统一事件循环。

timerfd 把定时器表示成 fd:

时间到了 -> fd 可读 -> epoll_wait 返回

eventfd 把用户态/线程间通知表示成 fd:

其他线程写 eventfd -> fd 可读 -> epoll_wait 返回

这样运行时就可以用同一个 epoll_wait 同时等待:

  • socket 可读可写。
  • pipe 可读。
  • 定时器到期。
  • 其他线程发来的唤醒事件。

这件事的抽象价值很大:

把“等待不同种类事件”统一成“等待 fd 就绪”。

io_uring 则更进一步,用提交队列和完成队列表达真正的异步 I/O,尤其适合传统 epoll 不擅长的磁盘 I/O 场景。

Goroutine 的工程化模型

goroutine 可以理解为 Go 对轻量执行流的工程化实现:

goroutine = Go runtime 调度的轻量任务

写:

go f()

不是直接创建一个 OS 线程,而是创建一个 goroutine,由 Go runtime 调度到某个 OS 线程上执行。

Go 的调度通常用 G-M-P 模型理解:

G = goroutine,用户态任务。
M = machine,实际 OS 线程。
P = processor,运行 Go 代码所需的调度资源和本地队列。

多个 G 会复用到少量 M 上运行,形成 M:N 调度。这样 goroutine 可以做到:

  • 像线程一样写阻塞风格代码。
  • 栈从很小开始,按需增长。
  • 大量任务由 Go runtime 在用户态调度。
  • 遇到网络 I/O 等等待时,runtime 配合 netpoller 暂停当前 goroutine,转去运行其他 goroutine。

所以 goroutine 的关键优势不是“语法短”,而是:

把线程式代码的可读性,和协程式调度的低成本结合起来。

它也不是凭空解决 CPU 并行。真正同时执行仍然依赖多个 OS 线程和多个 CPU 核;GOMAXPROCS 决定同一时刻可以并行执行 Go 代码的 P 的数量。

Channel 与通信式同步

讲义引用了 Effective Go 的一句话:

Do not communicate by sharing memory; instead, share memory by communicating.

信号量、条件变量、互斥锁能表达同步,但数据如何传递仍然靠程序员手工维护共享内存。如果忘记加锁、锁错对象或破坏不变量,就会回到 data race 和 atomicity violation。

Go channel 把同步和通信合在一起:

done <- id     // 发送:传递数据,也可能阻塞等待接收者
id := <-done   // 接收:取得数据,也可能阻塞等待发送者

这和 UNIX pipe 的思想接近:

生产者写入 channel / pipe
消费者读取 channel / pipe
数据流本身携带同步关系

课程里的 Mandelbrot-Go 例子可以这样理解:

多个 goroutine 分块计算像素行
-> 每个 worker 完成后向 done channel 发送完成事件
-> monitor 用 select 同时等待 done 和 timer
-> finish channel 通知主 goroutine 收尾

这里的 channel 不只是“队列”,也是 happens-before 边:发送完成事件先于接收方观察到完成。

异步编程的状态机视角

即使不展开 JavaScript,也需要保留异步编程的一般模型,因为它和协程是同一个问题的另一种表达。

async/await 的核心不是“自动多线程”,而是:

把一个顺序函数按 await 切成若干段,
每段在前一个等待事件完成后继续执行。

编译器或运行时大致会把:

do A
await event1
do B
await event2
do C

改写成状态机:

state 0: do A; register event1; suspend
state 1: do B; register event2; suspend
state 2: do C; finish

所以 await 和协程的 yield 在控制流意义上很像:都表示“保存当前状态,等以后恢复”。差别在于:

  • 协程路线试图保留类似线程的执行流抽象。
  • 异步路线更明确地把等待点暴露给语言和运行时。
  • Future / Promise 把“未来完成的值”和依赖关系显式对象化。

这也是为什么很多语言的 async/await 看起来像同步代码,但底层并不是阻塞线程,而是在事件完成后继续执行后半段。

计算图视角下的统一理解

本讲仍然可以回到计算图:

节点 = 一段可以连续执行的代码
边   = 必须等待的事件、I/O 完成、channel 通信或 join 依赖

OS 线程模型的问题是:每个节点或每条等待链都可能占用一个重线程。协程和异步编程则尝试把等待中的节点从 OS 线程上摘下来。

可以这样对比:

OS thread:等待 I/O 时,线程阻塞在内核里。Coroutine + epoll:等待 I/O 时,协程挂起,OS 线程继续跑其他协程。Goroutine:程序员写阻塞风格代码,Go runtime 负责在等待时挂起 G,复用 M。Async/await:程序员标出 await 点,编译器/运行时把函数拆成事件驱动状态机。

因此,本讲的主线不是“协程比线程高级”,而是:

真实 workload 里大量并发活动都在等待;
高性能运行时必须把等待成本从 OS 线程成本中解耦出来。

常见误解

协程是否就是用户态并发

可以粗略这么说,但更精确的是:

协程是用户态并发的执行单元;
用户态调度器把许多协程组织成并发程序。

只有一个协程时,它只是一个可暂停函数;有调度器和多个可运行协程时,才体现用户态并发。

协程是否天然并行

不是。协程本身只说明“可挂起/恢复”,不说明“能在多个 CPU 核上同时运行”。要并行,需要多个 OS 线程承载协程,或者像 Go runtime 那样把 goroutine 分配到多个 worker thread 上。

epoll 是否负责读写数据

不是。epoll 只告诉你 fd 是否就绪:

fd readable -> 现在调用 read 比较可能取得数据
fd writable -> 现在调用 write 比较可能写入缓冲区

真正的数据传输仍然由 read/write/recv/send 完成。就绪也不等于一定能读完整个请求;代码仍然要处理短读、短写、EOF、错误和重试。

async/await 是否就是多线程

不是。async/await 更像“把函数切成状态机”。它能让单线程在等待 I/O 时继续处理其他事件,但 CPU 密集代码如果一直运行,仍然会占住当前执行线程。

与系统性能的连接

协程和异步 I/O 服务的是典型 I/O 密集 workload:

大量连接
少量活跃
大量时间在等待网络、定时器或外部服务
每个请求的 CPU 工作不一定很重

这类 workload 如果用“一连接一线程”,会被栈空间、调度和 cache 扰动拖垮。如果用 epoll + coroutine 或 runtime netpoller,则可以让少量线程只在事件真正就绪时推进对应任务。

但 CPU 密集型 workload 不会因为换成协程自动变快。此时瓶颈是计算本身,需要回到第 18 讲的并行算法:

画计算图
选择任务粒度
减少同步边
改善局部性
用多线程 / SIMD / GPU 真正并行执行

对 AI infrastructure 来说,这个边界很重要:

  • 请求接入、网络等待、排队、流式返回适合协程/异步 I/O。
  • tokenizer、batching、调度器可以用 worker pool 和 channel/queue。
  • GPU kernel 执行、矩阵计算、推理算子优化属于并行计算和硬件局部性问题。
  • serving 系统常常把两类模型组合起来:上层协程处理大量请求,下层线程池/GPU worker 执行重计算。

本节知识点总结

1. OS 线程不是免费的:栈、内核对象、调度、上下文切换和缓存扰动都会成为成本。
2. 第 19 讲的核心问题是:如何让大量等待型并发任务不再各占一个重线程。
3. 协程是可挂起/可恢复的执行单元,通常由用户态运行时调度。
4. 协程实现可以有栈,也可以由编译器改写成无栈状态机。
5. 协程轻量不等于自动并行;并行仍然依赖多个 OS 线程和多个 CPU 核。
6. 阻塞系统调用会阻塞承载协程的 OS 线程,因此协程运行时必须配合非阻塞 I/O。
7. `O_NONBLOCK` 让无数据的 `read` 返回 `EAGAIN`,从而允许协程注册等待事件并 `yield`。
8. `epoll` 负责高效等待大量 fd 的就绪事件,是协程网络运行时的重要基础。
9. `eventfd`、`timerfd` 把通知和定时器也纳入 fd 事件模型,方便统一调度。
10. Goroutine 是 Go runtime 调度的轻量任务,通过 `G-M-P` 模型把大量 G 复用到 OS 线程上。
11. Channel 把同步和通信合在一起,发送/接收既传递数据,也建立执行顺序。
12. `async/await` 的本质是把函数按等待点拆成状态机,不是自动创建线程。
13. 协程/异步适合 I/O 密集并发;CPU 密集加速仍要靠并行算法、线程、SIMD 或 GPU。
http://www.gsyq.cn/news/1569673.html

相关文章:

  • Selenium自动化测试从入门到精通:四阶段学习路线与实战指南
  • 基于MC68HC908EY16的红外遥控LIN机器人:输入捕获与总线通信实战
  • 2026上海防水补漏上门施工哪家强?正规商家资质+报价+口碑+售后四维实测对比 - 防水资讯
  • FanControl智能散热配置:打造个性化风扇控制方案
  • 什么是全景运维地图?全景运维地图包括哪些关键技术?
  • 基于BFU768F的5-6GHz低噪声放大器设计:实现1.4dB噪声系数与快速开关
  • Java Web自动化测试入门:Selenium环境搭建与Page Object模式实战
  • 从MPC5674F到MPC5676R:嵌入式系统单核到双核迁移实战指南
  • 程序员量化交易实战 06:先把数据库表结构讲清楚
  • uClinux在ColdFire无MMU平台的移植与调试实战指南
  • 8大主流网盘直链下载助手:免费解锁高速下载的终极解决方案
  • 从EA LPC1788到Keil MCB1700的emWin BSP移植实战指南
  • 英雄联盟玩家的3个秘密武器:如何用本地自动化工具提升游戏体验
  • QQ音乐解析终极指南:轻松获取海量音乐资源的完整解决方案
  • 半导体量检测工艺及设备
  • 3D合成与不变技能:实现机器人视点泛化的核心技术
  • Expect SSH自动化脚本编写原理与生产实践指南
  • 俄艾斯国际俄罗斯EAC认证,提升国货欧亚市场核心竞争力 - 品牌速递
  • 2026长沙防水补漏上门施工哪家强?正规商家资质+报价+口碑+售后四维实测对比 - 防水资讯
  • 2026高速贴标机故障率口碑综合测评:飞彬贴标机适配各行业深度分析 - 万事通达
  • i.MX53xA UART与USB接口硬件设计:电气特性解析与工程实践
  • B站会员购抢票自动化:如何用biliTickerBuy告别手动抢票的烦恼?
  • Memory is Reconstructed, Not Retrieved: Graph Memory for LLM Agents
  • 5种方法快速掌握跨平台资源下载工具:从技术原理到实战应用
  • Linux /dev/null 原理与实战:标准流重定向与静默化工程
  • 武汉市洪山区水电维修|维小达|电路|水管|马桶|暖气|管道疏通一站式全屋水电维保服务 - 维小达科技
  • 开源漏洞扫描工具实战:从工具使用到漏洞原理的逆向学习指南
  • 2026沈阳防水补漏上门施工哪家强?正规商家资质+报价+口碑+售后四维实测对比 - 防水资讯
  • CF2144E1 思路分享(dp)
  • 3分钟掌握Adobe-GenP:终极Adobe软件激活完整指南