Python の GIL 実測:スレッド・プロセス・asyncio の使い分け

CPU処理とI/O待ちをスレッド・プロセス・asyncioで1〜8ワーカー実測。macOSのspawnで落ちる理由、Lockで直す競合状態、イベントループを510ms止めたブロッキング呼び出しと判断表まで。

シリーズ:Python 講座 5/6(上級)。 関数・ジェネレーター・デコレーターは書けるようになり、次は「同時に複数のことをさせたい」段階の人向けです。読み終えると、スレッド・プロセス・asyncio のどれを使うかを、噂ではなく実測値で決められるようになります。

スクレイピングのスクリプトに threading を足したのに速くならなかった、FastAPI のサービスがときどき0.5秒ほど固まる、社内の Linux サーバーでは動く multiprocessing が Mac では落ちる。この講座はそういう悩みを持つ人のためのものです。結論を先に書くと、待ちが多いならスレッド、計算が重いならプロセス、数千の待ちを同時に抱えるなら asyncio。そして asyncio はブロッキング呼び出しが一つ混ざるだけで台無しになります。

実行環境は Apple Silicon Mac(M1 Pro、高性能コア6+高効率コア2)です。時間の計測はすべて Python 3.9 で行いました。asyncio.TaskGroup や except* のように 3.11 が必要な機能は Python 3.11 で動かしましたが、こちらは Rosetta 2 の変換下で動くビルドなので、速度の比較には使っていません。計測中は他の処理も動いていたため、数値は5回の中央値です。

GIL を一段落で

CPython には GIL(グローバルインタープリターロック)があり、Python のバイトコードを実行できるスレッドは同時に一つだけです。I/O を待つスレッド(ソケット、ファイル、time.sleep)はロックを手放すので、待っている間は別のスレッドが動けます。純粋な Python のループを回しているスレッドはロックを握ったままで、インタープリターが数ミリ秒ごとに「譲って」と頼むだけです。

>>> import sys; sys.getswitchinterval()
0.005

この5msが切り替え間隔です。スレッドは交代で動くだけで、Python コードを並列には実行しません。プロセスはそれぞれ自分のインタープリターと GIL を持つので本当に並列に動きますが、起動コストと、引数・戻り値を pickle してやり取りするコストがかかります。

判断図。まず何が遅いかを見る。純粋な Python で計算しているならプロセスプール、全体で0.1秒程度に満たないなら逐次のまま。I/O を待っているなら同時に待つ数とライブラリが非同期かを確認する。数十〜数百でブロッキングのライブラリならmax_workersを指定したスレッドプール。数千で非同期ライブラリがそろっていればasyncio、残ったブロッキング呼び出しはasyncio.to_threadへ逃がす。
スレッド・プロセス・asyncio の選び方。どの API が新しいかではなく、プログラムが何を待っているかから決める。

CPU 処理:スレッドとプロセスを 1/2/4/8 ワーカーで比較

同じ量のジョブを8個用意しました。中身は300万回の純粋 Python ループです。逐次実行、ThreadPoolExecutor、ProcessPoolExecutor の3通りで回し、プールの生成も計測範囲に含めています(プロセスの起動時間も数字に入っているということです)。

# cpu_bound.py
import time, sys, multiprocessing as mp
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def work(n):
    total = 0
    for i in range(n):
        total += i * i % 7
    return total

JOBS = [3_000_000] * 8   # 8 equal jobs

def timed(label, fn):
    t = time.perf_counter()
    res = fn()
    print(f"{label:<22}{time.perf_counter() - t:6.2f} s")
    return res

if __name__ == "__main__":
    print(sys.version.split()[0], "start method:", mp.get_start_method())
    expected = timed("sequential", lambda: [work(n) for n in JOBS])
    for w in (1, 2, 4, 8):
        with ThreadPoolExecutor(max_workers=w) as ex:
            assert timed(f"threads  x{w}", lambda: list(ex.map(work, JOBS))) == expected
    for w in (1, 2, 4, 8):
        with ProcessPoolExecutor(max_workers=w) as ex:
            assert timed(f"processes x{w}", lambda: list(ex.map(work, JOBS))) == expected

出力の1行目は 3.9.6 start method: spawn です。5回の中央値:

