R

ReflexKR

Reflex 官方文档

深夜厨房里的并发

·

课程概览

1

第一晚的长队:并发与并行

2

多招几个厨师:threading 基础

3

黑板上的数字错了:竞争条件与 Lock

4

只有三个灶台:同步工具

5

刀与砧板:死锁

6

订单夹:queue

7

外派厨师:concurrent.futures

8

面团为什么快不起来:GIL 与并行

9

一个人跑全场的前厅经理:asyncio 入门

10

团体预订取消了:结构化并发

11

前厅停住了:阻塞调用与混合使用

12

评论家之夜:综合

深夜厨房里的并发
第9课

时间 6:40–7:30

一个人跑全场的前厅经理:asyncio 入门

今晚的订单

#09 · 第九晚

一个人跑全场的前厅经理

危机

前厅改组第一天。前厅员工只有小露一个,桌子有十张。要是站在厨房门口等一桌的菜,其余九桌就全停了。

目标

不加人手,0.5 秒内把十张桌子的菜全部送出去。

深夜食堂 同时

从今晚起,前厅换了个样。前厅员工只有小露一个人,桌子有十张。主厨没有再招人。

小露一刻不停地绕着前厅一圈一圈地转。接了点单就递进厨房,不等菜出来,马上去下一桌。厨房的铃一响,她就端起那盘菜送出去。一圈,又一圈。看她跟着铃声(事件)沿同一条路线打转,厨师们第一天就给她起了个外号:事件循环。

小露一边系围裙带子一边说:“我最讨厌站在桌子前面傻等。我在这儿等的时候,别的桌子都在等我。”

主厨点点头。“到昨天为止,我们都是靠加人。今天学的是:怎么用好一个人等待的时间。在 Python 里,这叫 asyncio。”

9-1. async def 创建的是协程(7 分钟)

在函数前面加上 async,它就成了协程函数。先来看看奇怪的地方:调用了,却不执行。

import asyncio

async def take_order(table):
    print("order", table)
    return f"T{table}"

c = take_order(1)
print(type(c).__name__)
print(c)
# coroutine
# <coroutine object take_order at 0x...>
# <sys>:0: RuntimeWarning: coroutine 'take_order' was never awaited
  • take_order(1) 并没有打印 "order 1"。函数体一行都没跑。
  • 返回的是一个协程对象。就像一张写着“要去接 1 号桌的点单”的便条。
  • 便条一次都没执行就被扔掉的话,Python 最后会发出 was never awaited 警告。看到这个警告,几乎总是漏写了 await。

执行便条有两种办法:在最外层交给 asyncio.run(),在别的协程里面用 await。

print(asyncio.run(take_order(1)))
# order 1
# T1

c = take_order(2)
asyncio.run(c)
asyncio.run(c)
# order 2
# RuntimeError: cannot reuse already awaited coroutine

协程对象只能用一次。想再点一次同样的单,就重新调用 take_order(2),写一张新便条。

9-2. await 与 asyncio.run(7 分钟)

await 的意思是:“我在这儿等这件事做完。这期间,循环可以去做别的事。”await 只能写在 async def 里面。写在普通的 def 里,会报 SyntaxError: 'await' outside async function。

import asyncio
import time

async def table(n, wait):
    print(f"T{n} order")
    await asyncio.sleep(wait)   # 等厨房出菜的时候,小露是空闲的
    print(f"T{n} served")
    return f"T{n}"

async def one_by_one():
    a = await table(1, 0.3)
    b = await table(2, 0.3)
    return [a, b]

start = time.perf_counter()
print(asyncio.run(one_by_one()), f"{time.perf_counter() - start:.2f}s")
# T1 order
# T1 served
# T2 order
# T2 served
# ['T1', 'T2'] 0.62s
  • asyncio.sleep(0.3) 是 asyncio 版的 time.sleep(0.3)。等待期间把执行机会交还给循环。
  • 可结果是 0.3 + 0.3 = 约 0.6 秒。因为 await table(1, ...) 结束后才会走到下一行。就像小露站在 1 号桌前,一直等到菜出来。
  • await 的意思不是“同时”,而是“等”。想同时等,需要下一节的工具。

asyncio.run(main()) 是程序的入口:

  1. 新建一个事件循环(小露上班)。
  2. 把 main() 协程一直运行到结束。
  3. 取消剩下的任务,关闭循环(小露下班)。

