R

ReflexKR

Reflex公式ドキュメント

深夜厨房の並行処理

·

講座の概要

1

初日の行列:並行と並列

2

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

3

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

4

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

5

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

6

伝票差し:queue

7

出張料理人:concurrent.futures

8

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

9

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

10

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

11

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

12

評論家の夜:総まとめ

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

時間 3:20–4:10

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

今夜の注文書

#05 · 五日目の夜

包丁とまな板

ピンチ

ミンホは包丁を握ったまままな板を、スアはまな板を抱えたまま包丁を待っている。どちらも先に手放す気はない。厨房が止まった。

目標

包丁とまな板を一緒に使う料理人3人が、一度も止まらずにそれぞれ100回ずつ料理する。

深夜食堂 ドンシ

五日目の夜11時。厨房が妙に静かです。ミンホは包丁を握ってまな板のほうを見つめ、スアはまな板を抱えて包丁のほうを見つめています。二人とも「先に使ったら渡すから」と繰り返すばかりです。

ネギを刻んでいたタクさんが、手を止めてつぶやきます。「二人とも突っ立ってたら、誰も終わらんよ」

ユン・ソヨンシェフが首から下げたタイマーを止めます。「二人とも置いて。これは誰かが遅いせいじゃない。ルールがないせいだよ」

5-1. 互いを待つ二人の料理人 (7分)

昨夜のコンロのルールと同じく、包丁とまな板も一度に一人しか使えないロックです。ミンホは包丁 → まな板の順に、スアはまな板 → 包丁の順に取ります。

import threading
import time

knife = threading.Lock()
board = threading.Lock()

def minho():
    with knife:              # 包丁を取る
        time.sleep(0.1)
        with board:          # まな板を待つ
            print("minho done")

def sua():
    with board:              # まな板を取る
        time.sleep(0.1)
        with knife:          # 包丁を待つ
            print("sua done")

a = threading.Thread(target=minho, daemon=True)
b = threading.Thread(target=sua, daemon=True)
a.start(); b.start()
a.join(timeout=1); b.join(timeout=1)
print("alive:", a.is_alive(), b.is_alive())
# alive: True True
  • 1秒待っても、2つのスレッドはまだ生きています。働いているのではなく、相手が手放すのを互いに待ったまま止まっているのです。これがデッドロック(deadlock、行き詰まり)です。
  • join(timeout=1) は1秒までしか待たずに戻ってきます。スレッドを daemon=True で作ったので、プログラムはそのまま終了します。デッドロックを実際に試すときは、いつもこうして逃げ道を用意しておきましょう。
  • time.sleep(0.1) は、二人が最初の道具をほぼ同時に手に取るようにするための仕掛けです。実際のプログラムではこのすき間がごくまれにしか生じないので、デッドロックは「たまにだけ」起きる、さらにたちの悪いバグになります。

止まったことをプログラムの中で気づくには acquire(timeout=...) を使います。取れれば True、時間内に取れなければ False を返します。with lock: には時間制限を付けられないので、このときは acquire と release を直接呼びます。

knife = threading.Lock()   # 新しい包丁と新しいまな板
board = threading.Lock()

def cook(name, first, second, patience):
    with first:
        time.sleep(0.1)
        if second.acquire(timeout=patience):   # patience秒までしか待たない
            second.release()
            print(name, "done")
        else:
            print(name, f"gave up after {patience}s")

a = threading.Thread(target=cook, args=("minho", knife, board, 1.0))
b = threading.Thread(target=cook, args=("sua", board, knife, 0.5))
a.start(); b.start()
a.join(); b.join()
# sua gave up after 0.5s
# minho done

スアが0.5秒であきらめてまな板を置くと、まだ待っていたミンホがまな板を取って料理を終えます。一人が譲るだけでデッドロックはほどけます。その理由は、次の節の4つの条件で説明できます。

5-2. デッドロックの4つの条件 (8分)

デッドロックは、次の4つの条件がすべて同時にそろったときにだけ起きます。裏返せば、ひとつ崩すだけでデッドロックは起きません。

条件意味今夜の厨房では崩し方
相互排除資源は一度に一人しか使えない包丁は一人の手にしかない包丁を買い足すか、共有が要らない設計にする
保持して待機ひとつ握ったまま別のものを待つ包丁を持ったまままな板を待つ2つを一度に取るか、取れなければ置く
横取りできない他人が握る資源を力ずくで奪えないミンホの手の包丁は奪えない時間制限で自分から手放させる(解法2)
循環待ち待ちが輪になるミンホ → スア → ミンホ全員が同じ順番で取る(解法1)
  • 相互排除はロックの存在理由そのものなので、ふつうは崩しません。包丁がたくさんあるなら、そもそもロックは要りませんよね。
  • 実務でいちばんよく使うのは循環待ちを崩す方法です。全員が同じ順番で取れば、輪はできません。
  • その次は、保持して待機と横取りできないを一緒に崩す方法です。一定時間取れなければ、持っているものを自分から置きます。

