面试里被问"协程是什么",很多人的答案是一句背来的话:"协程是用户态的轻量级线程。"
这句话不算错,但它几乎解释不了任何事。听完之后你还是不知道该在什么时候用它、为什么它会被一个 time.sleep() 卡死、为什么它不需要锁。
这篇不讲定义,只讲三件事:协程在切什么、调度权在谁手里、什么时候不该用。
一、先把三个词排成一队:进程、线程、协程
这三个东西经常被并列着讲,但它们回答的其实是同一个问题:"谁来决定现在跑哪段代码?"
- 进程:操作系统给一个程序划的地盘,有自己独立的内存空间。切进程最贵,因为要换页表、换地址空间。
- 线程:进程内部的执行流,共享同一片内存,但各自有独立的栈。切换要走操作系统(内核态),几十到几百纳秒起步,而且共享内存意味着你需要锁。
- 协程:跑在某一个线程内部的、可以被暂停和恢复的函数。切换的时候不惊动操作系统,也不需要锁,因为同一时刻它的线程只在一个协程里跑。
注意最后半句,这是全部误解的源头:协程没有并行能力。 一个线程里的 1000 个协程,在任意一个瞬间,只有 1 个在执行。
那并发从哪来?来自"等待"。等数据库、等 HTTP、等磁盘——CPU 这段时间是闲着的。协程要做的就是:在等的时候,把线程让出去。
二、一个人守三口锅
想象你在厨房烧三口锅的水。
线程模型(多线程):你雇三个厨师,一人守一口锅。问题是每个厨师都要占工资、占灶台位,而且他们共用一口调料柜——A 在拿盐的时候 B 也想拿,就得排队加锁(还得小心别把调料柜锁死)。
协程模型:就你一个人。你给锅 A 点上火,趁它烧的时候去看锅 B;锅 B 的水还没开,你就顺手把菜切了;锅 C 冒泡了,你回去关火。
关键点在于:切换是你自己决定的,不是别人打断你的。 你在每次"把锅放上灶台"这个动作之后,主动去看下一口锅。
这就是"协作式调度"(cooperative)。对应的反面是抢占式调度(preemptive)——线程就是这样,操作系统可以在任意一条机器指令之后把你的 CPU 抢走,所以你必须用锁保护共享状态。协程不需要,因为没人能在你不知情的时候打断你。
代价也在这:既然靠"你自己主动让",那你一旦不让,别人就全等着。 后面第三节会看到这个代价有多疼。
三、协程的本质:一个能暂停、能恢复的函数
去掉所有名词,协程就是这么一个东西:
一个函数,能在中间某一行停下来,把控制权交回去;之后还能从停下的那一行接着往下跑。
普通函数做不到这一点。你调用 f(),它就必须一路跑到 return 或者抛异常,中间不能暂停;它的栈帧在调用期间一直在那儿堆着。
能暂停的函数里,"暂停点"就是挂起点。Python 里你天天见它,只是没往这个方向想:
def countdown(n):
while n > 0:
yield n # 挂起点:交出去一个值,然后在这里停住
n -= 1
c = countdown(3)
print(next(c)) # 3
print("我还能先干点别的")
print(next(c)) # 2 —— 从上次停下的那一行接着跑
countdown 就是一个协程(历史叫法 generator-based coroutine)。yield 是暂停按钮,next() 是恢复按钮。
Python 3.5 之后给了专门的语法 async def / await,本质上是把"暂停/恢复"这套能力包装得更清晰:await 明确告诉读者"这里会挂起,让出去"。
import asyncio
async def fetch(name, delay):
print(f"{name} 开始请求")
await asyncio.sleep(delay) # 挂起点:把控制权还给事件循环
print(f"{name} 拿到结果")
return name
async def main():
await asyncio.gather(fetch("A", 1), fetch("B", 1))
# 两个"请求"几乎同时开始,总耗时约 1 秒,而不是 2 秒
asyncio.run(main())
等等,一个线程怎么会让两个 fetch "同时"在跑?它没有。真实过程是:事件循环先跑到 fetch("A") 的 await asyncio.sleep(1),把它记成"1 秒后叫醒我",然后立刻切到 fetch("B"),同样挂起、登记定时器,此时两个协程都在睡觉,事件循环空转;1 秒后先到期的那一个被恢复,从挂起点继续往下。
"并发"在这里的意思是:多个任务的生命周期重叠在一起,而不是多个任务在同一时刻占用 CPU。 这句话值得抄在便利贴上。
四、代价:一个阻塞调用,全盘皆输
协作式调度的反面就是它的命门。只要有一个协程不让出线程,同线程里所有协程都动不了。
最常见的三条踩坑路径:
import time, asyncio
async def bad():
time.sleep(3) # 坑 1:同步阻塞,整个事件循环冻住 3 秒
async def also_bad():
requests.get(url) # 坑 2:同步 HTTP 库,同上
async def sneaky():
1 / 0 # 坑 3:协程里的异常没人 await 就会被静默吞掉或延迟报出
替代方案分别是 await asyncio.sleep()、aiohttp / httpx.AsyncClient、以及把同步的重活扔进 asyncio.to_thread() 或线程池里——协程不会把阻塞变没,它只是让你有机会把阻塞挪到别的地方去。
顺便说清另一件事:协程不加速 CPU 计算。 如果你要算 10 亿次哈希,用 asyncio 只会更慢。CPU 密集的活儿交给多进程(multiprocessing / ProcessPoolExecutor),跟协程不冲突。
五、什么时候用协程,什么时候别碰
一个照着选就行的清单:
用协程,当你的瓶颈是"等":
- 大量网络请求、爬虫、调用外部 API(几百到几千并发连接)
- Web 服务的 IO 密集处理(FastAPI、aiohttp、Starlette 都是这条路)
- 聊天/推送类长连接服务,连接数远大于 CPU 核数
别用协程,当你需要"算":
- 图像处理、加密、数值计算、压缩——用多进程,或者下沉到 C/Rust 扩展
- 团队里没人在意
async传播链:一个同步库就能把整条链路变成伪并发
用线程,当你要复用现成的同步库:
- 老的 ORM、老的 SDK 只给同步 API,包一层
to_thread()往往比硬改协程链路更省事
还有一个隐蔽的成本:async 是会传染的。 一个函数变成 async def,它的所有调用者都得跟着改。所以不要"为了看起来先进"就把整条链路异步化,先问一句:这条链路的瓶颈真的在等 IO 吗?
六、一句话收口
协程不是更快的线程,它是一套"函数可以主动暂停、把线程让给别人"的写法;省掉的不是计算时间,而是等 IO 时的空转。
如果你只记住一个动作,就记这个:下次看到 async def,先在函数体里搜一圈有没有 time.sleep、同步 requests、同步文件读写。 找到一处,你就知道那段代码为什么比预期慢十倍了。
Top comments (0)