Inside of LOVOT

GROOVE X 技術ブログ

Python非同期でCPUバウンドな処理を試してみよう:FreeThreadingとInterpreterPoolExecutor編

この記事は、GROOVE Xアドベントカレンダー2025 の23日目の記事です。

こんにちは、技術組織デザインチームのふくだです。 Pythonで非同期を利用すると、CPUバウンドな処理どうしようという問題によくぶつかります(LOVOTの意思決定エンジンではPythonの非同期を利用しています。 ブログの過去記事 をご参考ください)

この記事では、最近のPythonで追加されたFreeThreading(NoGIL)な PythonやInterpreterPoolExecutorをPython非同期と合わせて使ってみて、CPUバウンドな処理にどれくらい効果があるか確認してみました。

21日目の記事のアイキャッチが素敵だったのでforkしてしまいました

はじめに I/O バウンド と CPU バウンドって

I/O バウンドとCPUバウンドについて、簡単に説明します。処理時間がかかるものたちです。

処理 説明 得意なモジュール
I/O バウンドな処理 ネットワーク通信やDBアクセスなど時間がかかる処理 マルチスレッド, 非同期(asyncioやtrio)
CPU バウンドな処理 計算やデータ処理など、CPUの処理能力によって時間がかかる処理 マルチプロセス

特にCPUバウンドな処理は、非同期プログラミングやマルチスレッドでは効率的に扱うことが難しい場合があります。 PythonにはGIL(Global Interpreter Lock)が存在するため、スレッドでのCPUバウンドな処理の並列化が制限されることがあります。

それらの制限を解決するため、 Free ThreadingSubInterpreter が実装され、Pythonの並列処理能力を向上させる試みが進められています。

Free ThreadingやSubInterpreter

それぞれ一言で言うと以下のような機能です。

  • Free Threading: PythonのGILを無効化し、スレッドでのCPUバウンドな処理の並列化を可能にするカスタムビルドのPython
  • SubInterpreter:PythonのGILの制約を回避し、CPUバウンドな処理を効率的に並列化するための新しいアプローチ

詳細については、以下を参照してください。

Free Threading

SubInterpreter

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 の制約を回避できる
  • 真のマルチスレッド並列実行が可能

ThreadPoolExecutorProcessPoolExecutor との違い

特徴から ThreadPoolExecutorProcessPoolExecutor と比較すると以下のようになります。

  • 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

結果

CPU バウンドな処理を10回実行したときの平均時間は次のとおりです。ローカルでの素朴なベンチマークですが、 InterpreterPoolExecutorFree 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 ThreadingInterpreterPoolExecutor もプロダクションで利用するには、気にしなければならない点がいくつかありますが、CPUバウンドな処理に対して有効な選択肢が増えたことを実感できました。 処理の性質と実行環境を理解して、適切な方法を選ぶのが大事そうですね。

  • InterpreterPoolExecutor 良さそう
    • ProcessPoolExecutor よりも良い結果
    • プロセス生成コストを避けたい場面で有効
  • ThreadPoolExecutor(標準 Python) はGILの影響がやはり厳しい
  • ThreadPoolExecutor(Free Threading Python) も 良さそう

Python の課題に対して、さまざまな角度からのアプローチが進んでいます。コア開発者やコミュニティのみなさまにとても感謝です。今後も Python の進化に注目していきたいと思います。 CPU使用率もだいぶ気になるところですが、それはまた別の機会にまとめたいと思います。

GROOVE Xでは、一緒に働く仲間を募集しています。少しでも興味を持ってくださった方がいましたら、下記のリンクをご参照ください。

recruit.jobcan.jp


  1. asyncio.to_thread() はイベントループのdefaultのexecutorであるThreadPoolExecutorを利用しています。 cpython/Lib/asyncio/threads.py at main · python/cpython
  2. Python 3.13 の FreeThreadingについてまとめた記事があります。ご興味ある方はご参考ください PythonのGILと3.13の実験的な新機能「free threading」を知る | gihyo.jp