方式 1 2 4 8
逐次 1.70秒 — — —
スレッド 1.73秒 1.81秒 1.80秒 1.85秒
プロセス 1.77秒 0.92秒 0.52秒 0.47秒
実行時間(秒)の棒グラフ。逐次1.70。スレッド1・2・4・8本:1.73、1.81、1.80、1.85。プロセス1・2・4・8個:1.77、0.92、0.52、0.47。
同じ量の純粋 Python ジョブ8個(各300万回のループ)。5回の中央値、Python 3.9、Apple M1 Pro(高性能6+高効率2コア)、プール起動時間を含む。スレッドは逐次より速くならず、プロセスは速くなった。

表から読み取れることは三つあります。

  1. スレッドは一度も逐次より速くなりませんでした。 本数を増やすとむしろわずかに遅くなります。GIL の受け渡しに時間を使うからです。ばらつきも大きく、4本の回で2.84秒かかったこともありました。
  2. プロセスは4ワーカーまではほぼ比例して速くなり、そこで頭打ちになりました。 4→8 では0.52秒→0.47秒だけです。この Mac は高性能コア6個と遅い高効率コア2個の構成で、8ジョブだと最後のジョブが遅いコアを待つことになります。
  3. プロセスプールには固定費があります。 spawn で8ワーカーを起動して何もしないタスクを渡すだけで、中央値 0.099秒 かかりました。全体で50ms程度の処理なら、プロセスプールにするとかえって遅くなります。

macOS の落とし穴:spawn と if __name__ == "__main__"

Linux の Python(3.13 まで)は fork で子プロセスを作ります。子は親のメモリのコピーです。macOS は Python 3.8 から既定が spawn で、子は新しいインタープリターとして起動し、work を見つけるためにメインモジュールをもう一度 import します。モジュールの最上位に書いたコードは、子ごとにもう一度実行されるわけです。

よくある失敗例が、ガードのないスクリプトです。

# noguard.py — よくある失敗
from concurrent.futures import ProcessPoolExecutor

def square(x):
    return x * x

with ProcessPoolExecutor(max_workers=2) as ex:
    print(list(ex.map(square, range(5))))

Python 3.9 / macOS で実際に起きること(出力は125行。抜粋):

RuntimeError:
        An attempt has been made to start a new process before the
        current process has finished its bootstrapping phase.

        This probably means that you are not using fork to start your
        child processes and you have forgotten to use the proper idiom
        in the main module:

            if __name__ == '__main__':
                freeze_support()
                ...
Traceback (most recent call last):
  File "~/demo/noguard.py", line 8, in <module>
    print(list(ex.map(square, range(5))))
  ...
concurrent.futures.process.BrokenProcessPool: A process in the process pool was terminated abruptly while the future was running or pending.

子プロセスは import の途中で with ProcessPoolExecutor(...) の行を実行し、自分のプールを作ろうとして異常終了します。親から見るとプールが壊れた状態です。直し方: プロセスを起動するコードはすべて if __name__ == "__main__": の中に入れ、ワーカー関数はモジュールの最上位に置きます(ラムダや入れ子の関数は pickle できないので子に渡せません)。本番の Linux サーバーで動くコードが、開発者の Mac では落ちる。この非対称がこの罠のやっかいなところです。

なお Python 3.14 では Linux の既定が fork から forkserver に変わります。3.14 は今回動かしていませんが、ガードを書いておけばどの起動方式でも正しく動きます。

I/O 待ち:100件・1,000件でスレッドと asyncio を比較

今度は「待つ」処理です。ネットワーク呼び出しの代わりに100msのスリープを使いました(外部ネットワークには一切つないでいません)。スレッドでは time.sleep、コルーチンでは asyncio.sleep です。

# io_bound.py
import asyncio, time
from concurrent.futures import ThreadPoolExecutor

DELAY = 0.1  # each "request" waits 100 ms

def fetch_blocking(i):
    time.sleep(DELAY)
    return i

async def fetch_async(i):
    await asyncio.sleep(DELAY)
    return i

def with_threads(n, workers):
    with ThreadPoolExecutor(max_workers=workers) as ex:
        return list(ex.map(fetch_blocking, range(n)))

async def with_asyncio(n):
    return await asyncio.gather(*(fetch_async(i) for i in range(n)))

def timed(label, fn):
    t = time.perf_counter()
    fn()
    print(f"{label:<34}{time.perf_counter() - t:7.3f} s")

