R

ReflexKR

Reflex 官方文档

深夜厨房里的并发

·

课程概览

1

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

2

多招几个厨师:threading 基础

3

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

4

只有三个灶台:同步工具

5

刀与砧板:死锁

6

订单夹:queue

7

外派厨师:concurrent.futures

8

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

9

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

10

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

11

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

12

评论家之夜:综合

深夜厨房里的并发
第10课

时间 7:30–8:20

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

今晚的订单

#10 · 第十晚

团体预订取消了

危机

团体预订,六张桌。3 号桌因为过敏问题退了套餐,整个团体说干脆改天一起来。可另外五桌的锅还在咕嘟咕嘟地煮着。

目标

一桌失败,其余的菜马上停下;而且任何一道菜都不许超过规定的时间。

深夜食堂 同时

晚上十点半,六桌团体客人同时落座。小露接了单递进厨房,厨房同时开做六套菜。十分钟后,3 号桌有人举手:花生过敏,刚才忘了说。团体的负责人说:“那今天我们就一起先走了。”

敏浩没听见,还在给 5 号桌煮汤。秀雅一把关掉他的火:“从来没定过‘一桌退了,大家都停’这条规矩啊。”

尹瑞妍主厨按下计时器。“一起开始的事,就得一起结束。今晚的活,就是把这条约定写进代码。”

10-1. gather 的漏洞:一桌失败了,其余照样在煮(5 分钟)

先用第 9 课的 asyncio.gather 来处理团体预订。2 号桌 0.1 秒就失败了。

import asyncio

async def table(n, sec, fail=False):
    await asyncio.sleep(sec)
    if fail:
        raise ValueError(f"T{n} allergy")
    print(f"T{n} served")

async def main():
    try:
        await asyncio.gather(table(1, 0.3), table(2, 0.1, fail=True), table(3, 0.5))
    except ValueError as e:
        print("caught:", e)
    await asyncio.sleep(0.6)   # 让店再多开一会儿

asyncio.run(main())
# caught: T2 allergy
# T1 served
# T3 served
  • gather 一碰到第一个异常就立刻往上抛,所以 caught: 最先打印出来。
  • 可另外两桌并没有被取消。没人在等的菜在后台继续煮,最后还打印出了 served。
  • 换成真实的程序,就是给已经走掉的客人上菜,或者让一堆没人接收结果的请求继续跑着。

gather(return_exceptions=True) 只是把异常装进结果列表里,并不会叫停其余的任务。想要“一个失败,全部停下”,得换别的工具。

10-2. TaskGroup:一起开始的事,一起结束(12 分钟)

asyncio.TaskGroup(3.11)把任务当作一组来管理。这种成组管理的做法,叫作结构化并发(structured concurrency)。

import asyncio

async def table(n, sec):
    await asyncio.sleep(sec)
    return f"T{n}"

async def main():
    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(table(n, 0.1 * n)) for n in range(1, 4)]
    # 走到这里,三个任务都已经结束了
    print([t.result() for t in tasks])

asyncio.run(main())
# ['T1', 'T2', 'T3']

TaskGroup 做出三条承诺。

  1. 离开 async with 块时,在块里创建的任务全都已经结束。不用再一个个 await。
  2. 只要有一个任务抛出异常,就把还在跑的其余任务全部取消。
  3. 取消完成后,把失败的那些异常打包进一个 ExceptionGroup 往上抛。

tg.create_task 会替你攥着任务,所以第 9 课说的“丢了引用、任务就没了”也不用担心。下面是 3 号桌失败的团体预订。

import asyncio

async def table(n, sec, fail=False):
    try:
        await asyncio.sleep(sec)
        if fail:
            raise ValueError(f"T{n} allergy")
        print(f"T{n} served")
    except asyncio.CancelledError:
        print(f"T{n} cancelled")
        raise                      # 收到的取消要原样抛回去

