今夜の注文書
#08 · 八日目の夜
ピンチ
明日使う麺の生地が4玉。料理人を4人つけたのに、一人でこねたときと時間がまったく同じ。
目標
生地4玉を、一人でやるときの2倍以上の速さで仕上げる。
深夜食堂 ドンシ
営業が終わった深夜1時。明日使う麺の生地が4玉、作業台に置かれています。ミンホ、スア、タクさん、そしてあなた。4人で1玉ずつ受け持ったのだから、4倍は速くなるはずです。
ところがシェフのタイマーは、一人でこねたときとほぼ同じ数字で止まります。ミンホが首をかしげます。「みんな手は動いてたんですけど?」
ユン・ソヨンシェフが作業台の上の包丁差しを指さします。包丁がたった1本だけ差してあります。「この厨房には古いルールがひとつある。切ったりこねたりする人は一度に一人だけ。今日はそのルールの話をしよう」
1限目の注文は、スープが煮えるのを待つ仕事でした。今日の生地は違います。数を200万回足すループ、つまり手が休まない仕事(CPU処理)です。
import threading
import time
def knead(n=2_000_000):
s = 0
for i in range(n): # 休まず計算する:生地をこねる
s += i
return s
start = time.perf_counter()
for _ in range(4):
knead()
print(f"sequential {time.perf_counter() - start:.2f}s")
start = time.perf_counter()
cooks = [threading.Thread(target=knead) for _ in range(4)]
for th in cooks:
th.start()
for th in cooks:
th.join()
print(f"4 threads {time.perf_counter() - start:.2f}s")
# sequential 0.24s
# 4 threads 0.23s (12コアPC、Python 3.14での実測)CPython(私たちが普段使っているPython)にはGIL(Global Interpreter Lock、グローバルインタープリターロック)があります。ルールはひとつです。
ひとつのプロセスの中で、Pythonのバイトコードを実行するスレッドは一度にひとつだけ。
厨房でいえば「包丁は1本だけ」ルールです。料理人が4人いても、包丁を握った人しか切れず、残りは包丁が戻ってくるのを待ちます。包丁はおよそ0.005秒ごとに次の人へ渡ります(sys.getswitchinterval()、3限目)。
| 時刻 | ミンホ | スア | タクさん | あなた |
|---|---|---|---|---|
| 0〜5ms | 包丁を握ってこねる | 待ち | 待ち | 待ち |
| 5〜10ms | 待ち | 包丁を握ってこねる | 待ち | 待ち |
| 10〜15ms | 待ち | 待ち | 包丁を握ってこねる | 待ち |
| 15〜20ms | 待ち | 待ち | 待ち | 包丁を握ってこねる |
4人がかわるがわるこねるので、生地4玉はどれも少しずつ進みます。並行はあっても並列はありません(1限目)。全体の作業量は変わらないので、時間も変わりません。
なぜこんなルールがあるのでしょう? Pythonはすべてのオブジェクトについて「いくつの場所から自分が使われているか」(参照カウント)を数えています。複数のスレッドがこの数を同時に書き換えると、3限目の黒板のように狂ってしまいます。昔のCPythonは、ひとつのロックでインタープリター全体を守るという簡単な道を選びました。それがGILです。
GILには大事な例外があります。スレッドは待っているあいだ(time.sleep、ファイルの読み込み、ネットワークの応答)はGILを手放します。スープが煮えるのを待つ人に包丁はいりませんから。
def simmer():
time.sleep(0.3) # スープが煮えるのを待つ:このあいだGILを手放す
start = time.perf_counter()
cooks = [threading.Thread(target=simmer) for _ in range(4)]
for th in cooks:
th.start()
for th in cooks:
th.join()
print(f"4 threads {time.perf_counter() - start:.2f}s")
# 4 threads 0.30s (順番にやれば1.2秒)Cで書かれた関数も、重い計算をしているあいだはGILを手放せます。hashlib が大きなデータをハッシュするときがそうです。人の手ではなくミキサー(Cのコード)が回す仕事なので、包丁ルールの外にいるわけです。
import hashlib
dough = b"x" * 50_000_000 # 50MB
def press():
hashlib.sha256(dough).hexdigest() # Cのコードが計算しているあいだGILを手放す
# 上と同じやり方で測ると(このPCでだいたい)
# sequential 0.11s
# 4 threads 0.04s| 仕事の種類 | GILのあるスレッドだと | 理由 |
|---|---|---|
| 待つ仕事(sleep、ファイル、ネットワーク) | 速くなる | 待っているあいだGILを手放す |
| C拡張の重い計算(hashlib、zlibなど) | たいてい速くなる | CのコードがGILを手放して計算する |
| 純粋なPythonの計算(ループ、文字列の組み立て) | 速くならない | バイトコードは一度にひとつのスレッド |
そしてGILはあなたのコードを守ってはくれません。GILが守るのはインタープリターの内部だけで、count += 1 のような複数段階の仕事は途中で切られます。3限目の黒板が、GILがあるのに狂った理由です。
下の実習台は、デプロイ先のサーバーで本物のスレッドが time.sleep する厨房です。サーバーはGILのあるPythonですが、待つ仕事なのでバーが重なります。
厨房タイムライン
注文
6
料理人
3
調理
待ち
キャンセル
失敗
シェフのひとこと
スレッドを増やしたのに速くならないなら、まずその仕事がスープなのか生地なのかを考えて。スープならスレッドで十分。生地なら、人を増やすんじゃなくて厨房を増やさなきゃ。
包丁が1本しかないなら、包丁が別にある厨房をもうひとつ構えればいいのです。それがプロセスです。プロセスごとにPythonのインタープリターが別々に動き、GILも別々にあります。
import multiprocessing as mp
import os
def branch(name):
print(name, "pid", os.getpid(), "parent", os.getppid())
if __name__ == "__main__":
print("main pid", os.getpid())
p = mp.Process(target=branch, args=("branch-1",))
p.start()
p.join()
print("exitcode", p.exitcode)
# 例:
# main pid 39516
# branch-1 pid 40268 parent 39516
# exitcode 0Process の使い方は threading.Thread と同じです(target、args、start、join)。違うのは os.getpid()、つまりプロセス番号です。支店は本店とは別の番号を持つ、独立したプログラムです。
支店はメモリを共有しません。支店でリストに何を入れても、本店のリストはそのままです。
orders = []
def add(n):
orders.append(n)
print("in branch:", orders)
if __name__ == "__main__":
p = mp.Process(target=add, args=("#1",))
p.start()
p.join()
print("in main:", orders)
# in branch: ['#1']
# in main: []3限目の黒板の問題がそもそも起きない代わりに、結果を受け取るには別途返してもらう必要があります。複数の作業を手分けして任せるなら Pool が便利です。
def knead(n):
s = 0
for i in range(n):
s += i
return s
if __name__ == "__main__":
with mp.Pool(processes=4) as pool:
print(pool.map(knead, [10, 100, 1000, 10_000]))
# [45, 4950, 499500, 49995000]if __name__ == "__main__": がどうしても必要な理由WindowsとmacOSは、支店をspawn方式で開きます。新しいPythonを立ち上げ、そのPythonがあなたのファイルを最初からimportし直します。関数の定義を読み直さないと、knead が何なのかわからないからです。
このチェックがないと、支店がファイルを読んでいる途中で mp.Process(...).start() の行にまた出会い、支店がさらに支店を開こうとします。Pythonはこれを止めて、エラーを出します。
# チェックなしで、いちばん外側でいきなり始めると(Windows)
p = mp.Process(target=knead, args=(1,))
p.start()
# 支店側で:
# RuntimeError:
# An attempt has been made to start a new process before the
# current process has finished its bootstrapping phase.
# ...そのため、multiprocessingの例はファイルに保存して python ファイル名.py で実行します。対話シェルやノートブックでは、支店が関数を見つけられずに失敗しがちです。
7限目の ThreadPoolExecutor を覚えていますか? ProcessPoolExecutor は同じ使い方で、作業をプロセスに任せます。submit、Future、result()、map、as_completed がすべてそのままです。
import os
from concurrent.futures import ProcessPoolExecutor
def knead(n):
s = 0
for i in range(n):
s += i
return os.getpid(), s
if __name__ == "__main__":
print("main", os.getpid())
with ProcessPoolExecutor(max_workers=2) as ex:
future = ex.submit(knead, 1000)
print(future.result())
for pid, s in ex.map(knead, [10, 100, 1000]):
print(pid, s)
# 例:
# main 43452
# (42784, 499500)
# 42784 45
# 42784 4950
# 42784 499500プロセス番号が main とは違います。どの支店がどの生地を受け持つかは、実行ごとに変わります。
支店と本店はメモリを共有しないので、関数と引数と結果をバイト列に梱包して(pickle)送り合います。梱包できないものは送れません。代表的な例がラムダです。
def double(x):
return x * 2
if __name__ == "__main__":
with ProcessPoolExecutor(max_workers=2) as ex:
print(ex.submit(double, 21).result())
try:
ex.submit(lambda x: x * 2, 21).result()
except Exception as e:
print(type(e).__name__, e)
# 42
# PicklingError Can't pickle <function <lambda> at 0x...>: it's not found as __main__.<lambda>double」を探して使います。名前のないラムダは、支店が見つけられません。def で定義します。関数の中の関数やラムダはだめです。支店はタダではありません。Pythonを新しく立ち上げ、ファイルをimportし直し、値を梱包して行き来させなければなりません。生地が小さいと、この準備時間のほうが仕事より大きくなります。
# 8-1の小さな生地(200万回)4つをプロセス4つで
with ProcessPoolExecutor(max_workers=4) as ex:
results = list(ex.map(knead, [2_000_000] * 4))
# 4 processes 0.31s (12コアPCでの実測、順番にやると0.24秒)| 方法(小さな生地4つ) | 時間(12コアPCでの実測) |
|---|---|
| 順番に | 0.24秒 |
| スレッド4本 | 0.23秒 |
| プロセス4つ | 0.31秒 |
かえって遅くなりました。生地を10倍(2000万回)にすると話が変わります。このPCでは、だいたい順番に2.4〜2.7秒、プロセス4つで0.9〜1.1秒でした。準備のコストはほとんど変わらないのに、仕事が大きくなったからです。
map の chunksize=)。with ProcessPoolExecutor() を新しく開くのは、毎回支店を一から構えるようなものです。支店をどうやって構えるかを開始方式(start method)といいます。
| 方式 | すること | 厨房でいえば |
|---|---|---|
spawn | 新しいPythonを立ち上げ、ファイルをimportし直す | 空きビルに厨房を一から構える |
fork | 今のプロセスをまるごと複製 | 本店をそのままコピーする(作業中の料理人のロックまで) |
forkserver | きれいなサーバープロセスをひとつ置き、そこから複製 | きれいな見本の厨房を置いて、それをコピーする |
forkserver です。WindowsとmacOSは spawn です。以前のLinuxのデフォルトだった fork は、スレッドが動いているプログラムを複製するとロックがこじれることがあるため変更されました。spawn しかありません。import multiprocessing as mp
if __name__ == "__main__":
print(mp.get_start_method()) # spawn (Windows)
print(mp.get_all_start_methods()) # ['spawn']
ctx = mp.get_context("spawn") # このプールだけspawnで
with ctx.Pool(processes=2) as pool:
print(pool.map(knead, [10, 100])) # [45, 4950]mp.get_context("spawn") は、そのコンテキストで作った Pool、Process にだけ適用されます。ライブラリの中ではこちらが安全です。if __name__ == "__main__": の先頭で mp.set_start_method("spawn") を一度だけ呼びます。二度呼ぶと RuntimeError: context has already been set になります。spawn を明示してテストしてください。3.14からフリースレッド(free-threaded)版が公式にサポートされました。GILを外して作ったPythonで、実行ファイルの名前は python3.14t です(末尾の t)。厨房をリフォームして、包丁が料理人の数だけそろったようなものです。
uv run --python 3.14t ファイル名.py で実行します。sys._is_gil_enabled() で確かめます。# knead_threads.py:8-1のコードのいちばん上に2行足すだけ
import sys
print(sys._is_gil_enabled())
...
# python knead_threads.py
# True
# sequential 0.24s
# 4 threads 0.23s
# python3.14t knead_threads.py
# False
# ...
# 4 threads 0.09s (12コアPCでの実測、約2.7倍)コードは1文字も変えていません。同じスレッドのコードが、インタープリターを替えただけで約2.7倍速くなりました。4倍にならないのは、スレッドを立ち上げて管理するコストと、GILなしでも安全に動くための内部の仕組みのコストのせいです。その代わり、スレッドをひとつしか使わないコードは、普通の版より少し遅くなることがあります。
知っておきたい点が2つあります。
PYTHON_GIL=0(または -X gil=0)で強制的に無効にでき、PYTHON_GIL=1 なら3.14tでもGILが有効になります。下の実習台の上側にあるバー4本は、教材を準備しながら12コアのPCで測った値です。その下の「サーバーで直接こねる」は、このサイトのデプロイ先サーバーで生地(60万回 × 4)を順番に、そしてスレッド4本で実際にこねます。デプロイ先のサーバーはGILのある版で、コア数も前もってわからないので、数字は実測のバーと違うことがあります。2本のバーの長さが同じくらいかどうかを見てください。
生地くらべ
ローカル実測(12コアPC、Python 3.14)
順番に
0.24s
スレッド4本
0.23s
プロセス4つ
0.31s
スレッド4本(3.14t、GILなし)
0.09s
シェフのひとこと
GILがなくなったら、消えたのは包丁ルールであって、黒板ルールじゃないからね。同じ黒板に何人も書いたら、今度は本当に同時に書くことになる。3.14tに移す前に、まずロックを確かめ直して。
支店は高くつくし、リフォームにはまだすべてのライブラリが追いついていません。そのあいだにもうひとつ選択肢があります。ひとつの建物の中に仕切りを立てた厨房、つまりサブインタープリターです。ひとつのプロセスの中にインタープリターをいくつも置き、仕切りごとにGIL(包丁)が別々にあります。3.14で concurrent.interpreters モジュールとして標準ライブラリに入りました。
from concurrent import interpreters
booth = interpreters.create()
booth.exec("menu = ['#1', '#2']; print('booth menu:', menu)")
booth.exec("menu.append('#3'); print('booth menu:', menu)")
print("main has menu?", "menu" in globals())
def knead(n):
s = 0
for i in range(n):
s += i
return s
print(booth.call(knead, 1000))
booth.close()
# booth menu: ['#1', '#2']
# booth menu: ['#1', '#2', '#3']
# main has menu? False
# 499500create() で仕切りを作り、exec(コード文字列) でその仕切りの中でコードを動かし、call(関数, 引数) で関数を呼んで結果を受け取ります。menu)は仕切りの中にしかありません。支店と同じく、メモリを共有しません。実務では、7限目と同じ形の InterpreterPoolExecutor(3.14)が便利です。
from concurrent.futures import InterpreterPoolExecutor
if __name__ == "__main__":
with InterpreterPoolExecutor(max_workers=4) as ex:
results = list(ex.map(knead, [2_000_000] * 4))
# このPCでだいたい0.18秒(GILのある3.14、順番にやると0.24秒)仕切りごとに包丁が別にあるので、普通のPythonでも生地が並んで進みます。まだ多くのC拡張がサブインタープリターに対応していない点は覚えておいてください。
今夜の目標は、生地4玉を一人でやるときの2倍以上の速さで仕上げることでした。純粋なPythonの計算なのでスレッドではだめで、1玉が大きいので支店を開くコストを払う価値があります。
import time
from concurrent.futures import ProcessPoolExecutor
BIG = 20_000_000 # 大きな生地1玉
def knead(n):
s = 0
for i in range(n):
s += i
return s
if __name__ == "__main__":
start = time.perf_counter()
alone = [knead(BIG) for _ in range(4)]
print(f"sequential {time.perf_counter() - start:.2f}s")
start = time.perf_counter()
with ProcessPoolExecutor(max_workers=4) as ex:
branches = list(ex.map(knead, [BIG] * 4))
print(f"4 processes {time.perf_counter() - start:.2f}s", branches == alone)
# 例(このPCで):
# sequential 2.43s
# 4 processes 0.93s Trueだいたい2.5倍。結果も一人でやったものとまったく同じです。3.14tがあれば、knead をスレッド4本で回しても同じような効果が得られます。
| 道具 | 包丁(GIL) | メモリ | 立ち上げのコスト | 向いている仕事 |
|---|---|---|---|---|
| スレッド(3.14) | プロセスにひとつ | 共有する | 小さい | 待つ仕事 |
| スレッド(3.14t) | ない | 共有する | 小さい | 待つ仕事 + 計算 |
| プロセス | 支店ごとにひとつ | 別々(pickle) | 大きい | 大きな計算 |
| サブインタープリター | 仕切りごとにひとつ | 別々 | 中くらい | 計算、純粋なPythonのコード |
シェフがエプロンをほどきながら言います。「生地は厨房を増やして解決した。じゃあホールは? テーブル10卓に係を10人置く? それとも一人で10卓を回してもらう?」
営業日誌 — 八日目の夜。生地はスレッドでは速くならなかった。包丁が1本しかないから。4つの支店に分けて任せ、2.5倍速く仕上げた。明日はホールを改装する。
knead を time.sleep(0.06) に置き換え、順番にやる場合とスレッド4本の場合をもう一度測ってみましょう。今度はなぜスレッドで速くなるのか、GILを使って説明してみてください。ProcessPoolExecutor の時間を表にまとめてみましょう。あなたのPCでプロセスが勝ち始めるのはどの大きさからでしょう?ProcessPoolExecutor に、関数の中で定義した関数(入れ子の関数)を submit してみて、どんなエラーが出るか確かめてから直してみましょう。シェフのレシピカード
GIL:Pythonのバイトコードは一度にひとつのスレッドだけ。待つとき(I/O)と一部のCのコードはGILを手放す。
計算(CPU)処理はスレッドでは速くならない → プロセス、3.14t、サブインタープリター。
ProcessPoolExecutor は ThreadPoolExecutor と同じ使い方。if __name__ == "__main__": は必須。
支店とやりとりする値はpickleされる:ラムダや入れ子の関数はだめ。いちばん外側の def で。
プロセスは立ち上げのコストが大きい:大きな計算だけに。開始方式はWindows・macOSがspawn、3.14のLinuxはforkserver。
sys._is_gil_enabled():3.14t(python3.14t)では False。同じスレッドのコードがそのまま並列に。
concurrent.interpreters:create() → exec() / call()、InterpreterPoolExecutor(3.14)。
新しい授業・講座のお知らせを受け取る
この講座に授業が追加されたり、続きの講座が公開されたりしたらメールでお知らせします。
R
ReflexKR
触って学ぶPythonとReflex。すべての例はReflex 0.10.0で実際に動かして確認しています。
ReflexKRはバナー広告なし・無料で運営しています。役に立ったら、コーヒー一杯で応援してください。 ☕ コーヒーで応援する
アフィリエイトと支援について© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
質問と回答
この回について気になることを書き込んでください。すべての言語のページの質問がここに集まります。