モダンPython上級 #5 GILと並行性 — threading vs multiprocessing vs asyncio
#4 非同期の深さ で「CPU バウンドは asyncio では解決しない」と短く触れました。今回がそのテーマです。GIL の正体、threading/multiprocessing/asyncio という 3 つの道具の分担、そして Python 3.13~3.14 がもたらした free-threaded ビルド までを整理します。
GIL — Global Interpreter Lock #
CPython (標準 Python 実装) には GIL というグローバルロックがあります。一度に 1 つのスレッドだけが Python バイトコードを実行 できます。
なぜあるのか #
CPython 内部のオブジェクト参照カウントを安全にするため。ロックなしで同時にオブジェクト参照を触ると、カウントがずれてメモリが壊れます。シンプルさ + シングルスレッド性能 + C 拡張の互換性 のために導入され、30 年以上維持されてきました。
結果 — CPU バウンドのマルチスレッディングは無意味 #
import threading
def cpu_heavy(n):
total = 0
for i in range(n):
total += i ** 2
return total
threads = [threading.Thread(target=cpu_heavy, args=(10_000_000,)) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()スレッド 4 つで同じ仕事を 4 回しても 4 倍速くなりません。GIL のせいで事実上シリアルに実行されます。CPU コアが 8 個あっても 1 個しか使っていないのです。
GIL が解放されるタイミング — I/O #
幸い、I/O 動作中は GIL が解放されます。 socket.recv、time.sleep、ファイル読み込み、DB クエリなどは別のスレッドが同時に進めることができます。だから I/O バウンドのマルチスレッディングは意味があり、CPU バウンドは無意味なのです。
NumPy、Pandas のような C 拡張も重い演算中には GIL を解放するケースが多く、数値計算はある程度マルチスレッドの効果があります。 ライブラリによります。
3 つの道具のポジション #
| 道具 | 適する用途 | コア活用 |
|---|---|---|
asyncio | I/O バウンド、同時 N 千~ N 万 | 1 |
threading | I/O バウンド、同期ライブラリ、少ない並行性 | 1 |
multiprocessing | CPU バウンド | N |
concurrent.futures | 両モード統合インターフェース | モードに応じて |
threading — 同期コードの並行性
#
import threading
def fetch(url):
response = requests.get(url)
return response.text
threads = []
for url in urls:
t = threading.Thread(target=fetch, args=(url,))
t.start()
threads.append(t)
for t in threads:
t.join()長所:
- 同期ライブラリをそのまま使える
- 既存コードの変更が最小
- I/O バウンドなら十分速い
短所:
- 並行性が N 千単位になるとスレッド自体のコスト (メモリ、コンテキストスイッチ)
- ロック / 共有状態が頻繁だとデバッグが難しい
- CPU バウンドは GIL のせいで無意味
Lock、RLock、Semaphore、Event
#
counter = 0
lock = threading.Lock()
def increment():
global counter
with lock:
counter += 1+= はアトミックではありません (load → add → store)。ロックなしでは race condition が起こります。非同期にはこの問題がない (一度に 1 つのコルーチン) のですが、スレッドにはあります。
concurrent.futures.ThreadPoolExecutor — より便利なインターフェース
#
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=10) as pool:
results = list(pool.map(fetch, urls))直接 Thread オブジェクトを作る場面はほとんどありません。ThreadPoolExecutor が標準的な答えです。
multiprocessing — 本物の並列
#
CPU バウンドは 別プロセスに送ります。各プロセスが自分の GIL を持つので、本物の並列実行になります。
from concurrent.futures import ProcessPoolExecutor
def cpu_heavy(n):
return sum(i ** 2 for i in range(n))
if __name__ == "__main__":
with ProcessPoolExecutor(max_workers=8) as pool:
results = list(pool.map(cpu_heavy, [10**7] * 8))8 つのプロセスが 8 つのコアを同時に使います。本物の 8 倍近い速度。
コスト — プロセス生成と IPC #
- プロセス生成がスレッドより高価
- データ転送がシリアライズ / デシリアライズ (pickle) を経る — 大きなデータの転送は高価
- デバッグが難しくなる (プロセス分離)
なので 重い計算をまとめて 送るべきです。小さい仕事を頻繁に送ると、IPC コストの方が計算より大きくなります。
if __name__ == "__main__": — 必須
#
def worker(x): ...
if __name__ == "__main__":
with ProcessPoolExecutor() as pool:
pool.map(worker, [1, 2, 3])multiprocessing は子プロセスを作るときにモジュールを再 import します。そのときに pool.map(...) のような呼び出しが 子でまた実行されると無限再帰 です。ガードが必須。
共有状態 — Queue、Manager、shared_memory
#
プロセス間のデータ共有は厄介です。
from multiprocessing import Queue, Process
def worker(q):
q.put("hello from worker")
if __name__ == "__main__":
q = Queue()
p = Process(target=worker, args=(q,))
p.start()
print(q.get()) # hello from worker
p.join()multiprocessing.Queue— プロセス間で安全なキューmultiprocessing.Manager— dict/list などをプロキシで共有multiprocessing.shared_memory(3.8+) — 大きな numpy 配列などをコピーなしで共有
asyncio — もう一度
#
#4 の非同期は シングルスレッド + 協調的な譲渡 です。
| ポジション | asyncio |
|---|---|
| 強み | 並行性 N 万、少メモリ、明示的な譲渡点 |
| 弱み | 非同期ライブラリが必要、CPU バウンドは効果なし |
asyncio と threading を混ぜる #
asyncio.to_thread で同期関数を非同期の中で安全に。
import asyncio
async def fetch(url):
return await asyncio.to_thread(requests.get, url)asyncio と multiprocessing を混ぜる #
import asyncio
from concurrent.futures import ProcessPoolExecutor
async def main():
loop = asyncio.get_running_loop()
with ProcessPoolExecutor() as pool:
result = await loop.run_in_executor(pool, cpu_heavy, 10_000_000)CPU バウンドの仕事を別プロセスに送り、結果は非同期で await。
Free-threaded — Python 3.13~3.14 の大きな変化 #
PEP 703 が GIL なしのビルド を導入しました。3.13 で実験段階、3.14 で PEP 779 として正式サポート段階 に入りました。
何が変わるか #
- GIL がなくなる — CPU バウンドのマルチスレッディングが本当に動作する
- 既存の同期コードをマルチスレッドで動かすと 自動でコア数だけ加速
- 新しい並行モデル (シングルプロセス + 本物のマルチスレッド) が可能
コスト — シングルスレッド性能 #
GIL はシンプルなぶん、シングルスレッド性能を引き上げる道具でもありました。なくすと若干のシングルスレッド性能損失があります。3.14 時点で 約 5~10% の損失 程度。時間とともに減っていく傾向。
どう使うか #
# uv で free-threaded ビルドをインストール
uv python install 3.14t
# (t が free-threaded ビルド)
# プロジェクトがこれを使うように
uv init my-app --python 3.14tライブラリ互換性 #
C 拡張ライブラリには GIL を前提としたコードが多く、互換性マイグレーションが進行中です。NumPy、PyTorch、Pillow などの主要ライブラリは free-threaded 互換を進めたか進行中です。新しいプロジェクトなら free-threaded から試す価値はありますが、レガシー依存があるプロジェクトは互換性確認が必須 です。
sub-interpreter — PEP 734 (3.14) #
GIL 回避のもう 1 つの方向。1 つのプロセスの中に複数のインタプリタを置き、各インタプリタが自分の GIL を持つモデル。
from concurrent.futures import InterpreterPoolExecutor
with InterpreterPoolExecutor(max_workers=4) as pool:
results = list(pool.map(cpu_heavy, [10**7] * 4))multiprocessing より軽く、free-threaded より互換性が安全な中間地帯。3.14 時点ではまだ新しい領域ですが、CPU バウンド並行性の重要なオプション として定着する可能性が大きいです。
決定ガイド — 一覧 #
| 仕事 | 最初に試すもの |
|---|---|
| HTTP リクエスト 1000 個を同時に | asyncio (httpx) |
| HTTP リクエスト 50 個、同期ライブラリを使用中 | ThreadPoolExecutor |
| 重い数値計算で 8 コアを活用 | ProcessPoolExecutor |
| numpy が GIL を解放する重い演算 | ThreadPoolExecutor (または numpy 自体の並列化) |
| 新規プロジェクトで CPU + I/O を同時活用 | 3.14 free-threaded + threading |
| 安定した分離、大きなデータ共有 | multiprocessing + shared_memory |
| シングルプロセスで CPU バウンドを多重 | InterpreterPoolExecutor (3.14+) |
よく出会う落とし穴 #
1) time.sleep と asyncio.sleep を混ぜる
#
async def fetch(url):
time.sleep(1) # GIL 保有 + イベントループ停止
...中級 #7 で見たもの。常に await asyncio.sleep。
2) print は thread-safe ではない #
def worker(i):
print(f"start {i}")
do_work()
print(f"end {i}")同時に複数のスレッドが print すると 行が混ざることがあります。 logging モジュールは thread-safe なので、そちらを使ってください。
3) multiprocessing で NumPy 配列を頻繁に送る #
with ProcessPoolExecutor() as pool:
pool.map(process, [big_numpy_array] * 100)配列を pickle でシリアライズ → 子に転送 → 子で deserialize。計算より IPC コストの方が大きくなります。 shared_memory、または numpy 自体の並列化 (BLAS/LAPACK のマルチスレッド) を検討。
4) デッドロック #
複数のロックを別の順序で取るとデッドロックします。
def t1():
with lock_a:
with lock_b: # t2 が b を取って a を待っているとデッドロック
...
def t2():
with lock_b:
with lock_a:
...ルール: ロックの取得順序をコード全体で一貫させる。または threading.RLock で再入可能ロックを使用。
実践 — どう始めるか #
1. まず同期コードで動かす
2. ボトルネックがどこか測定する (#7 で扱う)
3. ボトルネックが I/O バウンド → asyncio または ThreadPoolExecutor
4. ボトルネックが CPU バウンド → ProcessPoolExecutor または free-threaded
5. 上記すべてでダメならライブラリ自体をより速いものに変える (Cython、Rust 拡張)早すぎる最適化はここでも適用 されます。同期コードが十分速ければ、並行性を使わないのが正解です。
まとめ #
今回つかんだもの:
- GIL — CPython のグローバルロック、一度に 1 スレッドだけバイトコードを実行
- I/O 動作中は GIL が解放され、マルチスレッディングは意味を持つ。CPU バウンドは無意味
asyncio(シングルスレッド、大規模並行性)、threading(I/O + 同期コード)、multiprocessing(CPU バウンド)concurrent.futuresの ThreadPoolExecutor / ProcessPoolExecutor が標準- multiprocessing は IPC コスト — 重い仕事をまとめて送る、
if __name__ == "__main__":ガードは必須 - Free-threaded (3.13~3.14、PEP 703/779) — GIL なしビルド、CPU 並行性が本当に動作
- Sub-interpreter (3.14、PEP 734) — プロセス内の複数インタプリタ、新しい選択肢
- 落とし穴: 同期 sleep、print の並行性、IPC コスト、デッドロック
- 始めるときは同期 → 測定 → ボトルネックに応じて選択
次回(#6 typing 上級)では、中級 #2 の次の段階 — Variance、ParamSpec、Self、TypeGuard/TypeIs、overload まで、typing の難しい部分を扱います。