async def main():
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(table(1, 0.05))
            tg.create_task(table(2, 0.3))
            tg.create_task(table(3, 0.1, fail=True))
            tg.create_task(table(4, 0.5))
    except* ValueError as eg:
        print("group failed:", eg.exceptions)
    await asyncio.sleep(0.6)

asyncio.run(main())
# T1 served
# T2 cancelled      (T2 和 T4 的先后每次运行都不同)
# T4 cancelled
# group failed: (ValueError('T3 allergy'),)
  • T1 在失败之前就做完了,照常上菜。
  • T3 在 0.1 秒失败的那一刻,还在煮的 T2 和 T4 收到了取消。取消消息打印的先后,每次运行可能不一样。
  • 再多等 0.6 秒,也不会再打印 served。和 gather 不同,没有哪口锅在后台偷偷煮着。
  • 被取消的任务里,会在 await 那里抛出 asyncio.CancelledError。所以可以用 except 接住它做收尾。收到的取消一定要再抛回去(10-5)。

下面的时间线在服务器上跑的是真正的 TaskGroup。桌号越大,菜就做得越久一点(T1 0.3 秒,T2 0.345 秒,…… T6 约 0.53 秒)。被选为“会失败的那桌”,会在自己的菜快做完时失败。

厨房时间线

订单

6

会失败的那桌

无

时间限制(秒)

关

烹饪

等待

已取消

失败

六桌团体客人。“会失败的那桌”设为 3 时,T1、T2 先做完;T3 以红色长条结束的那一刻,T4~T6 被灰色(已取消)截断。也把它换成 1 或 6 试试。设为“无”就全部完成;把时间限制调到 0.4 秒左右,到点还没做完的桌会被取消。
  • 初始设置(会失败的那桌是 3)下,T1、T2 先做完;大约 0.4 秒 T3 失败的那一刻,T4~T6 三根长条在同一瞬间变灰截断。已经做完的 T1、T2 保持原样。
  • 把会失败的那桌换成 1,0.3 秒时其余五桌全部被截断;换成 6,五桌全做完之后,只有 T6 失败。
  • 失败设为“无”,六根长条都会走到底。最慢的 T6 大约 0.53 秒做完。
  • 把时间限制调到 0.4 秒左右,只有前面几桌能做完,到点还剩下的桌会被灰色截断。已经做好的菜不会被撤回。

10-3. ExceptionGroup 与 except*(7 分钟)

几个任务也可能同时失败。所以 TaskGroup 抛出的不是一个异常,而是一组异常。

import asyncio

async def boom(exc, sec):
    await asyncio.sleep(sec)
    raise exc

async def main():
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(boom(ValueError("T2 allergy"), 0.1))
            tg.create_task(boom(KeyError("T5"), 0.1))
    except Exception as e:
        print(type(e).__name__, "|", e)
        print(e.exceptions)

asyncio.run(main())
# ExceptionGroup | unhandled errors in a TaskGroup (2 sub-exceptions)
# (ValueError('T2 allergy'), KeyError('T5'))

ExceptionGroup(3.11)是一种把异常列表装在 .exceptions 里的异常。要在这一组里按类型分别接住,就用 except*。

eg = ExceptionGroup("kitchen", [ValueError("T2 allergy"), KeyError("T5"), ValueError("T6 salt")])

try:
    raise eg
except* ValueError as g:
    print("ValueError:", [str(e) for e in g.exceptions])
except* KeyError as g:
    print("KeyError:", g.exceptions)
# ValueError: ['T2 allergy', 'T6 salt']
# KeyError: (KeyError('T5'),)
  • 普通的 except 只会执行一个。except* 则会执行所有匹配的块。上面两个块都跑了。
  • g 永远是 ExceptionGroup,是只装着匹配异常的一个小组。
  • 哪个 except* 都没匹配上的异常,会组成剩下的一组,继续往上抛。
eg = ExceptionGroup("kitchen", [ValueError("T2"), KeyError("T5")])
try:
    try:
        raise eg
    except* ValueError:
        print("handled ValueError")
