R

ReflexKR

Reflex 官方文档

深夜厨房里的并发

·

课程概览

1

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

2

多招几个厨师:threading 基础

3

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

4

只有三个灶台:同步工具

5

刀与砧板:死锁

6

订单夹:queue

7

外派厨师:concurrent.futures

8

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

9

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

10

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

11

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

12

评论家之夜:综合

深夜厨房里的并发
第7课

时间 5:00–5:50

外派厨师:concurrent.futures

今晚的订单

#07 · 第七晚

外派厨师

危机

一家公司聚餐,30 份团体订单一下子全进来了。总不能招 30 个厨师。一个个手动建线程、再把结果收进列表的代码,也早就写烦了。

目标

把 30 份订单交给六个外派厨师,1 秒内做完,做好一份就上一份。烧焦的盘子也一个不漏。

深夜食堂 同时

第七晚,十点半。电话响了,主厨放下听筒:“公司聚餐,团体,30 人份。”

敏浩一边重新系围裙一边问:“要建 30 个线程吗?”主厨摇摇头。“自己招人,管吃管住发工资,最后还得送人走,全是我们的事。今天给劳务公司打电话。人归他们管,我们只管把活扔过去。”

7-1. 把活交给劳务公司:ThreadPoolExecutor(10 分钟)

concurrent.futures.ThreadPoolExecutor 就是一家劳务公司:事先养着几个线程,活一来就交给空闲的线程。第 2 课里亲手做的那些事,建 Thread、start、join、准备装结果的列表,它全都替你干了。

import time
from concurrent.futures import ThreadPoolExecutor

def cook(n):
    time.sleep(0.2)
    return f"#{n} ready"

with ThreadPoolExecutor(max_workers=3) as agency:   # 三个外派厨师
    fut = agency.submit(cook, 1)        # 交出活,换回一张“取餐券”(Future)
    print(fut.done())                   # 还在做
    print(fut.result())                 # 等它做完,拿到结果
    print(fut.done())
# False
# #1 ready
# True
  • submit(函数, 参数...) 交出活后会立刻返回一个 Future。它不是菜本身,而是“凭它取菜的券”。print(fut) 一下,能看到 <Future at 0x... state=running> 这样的状态。
  • fut.result() 会一直等到菜做好,然后返回函数的返回值。第 2 课里“线程不返回值”、只好往列表里塞的问题,到这里就解决了。
  • 离开 with 块时会调用 shutdown(wait=True):等交出去的活全部做完,再把线程收拾干净。

现在交出 30 份。厨师只有五个,所以每人轮流接六份。

import threading

def cook(n):
    time.sleep(0.1)
    return threading.current_thread().name

start = time.perf_counter()
with ThreadPoolExecutor(max_workers=5, thread_name_prefix="agency") as agency:
    futures = [agency.submit(cook, n) for n in range(1, 31)]   # 30 份订单
# 出了 with 块,所有任务都已经做完了
names = {f.result() for f in futures}
print(len(futures), sorted(names), f"{time.perf_counter() - start:.2f}s")
# 30 ['agency_0', 'agency_1', 'agency_2', 'agency_3', 'agency_4'] 0.60s

订单有 30 份,线程却只建了五个。30 份 0.1 秒的订单由五个人分担,用时 0.6 秒。建线程、销毁线程的开销只付一次,同一个厨师一单接一单地做下去。这就叫线程池(thread pool)。

7-2. 完成顺序与提交顺序:as_completed 和 map(10 分钟)

拿结果有两种办法。这次让 #1 订单最费时间。

from concurrent.futures import as_completed

def cook(n):
    time.sleep(0.1 * (5 - n))     # #1 最费时间(0.4 秒 … 0.1 秒)
    return f"#{n}"

