R

ReflexKR

Reflex公式ドキュメント

深夜厨房の並行処理

·

講座の概要

1

初日の行列:並行と並列

2

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

3

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

4

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

5

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

6

伝票差し:queue

7

出張料理人:concurrent.futures

8

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

9

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

10

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

11

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

12

評論家の夜:総まとめ

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

時間 1:40–2:30

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

今夜の注文書

#03 · 三日目の夜

黒板の数が合わない

ピンチ

料理人4人がそれぞれ1000皿作り、1皿出すたびに厨房の黒板の数に1を足した。黒板に書かれているのは4000ではなく1086。

目標

料理人が何人いても黒板の数がぴったり合うようにする。それでいて厨房を遅くしない。

深夜食堂 ドンシ

三日目の夜、深夜2時。営業が終わりました。今日はタクさんの友人が一日だけ手伝いに来てくれて、料理人は4人でした。シェフはルールをひとつ決めていました。「1皿出すたびに、黒板の数に1を足すこと」

注文書を数えてみると、4人がそれぞれ1000皿ずつ、合計4000皿です。ところが黒板には1086と書かれています。

ミンホが頭をかきます。「俺、ほんとに毎回足しましたよ?」スアもタクさんも同じことを言います。シェフがチョークを手に取ります。「誰も嘘はついてないよ。4人が同時に黒板の前に立った、それだけのこと」

3-1. 4000が1086になる (8分)

黒板に1を足す作業は、実は3つの段階です。黒板を読み、頭の中で1を足し、黒板に書く。この3段階をそのままコードにしてみましょう。

import threading
import time

board = 0                       # 黒板の数

def cook(times):
    global board
    for _ in range(times):
        seen = board            # 1. 黒板を読む
        time.sleep(0)           # 2. ちょっとよそ見する(別の料理人に順番が回るすき)
        board = seen + 1        # 3. 読んだ値に1を足して書く

threads = [threading.Thread(target=cook, args=(1000,)) for _ in range(4)]
for th in threads:
    th.start()
for th in threads:
    th.join()
print(board)
# 例:1086(実行するたびに変わる。4000はまず出ない)
  • time.sleep(0) は「0秒休む」ですが、実際には今の順番を別のスレッドに譲る効果があります。読むと書くのあいだに、別の料理人が割り込むすきをわざと作ったわけです。
  • 本物のプログラムでは、このすきは sleep ではなく、ログ出力やデータベースへの問い合わせ、関数呼び出しといったごく普通のコードです。すきはいつもどこかにあります。
  • 実行するたびに数が変わります。このPCのPython 3.14では、だいたい1000〜1200のあいだでした。

このように、複数のスレッドが同じ値を読んで書き直す順番によって結果が変わることを、競合状態(race condition)といいます。下の黒板の実験で確かめてみてください。料理人たちは上のコードとまったく同じく「読む → time.sleep(0) → 書く」をしています。

黒板の実験

料理人

4

一人あたりの回数

1000

Lockを使う

料理人4人がそれぞれ1000回ずつ黒板に1を足します。「Lockを使う」をオフにしたまま何度か書いてみて、オンにしてからもう一度書いてみてください。消えた注文が0になるか見てみましょう。
  • Lockをオフにすると、黒板の数は書かれるべき数よりずっと小さく、書くたびに変わります。「消えた注文」の欄がその差です。
  • 料理人を1人に減らせば、Lockがなくてもぴったり合います。競合状態は2人以上が同じ値に触れるときにだけ起きます。
  • Lockをオンにすると、何人でも何回でも書かれるべき数とぴったり同じです。そのLockが何なのかが3-3の内容です。

3-2. 競合状態を1ステップずつ見る (8分)

黒板が41のとき、ミンホとスアがほぼ同時に1皿ずつ出したとしましょう。時間を1コマずつ区切ってみると、こうなります。

時点ミンホスア黒板
1黒板を読む:4141
2(よそ見する)黒板を読む:4141
341 + 1 = 42 を書く42
441 + 1 = 42 を書く42