except ExceptionGroup as rest:
    print("left:", rest.exceptions)
print(isinstance(eg, Exception), eg.subgroup(ValueError).exceptions)
# handled ValueError
# left: (KeyError('T5'),)
# True (ValueError('T2'),)

except* 有两条规矩。在 3.14 里,两条都会在运行前就被当作语法错误拦下来。

src1 = '''
def f():
    try:
        pass
    except* ValueError:
        return 1
'''
src2 = '''
try:
    pass
except ValueError:
    pass
except* KeyError:
    pass
'''
for src in (src1, src2):
    try:
        compile(src, "<menu>", "exec")
    except SyntaxError as e:
        print("SyntaxError:", e.msg)
# SyntaxError: 'break', 'continue' and 'return' cannot appear in an except* block
# SyntaxError: cannot have both 'except' and 'except*' on the same 'try'
  • except* 块里不能用 return、break、continue。几个块要依次执行,中途跳出去就乱套了。把结果存进变量,到 try 外面再返回。
  • 同一个 try 里不能混用 except 和 except*。两种都需要,就把 try 拆成两层。本课程实验台的引擎也是这么写的。

主厨的提醒

在 except* 里想 return 的时候,把结果记进变量,到 try 外面再交出去。厨房也一样:事故报告得全部收齐了才下班,可不能听完第一份就关门走人。

10-4. 时间限制:asyncio.timeout 与 wait_for(8 分钟)

今晚的第二条规矩是“任何一道菜都不许超时”。asyncio.timeout()(3.11)给整个块加上时间限制。

import asyncio, time

async def stew(sec):
    await asyncio.sleep(sec)
    return "stew"

async def main():
    start = time.perf_counter()
    try:
        async with asyncio.timeout(0.2):
            await stew(1.0)
    except TimeoutError:
        print(f"timeout after {time.perf_counter() - start:.2f}s")

    try:
        print(await asyncio.wait_for(stew(0.1), timeout=0.2))
        print(await asyncio.wait_for(stew(1.0), timeout=0.2))
    except TimeoutError:
        print("wait_for: timeout")

    print(asyncio.TimeoutError is TimeoutError)

asyncio.run(main())
# timeout after 0.20s
# stew
# wait_for: timeout
# True
  • 一超时,块里的活就会被取消,块外面会收到内置的 TimeoutError。从 3.11 开始,asyncio.TimeoutError 和内置的 TimeoutError 就是同一个东西。
  • asyncio.wait_for(协程, timeout=) 是给单个协程加限制的老办法。需要直接拿到结果时很方便。
  • asyncio.timeout 能把好几个 await 包进一个块里,比如“从点单到上菜,加起来不超过 20 分钟”。

限制也可以不按“几秒之后”,而按“几点几分”来设,还可以在进入块之后再改。

import asyncio, time

async def main():
    loop = asyncio.get_running_loop()
    start = time.perf_counter()
    deadline = loop.time() + 0.3            # 从现在起 0.3 秒后这个“时刻”
    try:
        async with asyncio.timeout_at(deadline):
            await asyncio.sleep(1)
    except TimeoutError:
        print(f"timeout_at: {time.perf_counter() - start:.1f}s")

    start = time.perf_counter()
    try:
        async with asyncio.timeout(None) as cm:   # 一开始不设限
            cm.reschedule(loop.time() + 0.2)      # 之后再定为 0.2 秒
            print(cm.when() is not None)
            await asyncio.sleep(1)
    except TimeoutError:
        print(f"reschedule: {time.perf_counter() - start:.1f}s, expired={cm.expired()}")

asyncio.run(main())
# timeout_at: 0.3s
# True
# reschedule: 0.2s, expired=True
  • timeout_at(时刻) 接收的是以 loop.time() 为基准的绝对时刻。好几个步骤共用同一个“截止时刻”时很好用。
  • timeout(None) 不带限制地开始。之后可以用 cm.reschedule(时刻) 设定或推迟限制。