if __name__ == "__main__":
    for n in (100, 1000):
        print(f"--- {n} tasks ---")
        timed("threads, default max_workers", lambda: with_threads(n, None))
        timed(f"threads, max_workers={n}", lambda: with_threads(n, n))
        timed("asyncio.gather", lambda: asyncio.run(with_asyncio(n)))
100ms のタスク数 逐次 スレッド(既定のプール) スレッド(1タスク1本) asyncio
100 10.355秒 0.932秒 0.113秒 0.103秒
1,000 (約100秒、未実行) 8.684秒 0.210秒 0.110秒
10,000 — — 0.703秒、ピークRSS 90MB 0.196秒、ピークRSS 39MB
それぞれ100ミリ秒待つタスク1,000件の実行時間の棒グラフ。既定12ワーカーのスレッドプール:8.684秒。1,000ワーカーのスレッドプール:0.210秒。asyncio.gather:0.110秒。
100msの疑似リクエスト1,000件、Python 3.9。ThreadPoolExecutor の既定ワーカー数はこのMacで min(32, cpu_count + 4) = 12 しかなく、約84回に分けて実行された。

意外なのは2列目です。max_workers を省略した ThreadPoolExecutor() は min(32, os.cpu_count() + 4) 本、この Mac では 12本 しか作りません。1,000件を12本ずつ処理すると約84回 × 100ms で8.7秒です。既定値は「いろいろ混ざった処理」向けで、1,000本のソケットを同時に待つ用途向けではありません。1タスク1本にすれば、100件では asyncio とほぼ同じ、1,000件でも十分速く、10,000件になると asyncio の2倍以上のメモリ(resource.getrusage のピーク RSS、それぞれ別プロセスで計測)と3.6倍の時間を使いました。増えた時間は 10,000 本の OS スレッドを作って切り替えるコストです。

実測から言えるのは、同時に数百件程度までならサイズを指定したスレッドプールで十分で、ブロッキングなライブラリもそのまま使えるということです。asyncio が勝つのは数千件規模からで、それも経路上のライブラリがすべて非同期対応している場合に限ります。

競合状態:更新が消えるカウンター

GIL があってもコードはスレッドセーフになりません。counter += 1 は複数のバイトコード(3.9 では LOAD_GLOBAL、LOAD_CONST、INPLACE_ADD、STORE_GLOBAL)に分かれていて、読み込みと書き込みの間で切り替わると更新が一つ消えます。

# race.py
import sys, threading

N, THREADS = 1_000_000, 4
counter = 0
lock = threading.Lock()

def unsafe():
    global counter
    for _ in range(N):
        counter += 1          # read, add, write: three steps

def safe():
    global counter
    for _ in range(N):
        with lock:
            counter += 1

def run(target):
    global counter
    counter = 0
    ts = [threading.Thread(target=target) for _ in range(THREADS)]
    for t in ts: t.start()
    for t in ts: t.join()
    return counter

if __name__ == "__main__":
    print(sys.version.split()[0], "expected", N * THREADS)
    for i in range(3):
        print("unsafe:", run(unsafe))
    print("locked:", run(safe))
3.9.6 expected 4000000
unsafe: 1492419
unsafe: 2606045
unsafe: 2137778
locked: 4000000

半分以上の加算が消え、しかも毎回結果が違います。ここからがベテランもはまる点です。同じファイルを Python 3.11 で動かすと、3回とも 4000000 になりました。 新しいインタープリターはスレッド切り替えを確認する箇所が少なく、この2行のループはたまたま更新の途中で割り込まれなかったのです。それでも安全ではありません。読み込みと書き込みの間に関数呼び出しを一つ挟むと、3.11 でも更新が消えます。

def plus_one(x):
    return x + 1
...
        counter = plus_one(counter)   # 読み込みと書き込みの間に関数呼び出し
3.9.6 1919199 of 4000000
3.11.4 2616204 of 4000000

「3.11 で試したら大丈夫だった」は何の保証にもなりません。直すには Lock を使います。ただしコストはあり、3.9 で4スレッド × 100万回の加算は、ロックなしで0.23〜0.26秒、ロックありで1.00〜1.14秒でした。実際のコードでは、ロックする単位を大きくするか、スレッドごとに別のカウンターを持たせて最後に合計する方法が向いています。

asyncio の落とし穴:コルーチンの中のブロッキング呼び出し

asyncio はすべてのコルーチンを一つのスレッドで動かします。どれかのコルーチンがブロックする関数を呼ぶと、それが戻るまで他は何も動きません。止まった時間を測るため、10msごとに起きたい「心拍」タスクを走らせ、ハンドラーが3通りの方法で0.5秒待つ間の最大の空白を記録しました。