2人が1皿ずつ、合わせて2皿出したのだから、黒板は43のはずです。ところが42です。スアはミンホが書き直す前の値を読み、ミンホが書いた42を自分の42で上書きしました。ミンホの1皿が消えたのです。

  • 問題は「読む → 計算する → 書く」のあいだに別のスレッドが割り込むことです。3つの段階がひとかたまりで実行される保証はありません。
  • 4人が同時に読めば、一度に3皿消えることもあります。4人全員が41を読んで全員が42を書けば、4皿が1皿に減ります。4000が1086まで落ちるのはこのためです。
  • 順番が運よく合えば、数が合うこともあります。だから競合状態は、テストでは通るのに客が押し寄せた夜にだけ爆発する、いちばん見つけにくいバグになるのです。

3-3. チョークはひとつだけ:Lock (10分)

解決策は単純です。黒板の前には一度に一人だけ立たせます。チョークをひとつだけ置き、チョークを握った人だけが黒板に書けるようにするのです。Pythonのチョークは threading.Lock です。

board = 0
chalk = threading.Lock()        # チョークはひとつだけ

def cook(times):
    global board
    for _ in range(times):
        with chalk:             # チョークを握った人だけが黒板の前に立つ
            seen = board
            time.sleep(0)
            board = seen + 1

threads = [threading.Thread(target=cook, args=(1000,)) for _ in range(4)]
for th in threads:
    th.start()
for th in threads:
    th.join()
print(board)
# 4000
  • with chalk: ブロックに入るときにロックを取り(acquire)、出るときに放します(release)。別のスレッドがすでに取っていれば、放されるまでその場で待ちます。
  • ブロックの中で time.sleep(0) によって順番を譲ってもかまいません。別の料理人に順番が回っても、チョークがないので黒板に手を出せません。
  • 何度回しても4000です。ロックの中の「読む → 計算する → 書く」は、別のスレッドが割り込めないひとかたまりになりました。

with を使わずに、自分で取って放すこともできます。その場合は必ず try/finally で包み、途中で例外が起きてもチョークを置くようにします。

chalk = threading.Lock()
print(chalk.locked())
chalk.acquire()                 # チョークを握る
try:
    print(chalk.locked())
    print(chalk.acquire(blocking=False))    # すでに誰かが握っていればすぐFalse
    print(chalk.acquire(timeout=0.1))       # 0.1秒待ってみて握れなければFalse
finally:
    chalk.release()             # 何があっても置く
print(chalk.locked())
chalk.release()
# False
# True
# False
# False
# False
# RuntimeError: release unlocked lock
メソッドすること
acquire()ロックを取る。誰かが取っていれば放されるまで待つ。取れたら True
acquire(blocking=False)待たない。取れなければすぐ False
acquire(timeout=0.1)0.1秒までしか待たない。取れなければ False
release()ロックを放す。ロックされていないのに放すと RuntimeError
locked()いまロックされているかどうか

ロックの範囲は短く:黒板の前だけ

ロックはほかの人を待たせます。だから本当に必要な行だけを包むべきです。料理までチョークを握ったままやったらどうなるでしょう?

board = 0
chalk = threading.Lock()

def cook_wide():
    global board
    with chalk:
        time.sleep(0.1)         # 料理までチョークを握ったまま
        board += 1

def cook_narrow():
    global board
    time.sleep(0.1)             # 料理はそれぞれで
    with chalk:
        board += 1              # 黒板の前でだけロックする

for fn in (cook_wide, cook_narrow):
    board = 0
    threads = [threading.Thread(target=fn) for _ in range(4)]
    start = time.perf_counter()
    for th in threads:
        th.start()
    for th in threads:
        th.join()
    print(fn.__name__, board, f"{time.perf_counter() - start:.2f}s")
# cook_wide 4 0.40s
# cook_narrow 4 0.10s

どちらも数はぴったり4です。でも cook_wide は、チョークを握った人が料理を終えるまで残りが列に並ぶので、料理人が4人いても一人でやるのと同じ0.4秒かかります。共有する値に触れる行だけをロックしてください。

シェフのひとこと

ロックは黒板ひとつにチョークひとつ。黒板ごとに別のチョークを使ったり、誰かがチョークなしで書いちゃったりしたら意味がない。同じ値に触るコードは、全部同じLockを通らなきゃだめ。ひとつでも抜けたら、その穴から数が漏れるよ。

