R

ReflexKR

Reflex公式ドキュメント

深夜厨房の並行処理

·

講座の概要

1

初日の行列:並行と並列

2

料理人を増やす:threadingの基本

3

黒板の数が合わない:競合状態とLock

4

コンロは3つだけ:同期の道具

5

包丁とまな板:デッドロック

6

伝票差し:queue

7

出張料理人:concurrent.futures

8

生地はなぜ速くならない:GILと並列

9

一人で回すホール係:asyncioの第一歩

10

団体予約のキャンセル:構造化された並行処理

11

止まったホール:ブロッキングと組み合わせ

12

評論家の夜:総まとめ

深夜厨房の並行処理
7限目

時間 5:00–5:50

出張料理人:concurrent.futures

今夜の注文書

#07 · 七日目の夜

出張料理人を呼ぶ

ピンチ

宴会の団体注文が30件、一度に入った。料理人を30人雇うわけにはいかない。スレッドをひとつずつ作って結果をリストに集めるコードにも、もううんざりだ。

目標

30件の注文を出張料理人6人に任せて1秒以内に、できたものから出す。焦がした皿があっても見逃さない。

深夜食堂 ドンシ

七日目の夜10時半。電話が鳴り、受話器を置いたシェフが言います。「宴会の団体、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:   # 出張料理人3人
    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件を任せてみましょう。料理人は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)といいます。

7-2. 終わった順か、入れた順か:as_completedとmap (10分)

結果の受け取り方は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.4s
as_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

結果を受け取る順

調理

待ち

キャンセル

失敗

注文10件、出張料理人3人。結果を受け取る順をas_completedとmapで切り替えながら回して、棒の下の「戻ってきた順」を比べてみてください。
  • どちらの方式も約1.0秒かかり、棒の形もほとんど同じです。料理人たちの働き方は変わらず、結果を受け取る順番だけが違います。
  • as_completed では、戻ってきた順がたとえば #3 → #2 → #1 → #6 → #5 → #4 → … のように入り乱れます。3人が同時に始めた注文のうち、短いものが先に終わるからです。
  • map では、いつも #1 → #2 → … → #10 です。
  • 料理人を最大の8人に増やすと約0.4秒で終わり、as_completed の戻ってきた順は #8 → #7 → … → #1 のようにほぼ逆順になります。8件の注文が一斉に始まり、短い後ろの番号から終わるからです。

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:     # 料理人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 1

wait は、終わったものと終わっていないものの2つの集合を返します。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

この例は対話型シェルではなく、.py ファイルに保存して実行してください。プロセスプールがなぜ if __name__ == "__main__": を必要とするのかは、次の夜に見ます。

max_workers を省略すると、スレッドプールは min(32, os.process_cpu_count() + 4) 人を呼びます(3.12までは os.cpu_count())。12コアのこのPCでは16人です。待つ仕事にはコア数より多いスレッドが必要なので4を足し、増えすぎないよう32で打ち止めにしています。実際のサービスでは、相手のサーバーがさばける数を考えて自分で決めるほうがいいでしょう。

閉店後 (10分)

今夜の目標は、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()ですぐに捕まえて作り直した。

練習問題

  1. 閉店後のコードで、as_completed の代わりに agency.map(cook, range(1, 31)) を使うように変えてみてください。13番で何が起き、何皿受け取れますか?
  2. タイムラインで料理人を1人に減らし(約2.8秒かかります)、as_completedとmapを比べてみてください。なぜ戻ってきた順が同じになるのでしょうか?
  3. 30件の注文のうち最初に終わった一皿だけを受け取り、残りを取り消すコードを、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)。

← 前へ

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