用大白话讲清楚:协程到底是个什么东西
协程是一个可以被暂停、被恢复、暂停时状态不丢的函数,调度它的是 Python 代码而非操作系统。文章用烧水切菜的生活类比,从 yield 双向通道讲到 async/await 语法糖,并手搓 20 行事件循环演示调度逻辑。协程适合 IO 密集高并发场景,CPU 密集任务用它会更慢。
用大白话讲清楚:协程到底是个什么东西
一、先别急着定义,看一个人做早饭
假设你要做一顿早饭:烧水要 3 分钟,切菜要 2 分钟。
如果你站在灶台前死盯着水壶,等它开了再去切菜,总共花 5 分钟。
正常人都不会这么干。你把水壶坐上,转身去切菜,切完回来水正好开了 —— 2 分多钟搞定。
这就是「并发」的全部核心思想:
等待的时间不要浪费,拿去干别的活。
请注意一个细节:你还是你,一个人。 你没有分身,两个活儿并没有在同一瞬间一起执行,
你只是把「等水开」的空档填上了「切菜」。这一条后面会反复用到。
计算机里的「等水开」有个专门的名字:IO 等待。网络请求、读文件、查数据库,
绝大部分时间 CPU 都在干等。协程要解决的就是这件事。
二、传统函数的问题:一旦开始,就不许停
普通函数有个特性:调用即执行,执行到 return 才交还控制权。
def cook():
print("坐上水壶")
time.sleep(3) # 这 3 秒里,整个线程被这个函数按住了
print("水开了")
time.sleep(3) 期间,CPU 是闲着的,但你没法让它去干别的 —— 因为控制权在 cook() 手里,
函数没有「中途暂停」这个能力。
协程就是给函数补上这个能力。
三、协程 = 能中途暂停、之后原地满血继续的函数
def cook():
print("坐上水壶")
yield "等水开" # ← 在这里暂停,把控制权交回调用方
print("水开了,下面条")
c = cook() # 注意:这里什么都不会打印!只是造出了一个协程对象
next(c) # 输出「坐上水壶」,然后停在 yield 处
print("趁这个空档去切菜")
next(c) # 从 yield 后面接着跑,输出「水开了,下面条」
划重点:next(c) 是「恢复」,不是「重新开始」。
第二次 next(c) 时,cook() 里的局部变量、执行到哪一行,全都还在。
这些状态存在哪?存在函数的栈帧里,由 Python 运行时保存在堆上 ——
整个过程不涉及操作系统内核,这是协程快、协程轻的根本原因。
到这里,协程的正式定义就一句话:
协程就是一个可以被暂停、被恢复、暂停时状态不丢的函数。
调度它的是你自己的 Python 代码,不是操作系统。
四、yield 其实是一条双向通道
大多数人学 yield 时以为它只会「往外吐值」,其实它还能「把值收进来」:
def waiter():
while True:
dish = yield # 暂停,等别人送一个值进来
print(f"好了,端上 {dish}")
w = waiter()
next(w) # 预热:先推进到第一个 yield,准备接收
w.send("宫保鸡丁") # 输出:好了,端上 宫保鸡丁
w.send("西红柿鸡蛋") # 输出:好了,端上 西红柿鸡蛋
yield 是一个双向门:既能交出控制权,又能带一个值回来。
Python 3 时代的「协程」,本质就是这么一条可以双向通信的通道包装出来的。
五、async / await:把这条通道写成人话
用 yield 手搓协程太反人类了,所以 Python 3.5 给了语法糖:
import asyncio
async def boil_water():
print("坐上水壶")
await asyncio.sleep(3) # 「我要等了,谁想干活谁上」
print("水开了")
async def chop_veg():
print("开始切菜")
await asyncio.sleep(2)
print("切完了")
async def main():
await asyncio.gather(boil_water(), chop_veg())
asyncio.run(main())
跑一下,总耗时约 3 秒,不是 5 秒。两个人穿插着把活干完了。
-
await的准确含义是:把这个协程挂起,把控制权还给事件循环,等我这边的条件满足了再叫我。 -
asyncio.gather()负责把几个协程一起丢给循环,让它们互为彼此的「空档」。
新手最大的一个坑
async def 定义的函数,调用它不会执行任何一行代码。
boil_water() # 什么都不发生,只是造了一个 coroutine 对象
# 控制台可能还会给你一句 RuntimeWarning: coroutine ... was never awaited
await boil_water() # 这才真的跑
async def 的函数长得像函数,行为却像「生成器工厂」:调用 = 造对象,await / 丢进循环 = 才执行。
六、事件循环:那个真正在把控全场的人
协程自己不会跑,得有人推。这个「人」就是事件循环(event loop)。它干的事非常朴素:
- 手上维护一堆待办的协程;
- 挑一个恢复执行;
- 这个协程
await了 → 记下它要等什么,先放一边; - 去问别的协程,谁的等待条件满足了,就恢复谁;
- 重复,直到都跑完。
用 20 行代码手搓一个,比看十遍文档都清楚:
import time
from collections import deque
def sleep(seconds):
return ("SLEEP", seconds)
def task_a():
print("A: 坐上水壶")
yield sleep(3)
print("A: 水开了")
yield sleep(1)
print("A: 面条煮好了")
def task_b():
print("B: 开始切菜")
yield sleep(2)
print("B: 菜切完了")
def run(*coros):
queue = deque((c, time.monotonic()) for c in coros) # (协程, 该唤醒的时刻)
while queue:
coro, wake_at = queue.popleft()
gap = wake_at - time.monotonic()
if gap > 0:
time.sleep(min(gap, 0.05)) # 都还没到点,歇一下再看
queue.append((coro, wake_at))
continue
try:
_, delay = coro.send(None) # 恢复协程,拿到它下次想等多久
except StopIteration:
continue # 这个协程跑完了
queue.append((coro, time.monotonic() + delay))
run(task_a(), task_b())
你会看到 A、B 的打印交错出现,总耗时约 3 秒。这就是事件循环的全部魔法:
一个 while 循环加一个待办队列,没有任何黑科技。
类比到哪儿为止就不准了:真实的事件循环不是傻轮询,而是用
epoll/kqueue/select
这类 IO 多路复用机制监听一堆文件描述符,内核告诉它「这个 socket 可读了」它才醒。
上面的while+ 队列只是把「调度逻辑」剥出来给你看。
七、五个常见误区,逐条打脸
误区一:协程就是轻量级线程。
错。线程由操作系统调度、能真·并行、有内核栈;协程只是一个普通的 Python 对象,
调度者是 Python 代码。同一个线程里,永远只有一个协程在跑。
说「轻量级线程」会让人误以为协程能并行,这是万恶之源。
误区二:把函数改成 async 就会变快。
错。CPU 密集的活儿用协程只会更慢(还得养一个事件循环)。协程快是因为它在等 IO。
误区三:await asyncio.sleep() 和 time.sleep() 差不多。
完全相反。前者是「让路」,后者是「堵路」:
async def bad():
time.sleep(1) # 整个事件循环停摆 1 秒,另外 999 个任务一起干等
误区四:协程能吃满多核。
不能。协程只在一个线程里穿插,要多核请上多进程。
误区五:await requests.get(url) 就行。
不行。requests 是同步阻塞库,塞进协程里会把循环卡死。要配 httpx、aiohttp 这类原生异步库。
八、生活类比 ↔ 术语对照
| 生活里的东西 | 程序里对应的东西 |
|---|---|
| 你这个人 | 操作系统线程 |
| 水壶烧着,人去切菜 | IO 等待期间把 CPU 让给别人 |
| 记住「切到第几刀了」 | 协程的栈帧被保存在堆上 |
| 待办清单 | 事件循环的就绪队列 |
| 每样活干到一半就停手 |
await 挂起点 |
九、什么时候该用协程
适合:IO 密集 + 高并发连接。爬虫、网关、API 聚合、WebSocket 推送、
单机要扛几千个并发连接 —— 这些场景下协程的收益是数量级的。
不适合:CPU 密集(图像处理、加解密、数值计算),以及必须吃满多核的任务。
一句话总结:协程是「能中途暂停、能原地继续的函数」,
它把「等待」这件事从操作系统手里拿回到你的 Python 代码里自己管。
理解了这一点,async/await 的所有反直觉行为都会变得顺理成章。
来源:Google AI:DEV 作者专属(RSS) · dev.to