10-5. 取消:cancel、CancelledError、finally(6 分钟)

TaskGroup 也好,时间限制也好,说到底干的是同一件事:给任务发取消。要亲手发,就用 task.cancel()。

import asyncio

async def braise():
    print("stove on")
    try:
        await asyncio.sleep(10)
    finally:
        print("stove off")          # 就算被取消,火也要关

async def main():
    task = asyncio.create_task(braise())
    await asyncio.sleep(0.1)
    task.cancel()
    try:
        await task
    except asyncio.CancelledError:
        print("cancelled:", task.cancelled())

asyncio.run(main())
# stove on
# stove off
# cancelled: True
  • cancel() 不会当场把任务杀掉。它只是让任务在下一个停着的 await 处抛出 CancelledError。
  • 关火、关连接这类收尾,放进 try/finally。不管是取消、成功还是失败,都一定会执行。
  • CancelledError 继承的不是 Exception,而是 BaseException。这样设计,是为了不让 except Exception: 不小心把取消吞掉。

要是把取消吞掉,会怎样?

import asyncio, time

async def stubborn():
    try:
        await asyncio.sleep(0.5)
    except asyncio.CancelledError:
        pass                          # 把取消吞了
    await asyncio.sleep(0.5)          # 然后接着做菜
    return "late"

async def main():
    start = time.perf_counter()
    try:
        async with asyncio.timeout(0.1):
            print(await stubborn())
    except TimeoutError:
        print("timeout")
    print(f"{time.perf_counter() - start:.1f}s")

asyncio.run(main())
# late
# 0.6s

明明设了 0.1 秒的限制,却花了 0.6 秒,TimeoutError 也没出现。时间限制靠取消来干活,可这个取消被协程吃掉了。TaskGroup 的“取消其余任务”也会这样垮掉。所以接到 CancelledError 后,只做收尾,然后再抛回去(raise)。

主厨的提醒

接到取消,就关火、擦砧板,然后直接举手。一想着“再煮一会儿就好了”接着煮,厨房的规矩就全塌了。敏浩,说的就是你。

10-6. 挡住取消的 shield,数取消的 cancelling(2 分钟)

有些活,比如保存收据,中途绝不能断。asyncio.shield() 能挡住外面来的取消,不让它传到里面的任务。

import asyncio

async def save_receipt():
    await asyncio.sleep(0.3)
    print("receipt saved")
    return "ok"

async def main():
    inner = asyncio.create_task(save_receipt())
    try:
        async with asyncio.timeout(0.1):
            await asyncio.shield(inner)
    except TimeoutError:
        print("waiter gave up")
    print(await inner)              # 里面的任务一直在跑

asyncio.run(main())
# waiter gave up
# receipt saved
# ok

等着的一方 0.1 秒就放弃了,里面的任务却跑完了全程。得一直攥着里面那个任务的引用,之后才能拿到结果。

Task.cancelling() 返回该任务收到的取消请求数,Task.uncancel() 把这个数减一。这是 asyncio.timeout 和 TaskGroup 在内部分辨“这个取消是不是我发的”时用的工具,自己直接用到的机会很少。

import asyncio

async def worker():
    try:
        await asyncio.sleep(1)
    except asyncio.CancelledError:
        me = asyncio.current_task()
        print("cancelling:", me.cancelling())
        me.uncancel()
        print("after uncancel:", me.cancelling())
        return "rescued"

async def main():
    t = asyncio.create_task(worker())
    await asyncio.sleep(0.05)
    t.cancel()
    print(await t)

asyncio.run(main())
# cancelling: 1
# after uncancel: 0
# rescued

打烊(10 分钟)

把今晚的两条规矩用在同一个厨房里。每桌限时 0.6 秒,六桌绑进同一个 TaskGroup。

import asyncio, time