3-4. GILがあっても競合状態は起きる (8分)

Python(正確にはCPython)にはGIL(Global Interpreter Lock)という大きなロックがあります。ある瞬間にPythonのコードを実行するスレッドはひとつだけ、というルールです。詳しくは8限目で扱いますが、ここでよくある誤解がひとつ生まれます。「一度にひとつしか実行されないなら、競合状態も起きないのでは?」

違います。GILはバイトコード1命令の単位でスレッドを切り替えるだけで、あなたの「読む → 計算する → 書く」をひとかたまりとして守ってはくれません。board += 1 の1行も、中を開けてみると複数の段階です。

import dis

count = 0

def add():
    global count
    count += 1

dis.dis(add)
# 結果の一部(3.14):
#   LOAD_GLOBAL              0 (count)     ← 読む
#   LOAD_SMALL_INT           1
#   BINARY_OP               13 (+=)        ← 計算する
#   STORE_GLOBAL             0 (count)     ← 書く

読むと書くのあいだでスレッドが切り替われば、3-2の表とまったく同じことが起きます。いつスレッドを切り替えるかはインタープリターが決め、その間隔は sys.getswitchinterval() で見られます。

import sys
print(sys.getswitchinterval())
# 0.005

デフォルトは0.005秒です。あるスレッドがずっと動いていても、だいたいこの間隔ごとに、別のスレッドに順番を譲るよう求められます。time.sleep やI/Oのような待つ仕事に出会えば、それより先に譲ります。

time.sleep(0) なしで、count += 1 だけを400万回やってみたらどうでしょう?

count = 0

def cook(times):
    global count
    for _ in range(times):
        count += 1

threads = [threading.Thread(target=cook, args=(1_000_000,)) for _ in range(4)]
for th in threads:
    th.start()
for th in threads:
    th.join()
print(count, sys._is_gil_enabled())
# Python 3.14:   4000000 True
# Python 3.14t:  例:1127695 False(実行するたびに変わる)
  • このPCの3.14(GILあり)では、何度回しても4000000が出ました。でもこれはたまたま、スレッドが切り替わる位置がその4段階のあいだに来なかっただけです。Pythonが += をアトミック(分割されないひとかたまり)に実行すると約束したことはありません。バージョンが変わったり、ループの中のコードが1行増えたりするだけで崩れることがあります。
  • GILのないフリースレッド版3.14tでは、スレッドが本当に同時に動くので、400万のうち100万ちょっとしか残りません。GIL版でたまたま合っていたコードが、3.14tではすぐに狂います。
  • 3-1の黒板のコードは、3.14tで4000のうち、たとえば2410でした。数は違っても、狂うことに変わりはありません。
  • 結論はひとつです。複数のスレッドが同じ値を書き直すなら、GILがあってもなくてもLockで包んでください。3.14tに移すつもりのコードならなおさらです。

3-5. 同じ人がもう一度ロックするとき:RLock (6分)

Lock は誰が取ったかを気にしません。だからすでにチョークを握っている人がもう一度チョークを取ろうとすると、自分が置くのを自分で待つことになり、永遠に止まります。本当に止まると困るので、0.5秒だけ待ってみます。

def probe(name, lock):
    with lock:                              # 一度ロックして
        start = time.perf_counter()
        ok = lock.acquire(timeout=0.5)      # 同じスレッドがもう一度
        waited = time.perf_counter() - start
        print(name, ok, f"{waited:.1f}s")
        if ok:
            lock.release()

probe("Lock", threading.Lock())
probe("RLock", threading.RLock())
# Lock False 0.5s
# RLock True 0.0s
  • Lock は0.5秒きっちり待って False です。timeout がなければ、このスレッドはここで永遠に止まっていたでしょう。
  • RLock(再入可能ロック、reentrant lock)は、取ったスレッドが誰なのかを覚えています。同じスレッドなら何度でも取り直せて、取った回数だけ放すと完全に解放されます。ほかのスレッドはいつもどおり待ちます。

これは、ロックするメソッドが別のロックするメソッドを呼ぶときによく起きます。