一个程序里通常只在最外层调用一次。在已经有循环在跑的地方再调用,会报 RuntimeError: asyncio.run() cannot be called from a running event loop。

9-3. 事件循环在做什么(6 分钟)

事件循环是在一个线程里转的循环。它做的事,和小露在前厅里转圈一样:

  1. 挑一个现在可以推进的协程。
  2. 把这个协程运行到下一个 await。
  3. 记下这个协程在等什么(计时器、网络响应),然后去下一个协程。
  4. 没有可推进的协程时,就等最早会响的那个铃,再把对应的协程叫醒。

把三张桌子同时交给她(下一节的 gather),小露的路线是这样的。1 号桌的菜要 0.3 秒,2 号桌 0.2 秒,3 号桌 0.1 秒。

时刻小露在做什么输出
0.0 秒下了 T1 的单,在 await 处离开T1 order
0.0 秒下了 T2 的单,离开T2 order
0.0 秒下了 T3 的单,离开T3 order
0.0~0.1 秒没事可做,等最早响的铃
0.1 秒T3 的铃响了。端起盘子送出去T3 served
0.2 秒T2 的铃T2 served
0.3 秒T1 的铃T1 served

记住两件事:

  • 人只有一个。 线程只有一个,任何时刻正在执行的协程也只有一个。变快是因为等待的时间叠在了一起,不是因为手变多了。
  • 只有在 await 处才会交出执行权。 线程随时可能被操作系统打断(第 3 课),协程却只在自己 await 时才离开。所以两个 await 之间的代码不会被中途打断。

9-4. gather:同时等(8 分钟)

asyncio.gather(协程, 协程, ...) 把多个协程一次性交给循环,并一直等到全部结束。

async def together():
    return await asyncio.gather(table(1, 0.3), table(2, 0.2), table(3, 0.1))

start = time.perf_counter()
print(asyncio.run(together()), f"{time.perf_counter() - start:.2f}s")
# T1 order
# T2 order
# T3 order
# T3 served
# T2 served
# T1 served
# ['T1', 'T2', 'T3'] 0.31s
  • 耗时不是总和(0.6 秒),而是最长的那个(0.3 秒)。
  • 完成顺序是 T3 → T2 → T1,但结果列表是按传入顺序排的。和第 7 课的 executor.map 一样。
  • 把多个协程做成列表传进去时,要像 gather(*coros) 这样用星号解包。

在下面的实验台里,轮流切换“一桌一桌 await”和“用 gather 一起”跑一跑。一行就是一张桌子,横条全是“等待”的颜色。小露真正动手的时间几乎为零,绝大部分都在等厨房。

厨房时间线

订单

6

烹饪

等待

已取消

失败

10 张桌子,每张 0.25 秒。一桌一桌 await,横条像台阶一样首尾相接,约 2.6 秒;用 gather 一起等,横条全部叠在一起,约 0.25 秒。改改桌子数量试试。
  • 一桌一桌:桌子越多,时间越长。10 张大约 2.6 秒。
  • gather:不管是 1 张还是 12 张,都约 0.25 秒。第 1 课招八个厨师得到的效果,现在一个员工就做到了。
  • 这个实验台同样是服务器上真的在跑 asyncio 循环,画的是每张桌子实际开始和结束的时刻。

有一桌出了问题:return_exceptions

交给 gather 的协程里有一个抛了异常,gather 就会把这个异常原样抛出来。想把结果全部拿到,就传 return_exceptions=True。

async def table(n):
    await asyncio.sleep(0.1 * n)
    if n == 2:
        raise ValueError(f"T{n} wrong dish")
    return f"T{n}"

async def main():
    results = await asyncio.gather(table(1), table(2), table(3), return_exceptions=True)
    print(results)
    try:
        await asyncio.gather(table(1), table(2), table(3))
    except ValueError as e:
        print("ValueError:", e)

asyncio.run(main())
# ['T1', ValueError('T2 wrong dish'), 'T3']
# ValueError: T2 wrong dish
  • 设了 return_exceptions=True,异常也会作为结果列表里的一格放进来。逐个用 isinstance(r, Exception) 检查结果。
  • 默认值(False)时即使异常被抛出来,其余的桌子也不会被取消,会继续跑下去。“一个失败就让其余的都停下”,要靠第 10 课的 TaskGroup。

主厨的提醒