# stall.py
import asyncio, time

async def heartbeat(stop, gaps):
    last = time.perf_counter()
    while not stop.is_set():
        await asyncio.sleep(0.01)            # wants to wake every 10 ms
        now = time.perf_counter()
        gaps.append(now - last)
        last = now

async def handler(mode):
    if mode == "time.sleep":
        time.sleep(0.5)                      # blocks the whole event loop
    elif mode == "asyncio.sleep":
        await asyncio.sleep(0.5)
    elif mode == "to_thread":
        await asyncio.to_thread(time.sleep, 0.5)   # 3.9+

async def main(mode):
    stop, gaps = asyncio.Event(), []
    hb = asyncio.create_task(heartbeat(stop, gaps))
    await asyncio.sleep(0.05)
    t = time.perf_counter()
    await handler(mode)
    took = time.perf_counter() - t
    stop.set(); await hb
    print(f"{mode:<14} handler {took:.3f} s | heartbeat max gap {max(gaps)*1000:6.1f} ms, ticks {len(gaps)}")

if __name__ == "__main__":
    for mode in ("time.sleep", "asyncio.sleep", "to_thread"):
        asyncio.run(main(mode))
time.sleep     handler 0.505 s | heartbeat max gap  510.1 ms, ticks 5
asyncio.sleep  handler 0.501 s | heartbeat max gap   11.4 ms, ticks 51
to_thread      handler 0.508 s | heartbeat max gap   30.7 ms, ticks 46

ハンドラー自体はどれも0.5秒なので、ハンドラーの時間だけを測るテストでは問題が見えません。心拍のほうが正直で、time.sleep の場合はループ上の他のタスクがすべて 510ms 止まっていました。Web サーバーなら、その間ほかのクライアントのリクエストも全部止まるということです。requests.get、同期の DB ドライバー、遅いディスクでの open().read()、CPU を使い切るループも、ここでは time.sleep と同じ振る舞いをします。

実際のプロジェクトで見つけるにはデバッグモードを使います。asyncio.run(handler(), debug=True) で中に0.3秒の time.sleep を入れると、Python 3.9 は次のログを出しました。

Executing <Task finished name='Task-1' coro=<handler() done, defined at ~/demo/debugwarn.py:2> result=None created at .../asyncio/base_events.py:621> took 0.306 seconds

loop.slow_callback_duration(既定0.1秒)より長いコールバックが報告されます。もう一つありがちなのが、コルーチンを await せずに呼ぶ間違いです。何も実行されず、RuntimeWarning: coroutine 'f' was never awaited が出るだけです。

asyncio.TaskGroup(3.11+):失敗したらまとめて止める

asyncio.gather には不便な既定動作があります。一つのタスクが失敗すると例外は返ってきますが、残りのタスクは裏で動き続けます。Python 3.9 での出力:

  a done
caught: b: bad response after 0.20 s
  c done
main exits

エラー処理が終わった後で c が完了しています。3.11 で追加された asyncio.TaskGroup は、一つが失敗すると兄弟タスクをキャンセルし、ExceptionGroup を送出します。受け取るには新しい構文 except* を使います。

# taskgroup.py — Python 3.11+ が必要
import asyncio, sys, time

async def fetch(name, delay, fail=False):
    try:
        await asyncio.sleep(delay)
        if fail:
            raise ValueError(f"{name}: bad response")
        print(f"  {name} done")
        return name
    except asyncio.CancelledError:
        print(f"  {name} cancelled")
        raise

async def main():
    t = time.perf_counter()
    try:
        async with asyncio.TaskGroup() as tg:
            a = tg.create_task(fetch("a", 0.1))
            b = tg.create_task(fetch("b", 0.2, fail=True))
            c = tg.create_task(fetch("c", 1.0))
    except* ValueError as eg:
        print("caught:", [str(e) for e in eg.exceptions])
    print(f"elapsed {time.perf_counter() - t:.2f} s, a={a.result()!r}, c.cancelled()={c.cancelled()}")

print(sys.version.split()[0])
asyncio.run(main())
3.11.4
  a done
  c cancelled
caught: ['b: bad response']
elapsed 0.20 s, a='a', c.cancelled()=True

