今夜の注文書
#07 · 七日目の夜
ピンチ
宴会の団体注文が30件、一度に入った。料理人を30人雇うわけにはいかない。スレッドをひとつずつ作って結果をリストに集めるコードにも、もううんざりだ。
目標
30件の注文を出張料理人6人に任せて1秒以内に、できたものから出す。焦がした皿があっても見逃さない。
深夜食堂 ドンシ
七日目の夜10時半。電話が鳴り、受話器を置いたシェフが言います。「宴会の団体、30人前」
ミンホがエプロンを締め直しながら尋ねます。「スレッドを30個作りますか?」 シェフは首を横に振ります。「人を雇ったら、寝る場所も給料も、帰すところまで全部こっちの仕事になる。今日は派遣会社に電話する。人の管理は向こうがやって、こっちは仕事を投げるだけ」
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: # 出張料理人3人
fut = agency.submit(cook, 1) # 仕事を任せると「引換券」(Future)をくれる
print(fut.done()) # まだ調理中
print(fut.result()) # 終わるまで待ってから結果を受け取る
print(fut.done())
# False
# #1 ready
# Truesubmit(関数, 引数...) は仕事を任せて、すぐに Future を返します。料理ではなく「料理を受け取るための引換券」です。print(fut) してみると、<Future at 0x... state=running> のように状態が見えます。fut.result() は料理が終わるまで待ってから、関数の戻り値を返します。2限目で「スレッドは戻り値を返さない」からとリストに入れていた問題が、ここで解決します。with ブロックを抜けるときに shutdown(wait=True) が呼ばれ、任せた仕事がすべて終わるまで待ってからスレッドを片づけます。では30件を任せてみましょう。料理人は5人しかいないので、一人が6件ずつ順番に受け持つことになります。
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件なのに、スレッドは5本しか作られていません。0.1秒の注文30件を5人で分けて0.6秒。スレッドを作って捨てるコストは一度払うだけで、同じ料理人が次々と次の注文を受け取ります。これをスレッドプール(thread pool)といいます。
結果の受け取り方は2通りあります。今度は#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.4sas_completed(futures) | executor.map(関数, 値の並び) | |
|---|---|---|
| 返すもの | 終わった Future をひとつずつ | 結果の値をひとつずつ |
| 順番 | 終わった順 | 入れた順 |
| 最初の結果 | いちばん早く終わったものが出たらすぐ | #1が終わるまで待つ |
| 向いている場面 | できたものから出す、進捗の表示 | 結果を入力と対応させてそのまま使うとき |
map は、#4が0.1秒で終わっていても、#1が終わる0.4秒まで渡してくれません。入れた順を守らなければならないからです。全体の時間は同じでも、最初の一皿が出るまでの時間が違います。
as_completed は Future しか返さないので、どの注文の結果かを知るには、辞書で対応を作っておくのが定番です。
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
結果を受け取る順
調理
待ち
キャンセル
失敗
as_completed では、戻ってきた順がたとえば #3 → #2 → #1 → #6 → #5 → #4 → … のように入り乱れます。3人が同時に始めた注文のうち、短いものが先に終わるからです。map では、いつも #1 → #2 → … → #10 です。as_completed の戻ってきた順は #8 → #7 → … → #1 のようにほぼ逆順になります。8件の注文が一斉に始まり、短い後ろの番号から終わるからです。任せた仕事の中で例外が起きると、その例外は 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') Noneresult() を一度も呼ばなければ、例外はどこにも出力されずに消えてしまいます。焦がした皿がお客さんに出なかったのに、誰も気づかないようなものです。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で確かめるかして。
cancel() で取り消せるのはまだ始まっていない仕事だけです。もう火にかけた料理は元に戻せません。
with ThreadPoolExecutor(max_workers=1) as agency: # 料理人1人
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 1wait は、終わったものと終わっていないものの2つの集合を返します。return_when には FIRST_COMPLETED、FIRST_EXCEPTION、ALL_COMPLETED(デフォルト)があり、timeout が過ぎれば全部終わっていなくても戻ってきます。例外は送出しないので、pending が空かどうかは自分で確かめる必要があります。
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 |
この例は対話型シェルではなく、.py ファイルに保存して実行してください。プロセスプールがなぜ if __name__ == "__main__": を必要とするのかは、次の夜に見ます。
max_workers を省略すると、スレッドプールは min(32, os.process_cpu_count() + 4) 人を呼びます(3.12までは os.cpu_count())。12コアのこのPCでは16人です。待つ仕事にはコア数より多いスレッドが必要なので4を足し、増えすぎないよう32で打ち止めにしています。実際のサービスでは、相手のサーバーがさばける数を考えて自分で決めるほうがいいでしょう。
今夜の目標は、30件の注文を出張料理人6人に任せて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秒。スレッドを作るコードも、結果を入れるリストを前もって用意するコードもありません。
窓際の客が、今夜もひとりで麺を一杯前にしています。団体の皿が次々と運ばれていくあいだ、腕時計に一度目をやり、小さな手帳に何かを短く書きつけます。
シェフが出張料理人たちを帰しながら言います。「明日は生地が山ほどある。出張料理人を4人呼んで生地を任せたら、4倍速くなると思う?」
営業日誌 — 七日目の夜。団体注文30件を出張料理人6人(ThreadPoolExecutor)に任せて0.8秒で処理。13番を焦がしたが、result()ですぐに捕まえて作り直した。
as_completed の代わりに agency.map(cook, range(1, 31)) を使うように変えてみてください。13番で何が起き、何皿受け取れますか?wait(..., return_when=FIRST_COMPLETED) と shutdown(cancel_futures=True) で書いてみてください。すでに始まっていた料理はいくつでしたか?シェフのレシピカード
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)。
新しい授業・講座のお知らせを受け取る
この講座に授業が追加されたり、続きの講座が公開されたりしたらメールでお知らせします。
R
ReflexKR
触って学ぶPythonとReflex。すべての例はReflex 0.10.0で実際に動かして確認しています。
ReflexKRはバナー広告なし・無料で運営しています。役に立ったら、コーヒー一杯で応援してください。 ☕ コーヒーで応援する
アフィリエイトと支援について© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
質問と回答
この回について気になることを書き込んでください。すべての言語のページの質問がここに集まります。