was never awaited 这个警告,意思是订单写好了却没递进厨房。调用了协程函数,就一定要 await,或者做成任务。不然那道菜永远也上不了桌。

9-5. create_task:先交代下去(6 分钟)

asyncio.create_task(协程) 把协程包成一个任务(Task),马上排进循环。任务会在小露腾出手的时候(当前协程 await 的时候)开始执行。它不等任务做完就走到下一行,所以可以先把单下了,再去干别的事。

async def table(n, wait):
    print(f"T{n} order")
    await asyncio.sleep(wait)
    print(f"T{n} served")
    return f"T{n}"

async def main():
    start = time.perf_counter()
    t1 = asyncio.create_task(table(1, 0.3))
    t2 = asyncio.create_task(table(2, 0.2))
    print("Lou: 2 tasks created")
    await asyncio.sleep(0.1)          # 小露先去干一会儿别的
    print("Lou: back", t1.done(), t2.done())
    print(await t1, await t2, f"{time.perf_counter() - start:.2f}s")

asyncio.run(main())
# Lou: 2 tasks created
# T1 order
# T2 order
# Lou: back False False
# T2 served
# T1 served
# T1 T2 0.31s
  • "Lou: 2 tasks created" 比 T1 order 先打印出来。任务一创建就排上了,但要等 main 用 await 让出位置时才第一次执行。
  • await t1 是等一个已经在跑的任务结束。两个任务是一起等的,所以不是 0.5 秒,而是约 0.3 秒。
  • gather 会在内部把收到的协程包成任务。只需要把结果收齐时用 gather;要单独拿着、查看状态或者稍后再等时,用 create_task。

任务必须有人拿着引用

事件循环对任务只持有弱引用。没有放进变量或容器里的任务,可能还没跑完就被垃圾回收掉了。哪怕是倒水这种不需要结果的活,交代下去时也得攥着引用。

background = set()

async def refill_water(table):
    await asyncio.sleep(0.1)
    print(f"T{table} water")

async def main():
    for n in (1, 2, 3):
        task = asyncio.create_task(refill_water(n))
        background.add(task)                         # 攥住引用
        task.add_done_callback(background.discard)   # 做完后自己移出去
    print("pending", len(background))
    await asyncio.sleep(0.2)
    print("pending", len(background))

asyncio.run(main())
# pending 3
# T1 water
# T2 water
# T3 water
# pending 0

像 asyncio.create_task(refill_water(n)) 这样把结果直接扔掉的一行,大多数时候都能正常跑,可问题就出在“大多数时候”。偶尔消失的任务,是最难查的 bug。

9-6. asyncio.sleep 与 time.sleep(3 分钟)

两者都是“等一会儿”,可对小露来说完全是两码事。

async def polite(n):
    await asyncio.sleep(0.3)   # 等待期间把执行机会交给循环

async def stubborn(n):
    time.sleep(0.3)            # 连循环一起停住

async def main(fn):
    start = time.perf_counter()
    await asyncio.gather(*(fn(n) for n in range(3)))
    print(fn.__name__, f"{time.perf_counter() - start:.2f}s")

asyncio.run(main(polite))
asyncio.run(main(stubborn))
# polite 0.32s
# stubborn 0.90s
  • asyncio.sleep 在 await 的那一刻,跟循环说一声“0.3 秒后叫醒我”,然后离开。
  • time.sleep 让线程本身停住。跑循环的线程只有一个,这就等于小露站在桌子前傻等。这期间,没有人去照看别的桌子。
  • 所以三张桌子一张接一张地各等 0.3 秒,总共约 0.9 秒。用了 gather 也没用。

不只是 time.sleep,耗时很长的计算、等待时不交出执行权的老式库,也会同样让循环停住。这个问题和它的解法,是第 11 课的主题。

9-7. 协程、任务、Future,以及成本(3 分钟)

asyncio 里有三个名字很像的东西。

名称换成厨房来说作用
协程(coroutine)订单便条调用 async def 时产生。自己不会运行
任务(Task)已经递进厨房的订单包住协程,交给循环。有 done()、result()、cancel()
Future稍后会响的铃“将来会有值放进来的位置”。任务是 Future 的一种
print(issubclass(asyncio.Task, asyncio.Future))   # True

async def main():
    coro = take_order(1)
    task = asyncio.create_task(coro)
    print(type(coro).__name__, type(task).__name__)   # coroutine Task