シェフのひとこと

デッドロックはCPUを使わない。エラーも出ない。ログも残らない。ただ静かに止まってるだけ。だから「遅くなった」って報告が来たら、私はまずデッドロックを疑う。

5-3. 解法1:全員が同じ順番で取る (8分)

シェフが黒板にルールを書きます。「道具ごとに番号を付ける。包丁が1番、まな板が2番。いつも小さい番号から取る」

ミンホもスアも包丁から取るなら、まな板を待つのは包丁を握った人だけです。包丁を取れなかった人は何も持たずに待つので、輪ができません。ルールを人の記憶に頼らず、関数ひとつにまとめておきましょう。

from contextlib import contextmanager

KNIFE = (1, threading.Lock())   # (番号, ロック)
BOARD = (2, threading.Lock())

@contextmanager
def hold(*tools):
    ordered = sorted(tools, key=lambda t: t[0])   # 番号の小さいものから
    for _, lock in ordered:
        lock.acquire()
    try:
        yield
    finally:
        for _, lock in reversed(ordered):        # 放すときは逆順に
            lock.release()

done = []

def minho():
    for _ in range(100):
        with hold(KNIFE, BOARD):
            time.sleep(0)
    done.append("minho")

def sua():
    for _ in range(100):
        with hold(BOARD, KNIFE):     # 書く順番が違っても、取る順番は同じ
            time.sleep(0)
    done.append("sua")

ts = [threading.Thread(target=f, daemon=True) for f in (minho, sua)]
for t in ts: t.start()
for t in ts: t.join(timeout=2)
print(sorted(done), [t.is_alive() for t in ts])
# ['minho', 'sua'] [False, False]
  • スアが hold(BOARD, KNIFE) と書いても、hold の中で番号順に並べ替えるので、実際には包丁 → まな板の順に取ります。
  • 二人が100回ずつ交互に取っても、一度も止まりません。time.sleep(0) で順番を譲るすき間を与えても同じです。
  • 番号は人が決めてもいいし、プログラムの中だけで使うなら id(lock) のように重ならない値でもかまいません。大事なのはすべてのコードが同じ基準を使うことです。
  • 放す順番はデッドロックとは関係ありませんが、取った順の逆に放す習慣をつけるとコードが読みやすくなります。

ロックが2つ以上必要な場所ができたら、このルールをコードの先頭のコメントやドキュメントに書いておきましょう。新しく来た人が順番を知らずに逆から取った瞬間、デッドロックはまたやってきます。

5-4. 解法2:取れなければ置く (7分)

順番を決めにくい場合もあります。どのロックが必要か前もってわからないときです。そんなときは「一定時間待っても取れなければ、持っているものまで置いて最初からやり直す」というルールを使います。

import random

tries = {}

def polite_cook(name, first, second):
    tries[name] = 0
    while True:
        tries[name] += 1
        with first:
            time.sleep(0.05)
            if second.acquire(timeout=0.1):     # 0.1秒以内に取れなければ
                try:
                    return                       # 両方取れた:料理完了
                finally:
                    second.release()
        # withを抜けるときにfirstを置いた
        time.sleep(random.uniform(0, 0.1))       # ランダムに休んでからもう一度

a = threading.Thread(target=polite_cook, args=("minho", knife, board))
b = threading.Thread(target=polite_cook, args=("sua", board, knife))
a.start(); b.start()
a.join(); b.join()
print(tries)
# 例:{'minho': 2, 'sua': 3}  (実行するたびに変わる)

何度かぶつかったあと、最後には二人とも終わります。ここで random.uniform(0, 0.1) を外すと、新しい問題が起きることがあります。ライブロック(livelock)です。

瞬間ミンホスア
1包丁を取るまな板を取る
2まな板がない → 包丁を置く包丁がない → まな板を置く
3きっかり0.1秒休んで、また包丁を取るきっかり0.1秒休んで、またまな板を取る
…1〜3を延々と繰り返す1〜3を延々と繰り返す

廊下で向かい合った二人が、同じ側へよけ続けてしまう場面と同じです。デッドロックと違って二人とも忙しく動いているのに、誰も料理を終えられません。休む時間をランダムに混ぜれば、どちらかが先に動くことになり、繰り返しが崩れます。