with ThreadPoolExecutor(max_workers=4) as agency:
    start = time.perf_counter()
    futures = [agency.submit(cook, n) for n in range(1, 5)]
    for f in as_completed(futures):                 # 完成的顺序
        print(f.result(), f"{time.perf_counter() - start:.1f}s", end="  ")
    print()
    start = time.perf_counter()
    for dish in agency.map(cook, range(1, 5)):      # 提交的顺序
        print(dish, f"{time.perf_counter() - start:.1f}s", end="  ")
    print()
# #4 0.1s  #3 0.2s  #2 0.3s  #1 0.4s
# #1 0.4s  #2 0.4s  #3 0.4s  #4 0.4s
as_completed(futures)executor.map(函数, 多个值)
返回什么一个一个地返回已完成的 Future一个一个地返回结果值
顺序完成的顺序提交的顺序
第一个结果最快的那个一做完就出来要等 #1 做完
适合的场合做好一份上一份、显示进度结果要和输入一一对应、原样使用时

用 map 的话,#4 虽然 0.1 秒就做好了,也要等到 #1 做完的 0.4 秒才交出来,因为它得守住提交的顺序。总耗时一样,但第一盘菜上桌的时间不一样。

as_completed 只返回 Future,想知道是哪一单的结果,惯例是用 dict 把两者配好对。

with ThreadPoolExecutor(max_workers=4) as agency:
    table_of = {agency.submit(cook, n): f"T{n}" for n in range(1, 5)}   # Future → 桌号
    for fut in as_completed(table_of):
        print(table_of[fut], fut.result())
# T4 #4
# T3 #3
# T2 #2
# T1 #1

下面时间线里的“外派厨师”,跑的也是真正的 ThreadPoolExecutor。这个场景特意设计成编号越靠后的订单越快做完。

厨房时间线

订单

6

厨师

3

结果返回顺序

烹饪

等待

已取消

失败

10 份订单,3 个外派厨师。把“结果返回顺序”在 as_completed 和 map 之间来回切换,比一比长条下方的“返回顺序”。
  • 两种方式都要 1.0 秒左右,长条的形状也几乎一样。厨师们干活的方式没变,只是拿结果的顺序不同。
  • 用 as_completed 时,返回顺序是乱的,比如 #3 → #2 → #1 → #6 → #5 → #4 → …。因为三个人同时开工的订单里,短的先做完。
  • 用 map 时,永远是 #1 → #2 → … → #10。
  • 把厨师加到最多的 8 人,大约 0.4 秒就做完了,as_completed 的返回顺序几乎完全倒过来,像 #8 → #7 → … → #1。因为八份订单同时开工,编号靠后的短单先做完。

7-3. 烧焦的盘子,要等交券时才露馅(7 分钟)

交出去的活里要是抛了异常,这个异常会被悄悄存进 Future 里。直到你调用 result() 的那一刻,它才在那里重新炸开。

def cook(n):
    if n == 3:
        raise ValueError(f"#{n} burnt")    # 3 号订单烧焦了
    return f"#{n}"

with ThreadPoolExecutor(max_workers=2) as agency:
    futures = [agency.submit(cook, n) for n in range(1, 5)]
# 到这里为止一片安静,好像什么都没发生

for f in futures:
    try:
        print(f.result())
    except ValueError as e:                # 调用 result() 时才炸
        print("error:", e)
# #1
# #2
# error: #3 burnt
# #4

print(repr(futures[2].exception()), futures[0].exception())
# ValueError('#3 burnt') None
  • 要是从来不调用 result(),异常就会什么也不打印地消失。好比烧焦的盘子虽然没端给客人,却也没有任何人知道。
  • exception() 能把异常取出来,而不让它炸开。成功的话返回 None。
  • map 在取结果的过程中会当场抛出异常,之后的结果就拿不到了。如果一单失败了还要拿到其余的,就用 submit 加 as_completed。

想在一做完就马上干点什么,可以用 add_done_callback 挂个铃铛。传进去一个函数,它接收一个已完成的 Future 作为参数。

def cook(n):
    time.sleep(0.1)
    return f"#{n}"

