DEV Community

jianfeng huang
jianfeng huang

Posted on

协程到底是什么?用"一个人守三口锅"讲明白,别再和线程混为一谈

面试里被问"协程是什么",很多人的答案是一句背来的话:"协程是用户态的轻量级线程。"

这句话不算错,但它几乎解释不了任何事。听完之后你还是不知道该在什么时候用它、为什么它会被一个 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  —— 从上次停下的那一行接着跑
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

等等,一个线程怎么会让两个 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 就会被静默吞掉或延迟报出
Enter fullscreen mode Exit fullscreen mode

替代方案分别是 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)