今夜の注文書
#03 · 三日目の夜
ピンチ
料理人4人がそれぞれ1000皿作り、1皿出すたびに厨房の黒板の数に1を足した。黒板に書かれているのは4000ではなく1086。
目標
料理人が何人いても黒板の数がぴったり合うようにする。それでいて厨房を遅くしない。
深夜食堂 ドンシ
三日目の夜、深夜2時。営業が終わりました。今日はタクさんの友人が一日だけ手伝いに来てくれて、料理人は4人でした。シェフはルールをひとつ決めていました。「1皿出すたびに、黒板の数に1を足すこと」
注文書を数えてみると、4人がそれぞれ1000皿ずつ、合計4000皿です。ところが黒板には1086と書かれています。
ミンホが頭をかきます。「俺、ほんとに毎回足しましたよ?」スアもタクさんも同じことを言います。シェフがチョークを手に取ります。「誰も嘘はついてないよ。4人が同時に黒板の前に立った、それだけのこと」
黒板に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 ではなく、ログ出力やデータベースへの問い合わせ、関数呼び出しといったごく普通のコードです。すきはいつもどこかにあります。このように、複数のスレッドが同じ値を読んで書き直す順番によって結果が変わることを、競合状態(race condition)といいます。下の黒板の実験で確かめてみてください。料理人たちは上のコードとまったく同じく「読む → time.sleep(0) → 書く」をしています。
黒板の実験
料理人
4
一人あたりの回数
1000
Lockを使う
黒板が41のとき、ミンホとスアがほぼ同時に1皿ずつ出したとしましょう。時間を1コマずつ区切ってみると、こうなります。
| 時点 | ミンホ | スア | 黒板 |
|---|---|---|---|
| 1 | 黒板を読む:41 | 41 | |
| 2 | (よそ見する) | 黒板を読む:41 | 41 |
| 3 | 41 + 1 = 42 を書く | 42 | |
| 4 | 41 + 1 = 42 を書く | 42 |
2人が1皿ずつ、合わせて2皿出したのだから、黒板は43のはずです。ところが42です。スアはミンホが書き直す前の値を読み、ミンホが書いた42を自分の42で上書きしました。ミンホの1皿が消えたのです。
解決策は単純です。黒板の前には一度に一人だけ立たせます。チョークをひとつだけ置き、チョークを握った人だけが黒板に書けるようにするのです。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)
# 4000with chalk: ブロックに入るときにロックを取り(acquire)、出るときに放します(release)。別のスレッドがすでに取っていれば、放されるまでその場で待ちます。time.sleep(0) によって順番を譲ってもかまいません。別の料理人に順番が回っても、チョークがないので黒板に手を出せません。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を通らなきゃだめ。ひとつでも抜けたら、その穴から数が漏れるよ。
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(実行するたびに変わる)+= をアトミック(分割されないひとかたまり)に実行すると約束したことはありません。バージョンが変わったり、ループの中のコードが1行増えたりするだけで崩れることがあります。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.0sLock は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)
# 1000self.lock が Lock だったら、add_table が add を呼んだ瞬間に止まっていたでしょう。RLock は少し重く、ロックが何重になっているかわかりにくくもします。設計を変えて「ロックしない内部メソッド」(_add)を別に用意するのも良い方法です。
今夜の目標は、料理人が何人いても黒板の数がぴったり合い、厨房は遅くならないことでした。黒板をクラスにして、黒板を書き直す行だけをロックします。料理(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、時間はだいたい)count)を書き直す道は add() ひとつだけです。ロックをクラスの中に隠しておけば、誰かがうっかりチョークなしで書いてしまうことが減ります。シェフが黒板を消して、400と書き直します。「黒板は一人ずつで足りる。でも明日はコンロが3つで料理人が6人。一人ずつしか入れなかったら、コンロが2つ遊んじゃう。3人まで通してくれるチョークってないのかな?」
営業日誌 — 三日目の夜。黒板の4000が1086に減る事故。原因は、読むと書くのあいだに割り込んだ別の料理人。黒板の前にチョーク(Lock)をひとつだけ置き、黒板を書き直す行だけをロックして解決。
time.sleep(0) を消して、もう一度回してみましょう。数が合ったとしても安全とは言えない理由を、3-4を根拠に説明してみてください。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:だいたいこの間隔でスレッドが切り替わりうる。
新しい授業・講座のお知らせを受け取る
この講座に授業が追加されたり、続きの講座が公開されたりしたらメールでお知らせします。
R
ReflexKR
触って学ぶPythonとReflex。すべての例はReflex 0.10.0で実際に動かして確認しています。
ReflexKRはバナー広告なし・無料で運営しています。役に立ったら、コーヒー一杯で応援してください。 ☕ コーヒーで応援する
アフィリエイトと支援について© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
質問と回答
この回について気になることを書き込んでください。すべての言語のページの質問がここに集まります。