思路和第 7 课的 concurrent.futures.Future 一样。不过 asyncio 的 Future 可以 await,并且只在同一个循环里使用。很少需要自己创建 Future,通常有协程和任务就够了。

任务很轻

线程是操作系统创建的,每一个都要带上栈内存和操作系统的管理开销。任务只是一个 Python 对象。

async def table(n):
    await asyncio.sleep(0.5)
    return n

async def main():
    return await asyncio.gather(*(table(n) for n in range(10_000)))

# 10,000 tasks: 约 0.57 秒   (1 万张各等 0.5 秒的桌子)
# 1,000 threads: 约 0.62 秒  (1 千个各等 0.5 秒的线程)
# 这台电脑上测的值

开 1 万个任务的开销,比 1 千个线程还小。要等的活有成千上万件时(聊天服务器、向大量地址发请求),asyncio 就很合适,原因就在这里。

打烊(10 分钟)

今晚的目标是:不加人手,0.5 秒内把十张桌子的菜全部送出去。一个小露、一个事件循环、一个 gather 就够了。

import asyncio
import time

async def table(n):
    await asyncio.sleep(0.3)   # 等厨房出菜
    return f"T{n}"

async def hall():
    return await asyncio.gather(*(table(n) for n in range(1, 11)))

start = time.perf_counter()
served = asyncio.run(hall())
print(served)
print(f"{time.perf_counter() - start:.2f}s")
# ['T1', 'T2', 'T3', 'T4', 'T5', 'T6', 'T7', 'T8', 'T9', 'T10']
# 0.30s

一个线程都没创建。十张各要 0.3 秒的桌子,约 0.3 秒就全部完成。

线程(第 2~7 课)asyncio(从今天起)
干活的人好几个厨师小露一个人
什么时候换人操作系统随时切换只在 await 处
每一个的成本大(操作系统线程)小(Python 对象)
要当心的竞争条件、死锁阻塞调用、漏写 await

小露放下空托盘。“十张桌子,一圈就搞定了。”主厨看着计时器问:“好。不过明天是团体预订。六张桌子里有一张取消了,剩下五张桌子的菜,你打算怎么叫停?”

营业日志 — 第九晚。小露一个人负责十张桌子。用等待时离开(await)和一起等(gather)的办法,0.3 秒上完菜。明天有团体预订。

课后练习

  1. 依次运行“不加 await 直接调用 9-1 的 take_order”和“用 asyncio.run() 调用它”这两行,比较输出和警告。
  2. 去掉 9-5 的 main 里的 await asyncio.sleep(0.1),输出顺序会怎么变?先把预测写下来,再运行确认。
  3. 把打烊代码里的桌子增加到 100 张、1,000 张,测一测耗时。再对比一下,用 1,000 个线程做同样的事会怎样。

主厨的配方卡

  • async def 创建协程。只调用不会执行(会有 was never awaited 警告)。

  • asyncio.run(main()):打开循环,把 main 跑完,再关闭。在最外层调用一次。

  • await:等到结束,期间把执行权交给循环。只有在 await 处才会切换。

  • await asyncio.gather(*coros):同时等,结果按传入顺序。还有 return_exceptions=True。

  • task = asyncio.create_task(coro):先交代下去。一定要攥住引用(弱引用)。

  • asyncio.sleep 会让出执行权,time.sleep 会让循环停住。

  • 协程(便条)→ 任务(递进厨房的订单)⊂ Future(稍后会响的铃)。1 万个任务也很轻。

← 上一课

第8课 · 面团为什么快不起来:GIL 与并行

下一课 →

第10课 · 团体预订取消了:结构化并发

订阅新课时和新课程通知

本课程新增课时或推出后续课程时,我们会发邮件通知你。

收集项目:电子邮箱 · 用途:本课程的新课时和上线通知 · 保存期限:直到你要求删除。不会用于其他用途,也不会提供给他人。回复任意通知邮件,我们会立即删除。

问答

关于本课有任何疑问都可以在这里留言。所有语言版本页面的提问会汇集在一起。

以纯文本显示,不支持链接和格式。

R

ReflexKR

动手学 Python 和 Reflex。所有示例均已在 Reflex 0.10.0 上实际运行验证。

ReflexKR 免费,没有横幅广告。如果对你有帮助,欢迎请我们喝杯咖啡。 ☕ 请我们喝杯咖啡

联盟链接与赞助说明
한국어English日本語简体中文EspañolDeutsch

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex