跳到正文
原文
Google AI:DEV 作者专属(RSS)· jianfeng huang·· 8 小时前AI 评分40

用大白话讲清楚:协程到底是个什么东西

AI 导读

协程是一个可以被暂停、被恢复、暂停时状态不丢的函数,调度它的是 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)。它干的事非常朴素:

  1. 手上维护一堆待办的协程;
  2. 挑一个恢复执行;
  3. 这个协程 await 了 → 记下它要等什么,先放一边;
  4. 去问别的协程,谁的等待条件满足了,就恢复谁;
  5. 重复,直到都跑完。

用 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