この記事は、GROOVE Xアドベントカレンダー2025 の23日目の記事です。
こんにちは、技術組織デザインチームのふくだです。 Pythonで非同期を利用すると、CPUバウンドな処理どうしようという問題によくぶつかります(LOVOTの意思決定エンジンではPythonの非同期を利用しています。 ブログの過去記事 をご参考ください)
この記事では、最近のPythonで追加されたFreeThreading(NoGIL)な PythonやInterpreterPoolExecutorをPython非同期と合わせて使ってみて、CPUバウンドな処理にどれくらい効果があるか確認してみました。

はじめに I/O バウンド と CPU バウンドって
I/O バウンドとCPUバウンドについて、簡単に説明します。処理時間がかかるものたちです。
| 処理 | 説明 | 得意なモジュール |
|---|---|---|
| I/O バウンドな処理 | ネットワーク通信やDBアクセスなど時間がかかる処理 | マルチスレッド, 非同期(asyncioやtrio) |
| CPU バウンドな処理 | 計算やデータ処理など、CPUの処理能力によって時間がかかる処理 | マルチプロセス |
特にCPUバウンドな処理は、非同期プログラミングやマルチスレッドでは効率的に扱うことが難しい場合があります。 PythonにはGIL(Global Interpreter Lock)が存在するため、スレッドでのCPUバウンドな処理の並列化が制限されることがあります。
それらの制限を解決するため、 Free Threading や SubInterpreter が実装され、Pythonの並列処理能力を向上させる試みが進められています。
Free ThreadingやSubInterpreter
それぞれ一言で言うと以下のような機能です。
- Free Threading: PythonのGILを無効化し、スレッドでのCPUバウンドな処理の並列化を可能にするカスタムビルドのPython
- SubInterpreter:PythonのGILの制約を回避し、CPUバウンドな処理を効率的に並列化するための新しいアプローチ
詳細については、以下を参照してください。
Free Threading
SubInterpreter
- Python3.12で新たにサポートされたsub-interpretersの紹介 | gihyo.jp
- Python 3.12の新機能(その5) PEP 684: インタープリター別GIL: Python3.12の新機能 - python.jp
Python 3.12で追加された SubInterpreter は、各スレッドが独立したインタープリターを持つことで、GILの制約を回避し、スレッドでのCPUバウンドな処理の並列化を可能にする機能です。ですが、利用方法が限られていました。
それを解決したのが、Python 3.14で追加された concurrent.futures.interpreter モジュールと高レベルAPIである InterpreterPoolExecutor です。
下記のような流れ
- Python 3.12: SubInterpreter 追加
- Python 3.13: Free Threading 実験的なオプション追加
- Python 3.14: InterpreterPoolExecutor 追加, Free Threading オプション公式サポート
どちらも並列化を可能にする機能強化で、 SubInterpreter は2017年ごろから PEP 554 にて検討されていました。アプローチの異なる2種類の手法で課題への対応が進められたようです。
InterpreterPoolExecutorとは
InterpreterPoolExecutor は Python 3.14 で追加された標準ライブラリの Executor で、 ThreadPoolExecutor の サブクラス です。
各ワーカースレッドは 独立した SubInterpreter を持ち、その中でタスクを実行します。
- 各スレッドは 独立したサブインタプリタ 上で動く
サブインタプリタごとに GIL を持つ
- PEP 734 – Multiple Interpreters in the Stdlib | peps.python.org
その結果、以下のような特徴があります。
- プロセスを増やさない
- GIL の制約を回避できる
- 真のマルチスレッド並列実行が可能
ThreadPoolExecutor や ProcessPoolExecutor との違い
特徴から ThreadPoolExecutor や ProcessPoolExecutor と比較すると以下のようになります。
- ThreadPoolExecutor より並列が可能
- ProcessPoolExecutor より軽い
それぞれの実行イメージは以下のような感じです。
OS
└─ python script.py ← 親プロセス
└─ Main Interpreter(GIL A)
├─ ThreadPoolExecutor
│ └─ 同一プロセス内スレッド
│
├─ InterpreterPoolExecutor
│ └─ 同一プロセス内スレッド(Pool内で流用される)
│ ├─ Sub-interpreter #1(GIL B)
│ ├─ Sub-interpreter #2(GIL C)
│ └─ ...
└─ ProcessPoolExecutor
└─ OSが新しいプロセスを作る
├─ python child #1 → Interpreter(GIL)
├─ python child #2 → Interpreter(GIL)
└─ ...
モチベーション
asyncio の公式ドキュメントに、以下のような注釈が追記されていました。
注釈 Due to the GIL, asyncio.to_thread() can typically only be used to make IO-bound functions non-blocking. However, for extension modules that release the GIL or alternative Python implementations that don't have one, asyncio.to_thread() can also be used for CPU-bound functions.
意訳すると以下のようになります。
GIL(グローバルインタープリタロック)の制限により、asyncio.to_thread() は通常、I/O バウンドな関数をノンブロッキングにするためにしか使えません。しかし、GIL を解放する拡張モジュールや、GIL が存在しない代替の Python 実装では、asyncio.to_thread() は CPU バウンドな関数にも使用できます。
「なるほど!?!?」となり、実際に試してみよう、というのが本記事のモチベーションです。
asyncio.to_thread() は内部的に thread 1 を利用しています。
モチベーションは asyncio.to_thread() の公式ドキュメント由来ですが、検証用のコードは asyncio.to_thread() ではなく ThreadPoolExecutor を利用します。
InterpreterPoolExecutor ProcessPoolExecutor ThreadPoolExecutor はほぼ同じコードで切り替えが可能なためです。
検証してみた
動作環境
今回の検証環境は次のとおりです。ローカル環境での素朴なベンチマークなので、数値そのものより傾向を見ることを目的にしています。
- M2 Mac 8コア 24GB RAM
- Python 3.14.2
- Free Threading 版 CPython 3.14.2
- どちらも uv で python-build-standalone 版を使用
結果
CPU バウンドな処理を10回実行したときの平均時間は次のとおりです。ローカルでの素朴なベンチマークですが、 InterpreterPoolExecutor も Free Threading もすごいですね!
| 方法 | 平均時間 (秒) |
|---|---|
| ProcessPoolExecutor | 1.326s |
| InterpreterPoolExecutor | 1.049s |
| ThreadPoolExecutor (標準 Python) | 4.565s |
| ThreadPoolExecutor (Free Threading Python) | 1.227s |
実際の検証コード
今回検証で利用したコードは以下のとおりです。
import asyncio import time from concurrent.futures import ( ProcessPoolExecutor, InterpreterPoolExecutor, ThreadPoolExecutor, ) def cpu_bound(n: int) -> int: """n 回ループして単純な計算を行う CPU バウンドな関数""" acc = 0 for i in range(n): acc += i * i acc %= 1_000_000_007 return acc async def run(executor): """指定された executor で CPU バウンドな処理を並列実行して時間を計測する""" loop = asyncio.get_running_loop() t0 = time.perf_counter() with executor() as ex: tasks = [loop.run_in_executor(ex, cpu_bound, 10_000_000) for _ in range(10)] await asyncio.gather(*tasks) dt = time.perf_counter() - t0 print(f"{executor.__name__}: {dt:.3f}s") async def main(mode: str): match mode: case "process": await run(ProcessPoolExecutor) case "interpreter": await run(InterpreterPoolExecutor) case "thread": await run(ThreadPoolExecutor) case _: raise ValueError("mode must be one of: process | interpreter | thread") if __name__ == "__main__": import sys asyncio.run(main(sys.argv[1]))
以下のように実行しました。
$ uv run --python 3.14 sample.py process $ uv run --python 3.14 sample.py interpreter $ uv run --python 3.14 sample.py thread $ uv run --python 3.14t sample.py thread. # 3.14t がFreeThreading版です
おまけ:並列化しないループの場合
元々、Python 3.13で実験的にFreeThreadingが実装された際に、シングルスレッドでの性能低下が課題として言及されていました。 前述の結果のうち、FreeThreadingは遜色ないくらいの性能で驚きました。
試しに、並列化せずにCPUバウンドな関数をPython 3.14とPython 3.14 FreeThreading版で実行してみました。
def cpu_bound(n: int) -> int:
"""n 回ループして単純な計算を行う CPU バウンドな関数"""
acc = 0
for i in range(n):
acc += i * i
acc %= 1_000_000_007
return acc
def main():
t0 = time.perf_counter()
for _ in range(10):
cpu_bound(10_000_000)
dt = time.perf_counter() - t0
print(f"sync (standard): {dt:.3f}s")
結果は以下のとおりです。Python 3.13 の課題は、Python 3.14 で改善されているようでした。 2
| 方法 | 平均時間 (秒) |
|---|---|
| 標準 Python | 4.717s |
| FreeThreading | 5.344s |
考察
結果をまとめると、次のような印象です! Free Threading も InterpreterPoolExecutor もプロダクションで利用するには、気にしなければならない点がいくつかありますが、CPUバウンドな処理に対して有効な選択肢が増えたことを実感できました。
処理の性質と実行環境を理解して、適切な方法を選ぶのが大事そうですね。
- InterpreterPoolExecutor 良さそう
- ProcessPoolExecutor よりも良い結果
- プロセス生成コストを避けたい場面で有効
- ThreadPoolExecutor(標準 Python) はGILの影響がやはり厳しい
- ThreadPoolExecutor(Free Threading Python) も 良さそう
Python の課題に対して、さまざまな角度からのアプローチが進んでいます。コア開発者やコミュニティのみなさまにとても感謝です。今後も Python の進化に注目していきたいと思います。 CPU使用率もだいぶ気になるところですが、それはまた別の機会にまとめたいと思います。
GROOVE Xでは、一緒に働く仲間を募集しています。少しでも興味を持ってくださった方がいましたら、下記のリンクをご参照ください。
-
asyncio.to_thread()はイベントループのdefaultのexecutorであるThreadPoolExecutorを利用しています。 cpython/Lib/asyncio/threads.py at main · python/cpython↩ - Python 3.13 の FreeThreadingについてまとめた記事があります。ご興味ある方はご参考ください PythonのGILと3.13の実験的な新機能「free threading」を知る | gihyo.jp↩