本物のスレッドは実行のタイミングが少しずつぶれるので、短い例でライブロックを毎回再現するのは難しいものです。だからこそ危険です。負荷の高い日にだけ現れるのですから。解法2を使うときは、ランダムな休みを必ず一緒に入れてください。

5-5. 5人の料理人と5本の包丁 (6分)

コンピューターサイエンスには食事する哲学者の問題という有名な問題があります。円卓に5人が座り、あいだにフォークが1本ずつ置かれています。食べるには両隣のフォーク2本がどちらも必要です。厨房に置き換えると、丸い調理台に料理人5人と包丁5本です。

N = 5

def service(ordered):
    knives = [threading.Lock() for _ in range(N)]   # 包丁5本
    meals = [0] * N

    def cook(i):
        left, right = i, (i + 1) % N
        if ordered:
            first, second = min(left, right), max(left, right)
        else:
            first, second = left, right
        for _ in range(3):
            with knives[first]:
                time.sleep(0.05)          # 最初の包丁を持ったまま少し
                with knives[second]:
                    meals[i] += 1

    ts = [threading.Thread(target=cook, args=(i,), daemon=True) for i in range(N)]
    for t in ts: t.start()
    for t in ts: t.join(timeout=1)
    return sum(t.is_alive() for t in ts), meals

print("left first:", service(ordered=False))
print("small first:", service(ordered=True))
# left first: (5, [0, 0, 0, 0, 0])
# small first: (0, [3, 3, 3, 3, 3])
  • 全員が左の包丁から:5人が同時に左の包丁を取ると、右の包丁はいつも隣の人の手にあります。待ちが5人をひと回りする輪になり、1秒たっても5つのスレッドがすべて止まったままです。
  • 番号の小さい包丁から:0〜3番の料理人(コードの番号は0から)はそのまま左の包丁から取りますが、4番の料理人だけは右(0番の包丁)から取ることになります。輪が1か所切れるので、全員が3回ずつ料理を終えます。

下の実験台は、この状況を1ステップずつ見せてくれます。サーバーの本物のスレッドをデッドロックさせると二度とほどけないので、この実験台だけはシミュレーションで動きます。包丁の上の数字は、その包丁を握っている料理人の番号です。

包丁とまな板

1

2

3

4

5

·

·

·

·

·

ステップ 0

完成した料理: 1:0 2:0 3:0 4:0 5:0

戦略を選んで「1ステップ」を押してみてください。全員が左の包丁から取ると最初のステップですぐに止まり、番号の小さい包丁から取ると、いつも誰かが料理しています。
  • 全員が左の包丁から:最初のステップで5人全員が包丁を1本ずつ持ち、赤いデッドロック警告が出ます。そのあとは何度押しても変わりません。
  • 番号の小さい包丁から:5番の料理人は1番の包丁から取ろうとして待ちます。そのおかげで5番の包丁が空き、4番の料理人が2ステップ目で料理を始めます。「自動で進める」をオンにしておくと、完成した料理の数がまんべんなく増えていきます。
  • 取れなければ置く:最初の2ステップはデッドロックのように見え、警告も出ます。3ステップ目で待っていた料理人たちが包丁を置くと、5番の料理人が空いた包丁を取って料理を始めます。全員がまったく同じリズムで置いては取り直すなら、ライブロックになりうることを思い出してください。

5-6. 止まった厨房を調べる:faulthandler (4分)

プログラムが止まったのに、どこで止まったのかわからない。そんなときは標準ライブラリの faulthandler が助けてくれます。すべてのスレッドがいま何行目にいるかを出力します。下のコードを stuck.py として保存して実行してみてください。

import faulthandler
import threading
import time

knife = threading.Lock()
board = threading.Lock()

def minho():
    with knife:
        time.sleep(0.1)
        with board:
            pass

def sua():
    with board:
        time.sleep(0.1)
        with knife:
            pass

faulthandler.dump_traceback_later(1)        # 1秒後もまだここにいたら、全スレッドを出力
crew = [threading.Thread(target=f, name=f.__name__, daemon=True) for f in (minho, sua)]
for t in crew: t.start()
for t in crew: t.join(timeout=2)
faulthandler.cancel_dump_traceback_later()  # 時間内に終わったなら予約を取り消す
Timeout (0:00:01)!
Thread 0x00009560 [sua] (most recent call first):
  File "stuck.py", line 17 in sua
  ...
Thread 0x0000a670 [minho] (most recent call first):
  File "stuck.py", line 11 in minho
  ...

