DEV Community

jianfeng huang
jianfeng huang

Posted on

协程到底是什么?把它当成“一个能中途暂停的函数”,一切就通了

TL;DR:协程不是“更快的线程”,它是一个可以在中途停下来、把线程让给别人、之后还能从停下的那一行接着跑的函数。它省掉的不是计算时间,而是等 IO 时的空转。

面试被问“协程是什么”,大多数人的答案是背来的一句:“协程是用户态的轻量级线程。”

这句话不算错,但它几乎解释不了任何事。听完你还是不知道该在什么时候用它、为什么它会被一个 time.sleep() 卡死、为什么它不需要锁。

这篇不讲定义,只讲三件事:协程在切什么、调度权在谁手里、什么时候不该用它。

一、先问一个更朴素的问题:谁在决定现在跑哪段代码?

进程、线程、协程经常被并列着讲,但它们回答的其实是同一个问题:“现在这一刻,CPU 该执行哪段代码,由谁说了算?”

谁在调度 切换成本 需要锁吗 能并行吗
进程 操作系统 最贵(换地址空间、页表) 需要(或靠 IPC) 能,真并行
线程 操作系统 中等(内核态,几十到几百纳秒起) 需要(共享内存) 能,真并行
协程 它自己(在某个线程内部) 便宜(用户态,就是一次函数返回) 通常不需要 不能

最后两列是全部误解的来源。

协程没有并行能力。 一个线程里跑着 1000 个协程,任意一个瞬间也只有 1 个在执行——因为它们共用同一个线程。协程不是把活分给更多 CPU,它是让同一个线程别闲着。

而且协程不需要锁,理由很具体:没有谁能在一个协程不知情的时候打断它。操作系统可以在任意一条机器指令之后把线程的 CPU 抢走(这叫抢占式调度),所以线程之间必须加锁保护共享数据;协程是大家约好了“我主动让,你才上”,切换点全都写在代码里、看得见。

代价也正好在这:既然靠“你自己主动让”,那你一旦不让,同线程里所有人都得等你。

二、把协程想成餐厅里那个跑得飞起的服务员

多线程模型:每张桌子配一个专属服务员。听话是听话,但每个服务员都要占工资、占站位,而且他们共用一个调料柜——A 伸手拿盐的时候 B 也想拿,就得排队加锁(还得小心别互相锁死)。

协程模型:就雇一个服务员,管十桌。他给 3 号桌点上火,趁水没开的空档去 5 号桌点单;5 号桌还在犹豫,他顺手把 7 号桌的菜端了;3 号桌的锅冒泡了,他回去关火。

这个服务员做对了一件关键的事:他不是被谁打断才换桌的,是他自己决定“这一桌现在没事可做,我先去别处”。 每次“把锅放上灶台”之后,他都会主动看一眼下一桌。

这就是协作式调度(cooperative):切换的主动权在任务自己手里。线程是反过来的抢占式调度(preemptive):主动权在操作系统手里,你永远不知道自己在哪一行被换下去。

三、去掉比喻和名词:协程就是一个能暂停、能恢复的函数

把前面那些词都删掉,协程的真身只有一句话:

一个函数,能在中间某一行停下来,把控制权交回去;之后还能从停下的那一行接着往下跑。

普通函数做不到。你调用 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() 是恢复键,中间那句 print 能在它“暂停”的时候照常跑——因为线程从来没被占住。

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():
    results = await asyncio.gather(fetch("A", 1), fetch("B", 1))
    print(results)

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

四、一个线程,怎么让两个任务“同时”在跑?

它其实没有“同时”在跑。真实的执行顺序是这样的:

  1. main 里 gather 把 fetch("A") 和 fetch("B") 各包成一个任务,先推进 A;
  2. A 走到 await asyncio.sleep(1),登记一个“1 秒后叫醒我”的定时器,然后把控制权交回去;
  3. 事件循环立刻去推进 B,B 同样登记定时器、同样交回控制权;
  4. 此刻两个协程都在“睡”,线程短暂地没有活干;
  5. 1 秒到了,先到期的那个被唤醒,从挂起点继续往下跑,打印“等到了”。

所以这里的“并发”准确含义是:多个任务的生命周期重叠在一起,而不是多个任务在同一时刻占用 CPU。 这句话值得抄在便利贴上——它同时解释了协程为什么快(等待时间被重叠了)和为什么不快(算数的时间一点没省)。

五、代价:一个不让出的协程,能把整条线冻住

协作式调度的命门在第一节就埋下了:切换靠自觉。所以只要有一个协程不让出线程,同线程里的所有协程一起停摆。

最常见的三条路:

import time, requests, asyncio

async def bad():
    time.sleep(3)          # 坑 1:同步阻塞,整个事件循环冻住 3 秒

async def also_bad():
    requests.get(url)      # 坑 2:同步 HTTP 库,同上,而且更隐蔽(看着像在“请求”)

async def missing_await():
    asyncio.sleep(1)       # 坑 3:漏了 await —— 什么都不会发生,只留一个 RuntimeWarning
Enter fullscreen mode Exit fullscreen mode

对应的修法分别是 await asyncio.sleep()、httpx.AsyncClient / aiohttp、以及把实在同步的重活扔给 await asyncio.to_thread(...)。

要强调的是:协程不会把阻塞变没,它只是让你有机会把阻塞挪到别的地方去。 另外,协程也不会加速 CPU 计算——你要是拿它去算 10 亿次哈希,结果只会更慢。CPU 密集的活交给多进程,和协程并不冲突。

六、if-then 判断清单

  • 瓶颈是“等”,就用协程:几百上千个并发网络请求、爬虫、调用外部 API、IO 密集的 Web 服务(FastAPI / aiohttp 这条路)。
  • 瓶颈是“算”,别碰协程:图像处理、加解密、数值计算、压缩——用 multiprocessing 或下沉到 C/Rust 扩展。
  • 手里只有同步库,先别大改:老的 ORM、老的 SDK 包一层 asyncio.to_thread(),往往比硬把整条链路改成 async 更省事。
  • 只有三五个请求,别上协程:async 是会传染的,一个函数变 async def,它的所有调用者都得跟着改。先问一句:这条链路的瓶颈真在等 IO 吗?

七、收口

下次看到 async def,别急着想“这是并发”,先做一件事:在函数体里搜一圈有没有 time.sleep、同步 requests、同步文件读写。 找到一处,你就知道那段代码为什么比预期慢十倍了。

协程的核心就一个动作:在该等的时候,主动把线程让出去。

Top comments (0)