GIL と並行性 — threading vs multiprocessing vs asyncio
GIL の正体、threading/multiprocessing/asyncio の 3 つの道具の分担、そして Python 3.13〜3.14 の free-threaded ビルド (PEP 703/779) までをまとめます。
第18章 非同期の深さ で「CPU バウンドは asyncio では解決されない」と簡単に見ました。本章ではそのテーマを掘り下げます。GIL の正体、threading / multiprocessing / asyncio の 3 つの道具の分担、そして Python 3.13 〜 3.14 が持ち込んだ free-threaded ビルド までを整理します。
本章は第21章 パフォーマンス と対をなします。第21章が「自分のコードのボトルネックがどこかを測定」なら、本章は「ボトルネックが CPU / I/O のどちらかによってどの道具を選ぶか」の決定ガイドです。
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 — もう一度
#
第14章 / 第18章 の非同期は シングルスレッド + 協力的譲渡 です。
| 項目 | 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 つのプロセスの中に複数のインタープリタを置き、各インタープリタが自分の 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 vs asyncio.sleep の混在
#
async def fetch(url):
time.sleep(1) # GIL 保持 + イベントループ停止
...第14章 で見たもの。常に await asyncio.sleep。
2) print が thread-safe ではない #
def worker(i):
print(f"start {i}")
do_work()
print(f"end {i}")同時に複数のスレッドが print すると 行が混ざることがあります。 logging モジュールは thread-safe なのでそちらを使ってください。第31章 logging と観測性 で運用環境の logging セットアップを扱います。
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. ボトルネックがどこかを測定 (第21章 [パフォーマンス](./performance/) で扱う)
3. ボトルネックが I/O バウンド → asyncio または ThreadPoolExecutor
4. ボトルネックが CPU バウンド → ProcessPoolExecutor または free-threaded
5. 上記すべてでだめならライブラリ自体をより速いものに変える (Cython、Rust 拡張)早期最適化はここでも適用 されます。同期コードで十分速いなら、並行性を使わない方が単純で安全です。
練習問題 #
cpu_heavy(n)(例:sum(i ** 2 for i in range(n))) 関数を、(1) 同期直列、(2)ThreadPoolExecutor(8)、(3)ProcessPoolExecutor(8)の 3 つの方式で同じ入力 8 個に適用して時間を測定します。GIL の効果が直接見えます。requests.get(url)という同期ライブラリで URL 100 個を fetch するコードを、(1) 同期直列、(2)ThreadPoolExecutor(20)で比較します。I/O バウンドではスレッドが効果を出すことを確認します。- 同じ仕事を
httpx.AsyncClient+asyncio.gatherで書き、上の (2) と比較します。並行性が 1000 個に増えたときにどのモデルがより耐えられるか仮説を立てて測定します。
一行まとめ: GIL は CPython の全域ロック、一度に 1 スレッドだけバイトコード。I/O 中は GIL が解放されて threading が有効、CPU バウンドは multiprocessing / 3.14 free-threaded。
asyncioはシングルスレッドで N 万の並行性、CPU バウンドは解決できない。道具の分担はThreadPoolExecutor(I/O 同期) /ProcessPoolExecutor(CPU) /asyncio(大規模 I/O 非同期) / 3.14+ free-threaded (CPU 並行性、互換性に注意)。開始は同期 → 測定 → ボトルネック選択。
次の章 #
次の 第20章 typing 上級 — Variance、ParamSpec、Self、overload では、第9章 typing 本格 の次の段階 — Variance、ParamSpec、Self、TypeGuard / TypeIs、overload まで typing の難しい部分を扱います。