モダンPython上級 #5 GILと並行性 — threading vs multiprocessing vs asyncio

読了 8分

#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.recvtime.sleep、ファイル読み込み、DB クエリなどは別のスレッドが同時に進めることができます。だから I/O バウンドのマルチスレッディングは意味があり、CPU バウンドは無意味なのです。

NumPy、Pandas のような C 拡張も重い演算中には GIL を解放するケースが多く、数値計算はある程度マルチスレッドの効果があります。 ライブラリによります。

3 つの道具のポジション #

道具適する用途コア活用
asyncioI/O バウンド、同時 N 千~ N 万1
threadingI/O バウンド、同期ライブラリ、少ない並行性1
multiprocessingCPU バウンドN
concurrent.futures両モード統合インターフェースモードに応じて

threading — 同期コードの並行性 #

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 のせいで無意味

LockRLockSemaphoreEvent #

共有状態の保護
counter = 0
lock = threading.Lock()

def increment():
    global counter
    with lock:
        counter += 1

+= はアトミックではありません (load → add → store)。ロックなしでは race condition が起こります。非同期にはこの問題がない (一度に 1 つのコルーチン) のですが、スレッドにはあります。

concurrent.futures.ThreadPoolExecutor — より便利なインターフェース #

ThreadPoolExecutor
from concurrent.futures import ThreadPoolExecutor

with ThreadPoolExecutor(max_workers=10) as pool:
    results = list(pool.map(fetch, urls))

直接 Thread オブジェクトを作る場面はほとんどありません。ThreadPoolExecutor が標準的な答えです。

multiprocessing — 本物の並列 #

CPU バウンドは 別プロセスに送ります。各プロセスが自分の GIL を持つので、本物の並列実行になります。

multiprocessing の基本
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(...) のような呼び出しが 子でまた実行されると無限再帰 です。ガードが必須。

共有状態 — QueueManagershared_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% の損失 程度。時間とともに減っていく傾向。

どう使うか #

free-threaded ビルドの使用
# 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 を持つモデル。

sub-interpreter (3.14+)
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.sleepasyncio.sleep を混ぜる #

🚫 非同期の中で同期 sleep
async def fetch(url):
    time.sleep(1)    # GIL 保有 + イベントループ停止
    ...

中級 #7 で見たもの。常に await asyncio.sleep

2) print は thread-safe ではない #

thread + print
def worker(i):
    print(f"start {i}")
    do_work()
    print(f"end {i}")

同時に複数のスレッドが print すると 行が混ざることがあります。 logging モジュールは thread-safe なので、そちらを使ってください。

3) multiprocessing で NumPy 配列を頻繁に送る #

🚫 大きな配列を毎回 pickle
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.futuresThreadPoolExecutor / 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 の難しい部分を扱います。

X