def ring_bell(fut):                      # 做完时被调用的函数(回调)。接收一个 Future
    print("bell:", fut.result(), threading.current_thread().name)

with ThreadPoolExecutor(max_workers=2, thread_name_prefix="agency") as agency:
    for n in (1, 2):
        agency.submit(cook, n).add_done_callback(ring_bell)
# 例:
# bell: #1 agency_0
# bell: #2 agency_1

回调是在干完活的那个外派厨师的线程里被调用的(如果挂到一个已经完成的 Future 上,就在挂铃铛的那个人的线程里立刻调用)。所以如果在回调里修改共享数据,第 3 课的锁规矩照样适用。回调里抛的异常只会记进日志,然后被忽略。

主厨的提醒

只 submit 不调 result 的代码,是我在现场见得最多的错。异常就在取餐券里睡着了。交出去的活,要么用 result 拿回来,要么用 exception 查一查,必须二选一。

7-4. 取消,以及只等一个(8 分钟)

cancel() 只能取消还没开始的活。已经下锅的菜,可就收不回来了。

with ThreadPoolExecutor(max_workers=1) as agency:     # 一个厨师
    futures = [agency.submit(time.sleep, 0.2) for _ in range(3)]
    time.sleep(0.05)                                  # 给第一单下锅的时间
    print([f.cancel() for f in futures])              # 已经开始的收不回来
    print([f.cancelled() for f in futures], [f.done() for f in futures])
# [False, True, True]
# [False, True, True] [False, True, True]
  • cancel() 取消成功返回 True;已经在做或者做完了,返回 False。
  • done() 在做完或已取消时为 True。第一单还在做,所以是 False。

要一次性结束和劳务公司的合同,就用 shutdown(cancel_futures=True)。排着队的活全部取消,只等已经开始的活做完。

agency = ThreadPoolExecutor(max_workers=2)
futures = [agency.submit(time.sleep, 0.2) for _ in range(6)]
time.sleep(0.05)
agency.shutdown(cancel_futures=True)     # 排队的订单取消,锅上的订单做完
print(sum(f.cancelled() for f in futures), sum(not f.cancelled() for f in futures))
# 4 2

一堆活里只要有一个做完就想行动,用 wait。

from concurrent.futures import FIRST_COMPLETED, wait

def cook(sec):
    time.sleep(sec)
    return sec

with ThreadPoolExecutor(max_workers=3) as agency:
    futures = [agency.submit(cook, s) for s in (0.3, 0.1, 0.2)]
    done, pending = wait(futures, return_when=FIRST_COMPLETED)   # 只要有一个做完
    print([f.result() for f in done], len(pending))
    done, pending = wait(futures, timeout=0.15)                  # 默认是 ALL_COMPLETED
    print(len(done), len(pending))
# [0.1] 2
# 2 1

wait 返回两个集合:做完的和没做完的。return_when 可选 FIRST_COMPLETED、FIRST_EXCEPTION、ALL_COMPLETED(默认值);过了 timeout,没全做完也会返回。它不会抛异常,所以得自己检查 pending 是不是空的。

7-5. 同一份合同,不同的公司(5 分钟)

concurrent.futures 的好处在于合同是同一份。submit、map、Future、as_completed 都不用动,只换公司名就行。

from concurrent.futures import InterpreterPoolExecutor, ProcessPoolExecutor, ThreadPoolExecutor

def knead(n):
    return sum(range(n))

if __name__ == "__main__":               # 进程池必须有这一句(第 8 课)
    for Agency in (ThreadPoolExecutor, ProcessPoolExecutor, InterpreterPoolExecutor):
        with Agency(max_workers=2) as agency:
            print(Agency.__name__, list(agency.map(knead, [10, 100, 1000])))