async def table(n, sec, allergy, stopped):
    try:
        async with asyncio.timeout(0.6):     # 任何一道菜都不超过 0.6 秒
            await asyncio.sleep(sec)
            if n == allergy:
                raise ValueError(f"T{n} allergy")
            return f"T{n}"
    except asyncio.CancelledError:
        stopped.append(f"T{n}")             # 关火,举手
        raise

async def service(menu, allergy=None):
    start, stopped = time.perf_counter(), []
    try:
        async with asyncio.TaskGroup() as tg:
            tasks = [tg.create_task(table(n, s, allergy, stopped)) for n, s in menu.items()]
        print("served:", [t.result() for t in tasks])
    except* ValueError as eg:
        print("cancelled by:", [str(e) for e in eg.exceptions], "stopped:", sorted(stopped))
    except* TimeoutError as eg:
        print("too slow:", len(eg.exceptions))
    print(f"{time.perf_counter() - start:.1f}s")

MENU = {1: 0.30, 2: 0.35, 3: 0.20, 4: 0.40, 5: 0.45, 6: 0.50}
asyncio.run(service(MENU))
asyncio.run(service(MENU, allergy=3))
asyncio.run(service({**MENU, 6: 0.9}))
# served: ['T1', 'T2', 'T3', 'T4', 'T5', 'T6']
# 0.5s
# cancelled by: ['T3 allergy'] stopped: ['T1', 'T2', 'T4', 'T5', 'T6']
# 0.2s
# too slow: 1
# 0.6s
  • 平常的夜晚,六桌一起煮,跟着最慢的 T6,大约 0.5 秒做完。
  • 3 号桌在 0.2 秒失败时,其余五桌当场关火。
  • 有一道菜超过 0.6 秒,那一桌的 TimeoutError 就会打包抛上来,其余的同样停下。

快打烊时,靠窗的客人吃完了那碗面,合上了小本子。结账时,她对主厨简短地说了一句:“下周五我再来。”

主厨点点头,回头看向副厨。“取消也好,时间限制也好,说到底都发生在 await 那里。可厨房角落那台旧烤箱,根本不懂什么叫 await。小露要是站在它跟前干等,前厅会变成什么样?”

营业日志 — 第十晚。六桌团体绑进同一个 TaskGroup,3 号桌一退,其余的锅同时关火。每桌都设了时间限制,并把“不许吞掉取消”的规矩贴在了厨房墙上。

课后练习

  1. 在时间线上把“会失败的那桌”从 1 换到 6。解释一下:为什么只有桌号比失败那桌小的桌能活下来?接着把失败设为“无”,把时间限制从 0.3 秒一路调到 0.55 秒,看看能走到底的桌是怎么一桌桌变多的。
  2. 把 10-1 的 gather 例子改成 TaskGroup,让 2 号桌失败时,1 号和 3 号打印出 cancelled。
  3. 把打烊代码里 table 的 raise 删掉,在那个位置换成 await asyncio.sleep(0.3)(无视取消,接着煮),然后用 allergy=3 运行。看看耗时怎么变了,写下哪里出了问题。

主厨的配方卡

  • gather 在一个失败时不会叫停其余任务。必须一起结束的活,用 TaskGroup(3.11)绑在一起。

  • async with asyncio.TaskGroup() as tg: tg.create_task(...) — 出了块就全部结束;一个失败,其余取消。

  • 失败会汇总成 ExceptionGroup。用 except* ValueError 按类型接(不许 return,不许和 except 混用)。

  • 时间限制:async with asyncio.timeout(秒) 或 wait_for(协程, timeout=)。超时抛内置 TimeoutError。

  • 取消会在 await 处以 CancelledError 的形式到来。用 finally 收尾,收到的取消要再 raise。

  • 不能被打断的活用 asyncio.shield(task)。cancelling()/uncancel() 是写工具的人用的机关。

← 上一课

第9课 · 一个人跑全场的前厅经理:asyncio 入门

下一课 →

第11课 · 前厅停住了:阻塞调用与混合使用

订阅新课时和新课程通知

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

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

问答

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

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

R

ReflexKR

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

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

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

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

Built with Reflex