出力は短くしてあります(アドレスとパスは実行のたびに変わります)。スアは17行目の with knife: で、ミンホは11行目の with board: で止まっています。誰が何を待っているのか、ひと目でわかります。

  • faulthandler.dump_traceback_later(timeout):timeout秒後に、すべてのスレッドの位置を標準エラーに出力するよう予約する。repeat=True なら繰り返し出力し、exit=True なら出力後にプログラムを終了する。
  • faulthandler.dump_traceback(all_threads=True):予約なしで今すぐ出力する。時間のかかる処理の途中で状態をのぞきたいときに使う。
  • テストがときどき止まるなら、テストの開始時に dump_traceback_later を仕掛けておきましょう。止まった日に犯人が残ります。

シェフのひとこと

ロックを握ったまま別の関数を呼ぶときは気をつけて。その関数の中でどのロックをまた取るか、わからないでしょ。ロックの中では、黒板に数字を書くみたいに短くてわかりきったことだけやる。それが順番のルールより先。

閉店後 (10分)

今夜の目標は、包丁とまな板を一緒に使う料理人3人が、一度も止まらずにそれぞれ100回ずつ料理することでした。タクさんはフライパンまで3つ使います。道具ごとに番号を付けて hold で取らせれば、誰がどんな順番で書いても関係ありません。

PAN = (3, threading.Lock())
meals = {}

def cook(name, *tools):
    for _ in range(100):
        with hold(*tools):            # いつも番号の小さい道具から
            time.sleep(0)
            meals[name] = meals.get(name, 0) + 1

crew = [threading.Thread(target=cook, args=a, daemon=True) for a in [
    ("minho", KNIFE, BOARD), ("sua", BOARD, KNIFE), ("tak", PAN, BOARD, KNIFE)]]
for t in crew: t.start()
for t in crew: t.join(timeout=3)
print([meals[n] for n in ("minho", "sua", "tak")], sum(t.is_alive() for t in crew))
# [100, 100, 100] 0

3人とも100回ずつ、止まった人は0人です。シェフが黒板のルールの横に1行書き足します。「包丁1、まな板2、フライパン3。小さい番号から」

「今日は道具のせいで止まった。明日は人のせいで止まるよ。注文が殺到したとき、誰がどの注文を作るか、誰が決める?」

営業日誌 — 五日目の夜。包丁とまな板を互いに逆の順番で取ったせいで厨房が停止。道具に番号を付け、小さい番号から取ることにしたら、3人が100回ずつ止まらずに料理できた。

練習問題

  1. 5-1の最初の例から time.sleep(0.1) を消して、何度か実行してみてください。デッドロックは毎回起きますか?「たまにだけ止まるバグ」がなぜより危険なのか、一文で書いてみてください。
  2. 5-5の service で ordered=True のとき、4番の料理人だけが違う順番で取ることを print で確かめてみてください。料理人が一人だけ順番を変えてもデッドロックがほどける理由を、4つの条件で説明してみてください。
  3. 実験台で「取れなければ置く」を選び、「自動で進める」で24ステップ回したあと、「番号の小さい包丁から」と完成した料理の数を比べてみてください。

シェフのレシピカード

  • デッドロックの4条件:相互排除、保持して待機、横取りできない、循環待ち。ひとつ崩せばほどける。

  • 解法1:ロックに番号を付け、全員が小さい番号から取る。sorted(tools)で関数ひとつにまとめておく。

  • 解法2:if not lock.acquire(timeout=0.1): 持っているものを置き、ランダムに休んでからやり直す。

  • ライブロック:全員が同じリズムで譲り合うばかりで、誰も終わらない。ランダムな休みで崩す。

  • 実験するときは daemon=True + join(timeout=1) + is_alive() で逃げ道を作る。

  • 止まった場所を探す:faulthandler.dump_traceback_later(1)、dump_traceback(all_threads=True)。

← 前へ

4限目 · コンロは3つだけ:同期の道具

次へ →

6限目 · 伝票差し:queue

新しい授業・講座のお知らせを受け取る

この講座に授業が追加されたり、続きの講座が公開されたりしたらメールでお知らせします。

収集項目:メールアドレス・目的:この講座の新しい授業・公開のお知らせ・保有期間:解除のご依頼まで。他の目的には使わず、第三者にも提供しません。お知らせメールに返信いただければすぐに削除します。

質問と回答

この回について気になることを書き込んでください。すべての言語のページの質問がここに集まります。

リンクや書式なしで、そのままの文字で表示されます。

R

ReflexKR

触って学ぶPythonとReflex。すべての例はReflex 0.10.0で実際に動かして確認しています。

ReflexKRはバナー広告なし・無料で運営しています。役に立ったら、コーヒー一杯で応援してください。 ☕ コーヒーで応援する

アフィリエイトと支援について
한국어English日本語简体中文EspañolDeutsch

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

Built with Reflex