# ThreadPoolExecutor [45, 4950, 499500]
# ProcessPoolExecutor [45, 4950, 499500]
# InterpreterPoolExecutor [45, 4950, 499500]
公司厨师适合的活课
ThreadPoolExecutor同一个厨房里的线程要等的活(I/O)7
ProcessPoolExecutor分店厨房(进程)手不停的活(CPU)8
InterpreterPoolExecutor同一栋楼里隔开的小厨房(子解释器),3.14 起手不停的活,比进程更轻8

这个例子别在交互式 shell 里跑,要存成 .py 文件再运行。进程池为什么非要 if __name__ == "__main__":,明晚再说。

省略 max_workers 时,线程池会叫来 min(32, os.process_cpu_count() + 4) 个人(3.12 及以前是 os.cpu_count())。在这台 12 核电脑上就是 16 个。要等的活需要比核数更多的线程,所以加 4;又怕太多,所以封顶 32。实际的服务里,最好考虑对方服务器能扛住多少,自己定这个数。

打烊(10 分钟)

今晚的目标是:把 30 份订单交给六个外派厨师,1 秒内做好一份上一份,烧焦的盘子也一个不漏。每份订单要 0.1~0.2 秒,13 号订单会烧焦。

def cook(n):
    time.sleep(0.1 + 0.05 * (n % 3))     # 0.1~0.2 秒
    if n == 13:
        raise ValueError(f"#{n} burnt")
    return f"#{n}"

served, burnt = [], []
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=6, thread_name_prefix="agency") as agency:
    futures = {agency.submit(cook, n): n for n in range(1, 31)}
    for fut in as_completed(futures):     # 做好一份上一份
        try:
            served.append(fut.result())
        except ValueError:
            burnt.append(futures[fut])
print(len(served), burnt, f"{time.perf_counter() - start:.2f}s")
# 29 [13] 0.80s(时间只是大约)

29 盘菜做好一份上一份,烧焦的 13 号进了重做清单。用时 0.8 秒。没有建线程的代码,也没有事先准备好装结果的列表。

靠窗的客人今天也是一个人,面前一碗面。团体订单的盘子一盘盘端出去的时候,她看了一眼手表,在小本子上简短地记了点什么。

主厨送走外派厨师们,说道:“明天要揉的面堆成山。叫四个外派厨师来揉,能快四倍吗?”

营业日志 — 第七晚。30 份团体订单交给 6 个外派厨师(ThreadPoolExecutor),0.8 秒做完。13 号烧焦了,但在 result() 那里当场抓到,重新做了一份。

课后练习

  1. 把打烊代码里的 as_completed 换成 agency.map(cook, range(1, 31))。到 13 号时会发生什么?能拿到几盘菜?
  2. 在时间线上把厨师减到 1 人(大约要 2.8 秒),比较 as_completed 和 map。为什么返回顺序变得一样了?
  3. 用 wait(..., return_when=FIRST_COMPLETED) 和 shutdown(cancel_futures=True) 写一段代码:30 份订单里只要第一盘做好的,其余全部取消。已经开始做的菜有几份?

主厨的配方卡

  • with ThreadPoolExecutor(max_workers=n) as ex: fut = ex.submit(f, x); fut.result()

  • Future 是取餐券:done()、result()、exception()、cancel()、add_done_callback(fn)。

  • as_completed(futures) 按完成顺序,ex.map(f, xs) 按提交顺序。

  • 任务里的异常在调用 result() 时才炸。不调用,它就悄悄消失。

  • cancel() 只对还没开始的任务有效。一次性取消用 ex.shutdown(cancel_futures=True)。只等一个用 wait(fs, return_when=FIRST_COMPLETED)。

  • 同一份合同:ProcessPoolExecutor(第 8 课)、InterpreterPoolExecutor(3.14)。默认线程数是 min(32, os.process_cpu_count() + 4)。

← 上一课

第6课 · 订单夹:queue

下一课 →

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

订阅新课时和新课程通知

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

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

问答

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

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

R

ReflexKR

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

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

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

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

Built with Reflex