概要
Pythonの並列処理には threading / multiprocessing / asyncio の3つの選択肢があり、「CPUバウンドならmultiprocessing、I/Oバウンドならthreadingかasyncio」とよく言われます。
ただ、Python 3.13でGILを無効化できる free-threadingビルド が実験的に登場し、3.14で正式サポートになりました。GILがなくなると、この定番の使い分けはどう変わるのでしょうか。
この記事では、同じ処理を4つの方式(逐次・threading・multiprocessing・asyncio)で実行し、通常版Pythonとfree-threading版Pythonで計測しました。
本題
検証環境
| 項目 | 内容 |
|---|---|
| マシン | MacBook Air M1, 2020 |
| OS | Mac OS Sequoia 15.7.3 |
| 通常版 | Python 3.14 |
| free-threading版 | Python 3.14t |
free-threading版Pythonの入れ方
uv を使うと、通常版とfree-threading版を簡単に並べて入れられます。バージョン名の末尾に t を付けたものがfree-threading版です。
uv python install 3.14 3.14t
uv run --python 3.14 bench.py
uv run --python 3.14t bench.py
GILが本当に無効になっているかは、次のコードで確認できます。
import sys, sysconfig
print(sysconfig.get_config_var("Py_GIL_DISABLED")) # free-threadingビルドなら1
print(sys._is_gil_enabled()) # 実行中にGILが有効ならTrue
free-threadingビルドでも、GILに対応していないC拡張モジュールを読み込むと、実行時にGILが有効に戻ることがあります。計測スクリプトの冒頭でGILの状態を表示しているのはそのためです。
計測方法
2種類の処理
- CPUバウンド: 0から300万までの二乗和を計算する(純粋なPythonのループ)
- I/Oバウンド: 0.5秒スリープする(ネットワーク待ちなどの代わり)
これを 8タスク 実行し、全部終わるまでの時間を計ります。
4つの方式
| 方式 | 実装 |
|---|---|
| 逐次 | forループで1つずつ |
| threading | ThreadPoolExecutor(max_workers=8) |
| multiprocessing | ProcessPoolExecutor(max_workers=8) |
| asyncio |
asyncio.gather で8個のコルーチンを同時実行 |
asyncioのCPUバウンド版は、あえて await を挟まずに計算させています。asyncioはシングルスレッドで動くため、こう書くと「並行に見えて実は逐次」になることを確認する狙いです。
計測スクリプト
import asyncio
import os
import statistics
import sys
import sysconfig
import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
N_TASKS = 8 # 同時に実行するタスク数
CPU_N = 3_000_000 # CPUバウンド処理のループ回数
IO_SEC = 0.5 # I/Oバウンド処理の待ち時間(秒)
REPEAT = 3 # 計測の繰り返し回数(中央値を採用)
# ---- 計測対象の処理 ----
def cpu_task() -> int:
total = 0
for i in range(CPU_N):
total += i * i
return total
def io_task() -> float:
time.sleep(IO_SEC)
return IO_SEC
async def io_task_async() -> float:
await asyncio.sleep(IO_SEC)
return IO_SEC
async def cpu_task_async() -> int:
# awaitを挟まないので、イベントループを占有したまま計算する
return cpu_task()
# ---- 実行方式 ----
def run_sequential(func):
return [func() for _ in range(N_TASKS)]
def run_threads(func):
with ThreadPoolExecutor(max_workers=N_TASKS) as ex:
return [f.result() for f in [ex.submit(func) for _ in range(N_TASKS)]]
def run_processes(func):
with ProcessPoolExecutor(max_workers=N_TASKS) as ex:
return [f.result() for f in [ex.submit(func) for _ in range(N_TASKS)]]
def run_asyncio(coro_func):
async def main():
return await asyncio.gather(*(coro_func() for _ in range(N_TASKS)))
return asyncio.run(main())
def measure(label, runner, target):
times = []
for _ in range(REPEAT):
start = time.perf_counter()
runner(target)
times.append(time.perf_counter() - start)
median = statistics.median(times)
print(f"{label:<28} {median:8.3f} s")
return median
def main():
gil_enabled = getattr(sys, "_is_gil_enabled", lambda: True)()
free_threaded_build = bool(sysconfig.get_config_var("Py_GIL_DISABLED"))
print(f"Python : {sys.version.split()[0]}")
print(f"free-thread : build={free_threaded_build}, GIL enabled={gil_enabled}")
print(f"CPU cores : {os.cpu_count()}")
print(f"tasks={N_TASKS}, cpu_n={CPU_N}, io_sec={IO_SEC}, repeat={REPEAT}")
print("-" * 40)
print("[CPU-bound]")
measure("sequential", run_sequential, cpu_task)
measure("threading", run_threads, cpu_task)
measure("multiprocessing", run_processes, cpu_task)
measure("asyncio", run_asyncio, cpu_task_async)
print("[I/O-bound]")
measure("sequential", run_sequential, io_task)
measure("threading", run_threads, io_task)
measure("multiprocessing", run_processes, io_task)
measure("asyncio", run_asyncio, io_task_async)
if __name__ == "__main__":
main()
ProcessPoolExecutor に渡す関数はプロセス間で受け渡すためにpickle化されるので、lambdaではなくモジュールレベルの関数を渡しています。
結果
3.14(GILあり)
Python : 3.14.4
free-thread : build=False, GIL enabled=True
CPU cores : 8
tasks=8, cpu_n=3000000, io_sec=0.5, repeat=3
----------------------------------------
[CPU-bound]
sequential 1.151 s
threading 1.095 s
multiprocessing 0.397 s
asyncio 1.148 s
[I/O-bound]
sequential 4.029 s
threading 0.506 s
multiprocessing 0.689 s
asyncio 0.503 s
3.14(GILなし)
Python : 3.14.4
free-thread : build=True, GIL enabled=False
CPU cores : 8
tasks=8, cpu_n=3000000, io_sec=0.5, repeat=3
----------------------------------------
[CPU-bound]
sequential 0.834 s
threading 0.254 s
multiprocessing 0.334 s
asyncio 0.847 s
[I/O-bound]
sequential 4.028 s
threading 0.508 s
multiprocessing 0.685 s
asyncio 0.503 s
考察
- GILなしでは、CPUバウンドの最速がthreadingに入れ替わった
- GILあり: threadingは逐次の1.05倍とほぼ効果がなく、速くなったのはmultiprocessing(2.90倍)だけでした。「CPUバウンドならmultiprocessing」という定番どおりです
- GILなし: threadingが逐次の3.28倍になり、multiprocessing(0.334秒)より 約24%速い 0.254秒で最速になりました
- 想定外: free-threading版は逐次実行でも約28%速かった
- GILを外すために内部の仕組みが変わっているため、free-threading版は1スレッドでの性能が通常版より落ちるのが一般的です。ところが今回は、逐次実行が1.151秒 → 0.834秒と 約28%速く なりました。再計測しても同じ傾向でした。
- 別の環境では逆の結果(free-threading版のほうが遅い)になる可能性があります。「free-threading版はシングルスレッドでも速い」と一般化はできないので、自分の環境で試すことをおすすめします。
- I/Oバウンドは、GILの有無で差がない
- I/Oバウンドの結果は、GILの有無でほとんど同じでした。I/Oの待ち時間(今回は time.sleep)の間は、通常版でもGILが解放されるためです。
- asyncioはCPUバウンドを速くしない
- asyncioのCPUバウンドは、GILの有無にかかわらず逐次とほぼ同じ時間でした(1.148秒 / 0.847秒)。asyncioは1つのスレッドで、待ち時間に別の処理を進める仕組みです。await で待つことがない計算処理では、1つずつ順番に実行されるだけなので速くなりません。
おわりに
久しぶりに考察するような形の記事となり少々疲労感がすごいですが、普段考えていないところまで考える練習となり良い機会となりました。
今後も同じようにたまには考える記事を作成していけるようにしたいと思います。