同じファイルを Python 3.9 で動かすと、except* が新しい構文なのでコンパイルの段階で止まります。

  File "~/demo/taskgroup.py", line 22
    except* ValueError as eg:
          ^
SyntaxError: invalid syntax

except* を外しても AttributeError: module 'asyncio' has no attribute 'TaskGroup' になります。3.9/3.10 でも動かす必要があるなら、gather を使い、残ったタスクは自分でキャンセルしてください。

判断表

状況 使うもの 根拠(上の実測)
純粋 Python の計算で数秒以上 ProcessPoolExecutor 4ワーカーで1.70秒→0.52秒。スレッドは効果なし
計算が全体で0.1秒未満 逐次のまま 8ワーカーの起動だけで0.099秒
NumPy・hashlib・zlib など GIL を解放する C 実装の中での計算 スレッドでも効くことがある その呼び出しの間は GIL が外れる(ライブラリごとに確認)
数十〜数百件のブロッキング I/O(requests、DB ドライバー) ThreadPoolExecutor(max_workers=N) 100件で0.113秒、asyncio とほぼ同じ。既定はここでは12本
数千件の同時接続、ライブラリもすべて非同期 asyncio 10,000件で0.196秒・39MB、スレッドは0.703秒・90MB
非同期コードの中に一つだけブロッキング呼び出し asyncio.to_thread(3.9+) ループの空白が510ms→30.7ms
スレッド間で共有する可変状態 Lock、またはスレッドごとの結果を最後に合算 ロックなしで35〜63%の更新が消えた
まとめて失敗させたい複数のタスク 3.11+ は TaskGroup、それ以前は gather+手動キャンセル 0.20秒で兄弟タスクがキャンセルされた

正直な限界とバージョンの注記

  • 時間の計測は Python 3.9.6 だけです。 Python 3.11.4 は TaskGroup/except* と競合状態のデモを動かすのに使いましたが、Rosetta 2 上の x86_64 ビルドなので、その速度はネイティブの 3.11 について何も語りません。
  • フリースレッド版 Python(3.13+)は説明のみで、実行していません。 PEP 703 で GIL のないビルド(python3.13t)がオプションとして加わり、3.13 では実験的な扱いでした。実行時に sys._is_gil_enabled() でどちらのモードかを確認できます。このビルドなら上の CPU 処理のスレッドの表は大きく変わるはずで、race.py の競合は切り替え間隔に隠れなくなる分、むしろ深刻になります。今回の環境にはそのビルドがなく、インストールもしていません。
  • asyncio.to_thread は 3.9 から、TaskGroup と except* は 3.11 からです(3.9 で上の SyntaxError と AttributeError を確認)。
  • Linux の既定が forkserver になるのは Python 3.14 からで、3.14 は動かしていません。
  • 環境:Apple M1 Pro、macOS、他の処理も動作中。5回の中央値です。絶対値は環境で変わります。見てほしいのは比率です。

前回: 第4回 デコレーターとコンテキストマネージャー ・ 次回: 第6回 プロファイリングと最適化

関連:イテレーター・ジェネレーターとメモリ(第3回) ・ Java の仮想スレッドを実測 ・ VB.NET の Async/Await

よくある質問

CPU を使う処理は Python のスレッドで速くなりますか?

通常の(GIL のある)ビルドでは速くなりません。純粋 Python のジョブ8個が逐次で1.70秒、スレッド1・2・4・8本では1.73〜1.85秒でした(Python 3.9、Apple M1 Pro)。同じジョブをプロセスプールで回すと2ワーカーで0.92秒、8ワーカーで0.47秒でした。

Mac で multiprocessing が「An attempt has been made to start a new process before the current process has finished its bootstrapping phase」で落ちます。

macOS では子プロセスを spawn 方式で起動し、子がメインモジュールをもう一度 import します。if __name__ == '__main__': のガードがないと、子もプールを作ろうとして RuntimeError になり、親には BrokenProcessPool が返ります。プールを作る処理をガードの中へ移してください。

asyncio のサーバーが一つの処理で全体ごと固まるのはなぜですか?

コルーチンの中で time.sleep や requests.get のようなブロッキング呼び出しをすると、イベントループ全体が止まります。0.5秒の time.sleep で、10msごとに動きたいタスクに510msの空白ができました。await できる版を使うか asyncio.to_thread でスレッドへ逃がします。

  • #Python
  • #GIL
  • #threading
  • #multiprocessing
  • #asyncio
  • #並行処理
  • #上級

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