class Board:
    def __init__(self):
        self.lock = threading.RLock()
        self.count = 0

    def add(self):
        with self.lock:
            self.count += 1

    def add_table(self, dishes):
        with self.lock:             # 1卓分はまるごと
            for _ in range(dishes):
                self.add()          # 中で同じロックをもう一度取る

board = Board()
threads = [threading.Thread(target=board.add_table, args=(250,)) for _ in range(4)]
for th in threads:
    th.start()
for th in threads:
    th.join()
print(board.count)
# 1000

self.lock が Lock だったら、add_table が add を呼んだ瞬間に止まっていたでしょう。RLock は少し重く、ロックが何重になっているかわかりにくくもします。設計を変えて「ロックしない内部メソッド」(_add)を別に用意するのも良い方法です。

閉店後 (10分)

今夜の目標は、料理人が何人いても黒板の数がぴったり合い、厨房は遅くならないことでした。黒板をクラスにして、黒板を書き直す行だけをロックします。料理(time.sleep)はロックの外でそれぞれがやります。

class Chalkboard:
    def __init__(self):
        self._lock = threading.Lock()
        self.count = 0

    def add(self, n=1):
        with self._lock:            # 黒板の前でだけ
            self.count += n

board = Chalkboard()

def cook(dishes):
    for _ in range(dishes):
        time.sleep(0.001)           # 料理はロックの外で
        board.add()

threads = [threading.Thread(target=cook, args=(100,)) for _ in range(4)]
start = time.perf_counter()
for th in threads:
    th.start()
for th in threads:
    th.join()
print(board.count, f"{time.perf_counter() - start:.2f}s")
# 例:400 0.15s(数はいつも400、時間はだいたい)
  • 料理人4人が100皿ずつ、黒板はいつでも400です。3.14tで回しても400です。
  • 時間は、一人で100皿作る時間(このPCで約0.11秒)より少し長い程度です。4人は料理を一緒に進め、黒板の前でだけ少しずつ並ぶからです。
  • 黒板(count)を書き直す道は add() ひとつだけです。ロックをクラスの中に隠しておけば、誰かがうっかりチョークなしで書いてしまうことが減ります。

シェフが黒板を消して、400と書き直します。「黒板は一人ずつで足りる。でも明日はコンロが3つで料理人が6人。一人ずつしか入れなかったら、コンロが2つ遊んじゃう。3人まで通してくれるチョークってないのかな?」

営業日誌 — 三日目の夜。黒板の4000が1086に減る事故。原因は、読むと書くのあいだに割り込んだ別の料理人。黒板の前にチョーク(Lock)をひとつだけ置き、黒板を書き直す行だけをロックして解決。

練習問題

  1. 黒板の実験でLockをオフにして、料理人2人 × 2000回と、8人 × 500回を比べてみましょう。合計回数はどちらも4000なのに、消えた注文が多いのはどちらでしょう? それはなぜでしょう?
  2. 3-1のコードから time.sleep(0) を消して、もう一度回してみましょう。数が合ったとしても安全とは言えない理由を、3-4を根拠に説明してみてください。
  3. 3-5の Board を Lock に変え、ロックしない内部メソッド _add() を用意して、add() と add_table() のどちらも止まらないように直してみましょう。

シェフのレシピカード

  • 競合状態:読む → 計算する → 書くのあいだに別のスレッドが割り込み、値が消える。結果が実行ごとに変わる。

  • lock = threading.Lock() / with lock: 共有の値を書き直す行だけを包む。自分で書くなら acquire() … finally: release()

  • acquire(blocking=False)、acquire(timeout=秒) は取れなければ False。locked() で状態を確認。

  • RLock:同じスレッドがもう一度ロックできる。Lockを二度取ると自分で止まる。

  • GILがあっても count += 1 はアトミックではない。3.14tではもっと大きく狂う。共有の値にはいつもLock。

  • sys.getswitchinterval() = 0.005:だいたいこの間隔でスレッドが切り替わりうる。

← 前へ

2限目 · 料理人を増やす:threadingの基本

次へ →

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

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

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

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

質問と回答

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

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

R

ReflexKR

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

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

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

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

Built with Reflex