CPUを使う重い処理を ThreadPoolExecutor で4並列にしても、実行時間はほとんど縮みません。Python のスレッドが GIL を1つ共有するからです。Python 3.14(2025年10月リリース、現行は3.14.6)の concurrent.interpreters は、この前提を崩します。
スレッドを4本にしてもCPU処理が速くならない
8個の素数カウントを ThreadPoolExecutor(max_workers=4) に投げても、直列で回したときと所要時間が変わりません。CPUを100%使う純Pythonの処理では、GILを持っている1スレッドしか前に進めないからです。並列化したつもりで、実体は順番待ちになっています。
GILを1つ共有するとどうなるか
GILはインタプリタごとに1つ。従来のCPythonは1プロセスにインタプリタが1つなので、スレッドを何本立てても同時に実行できるのは常に1本です。ネットワークやファイルのI/O待ちでGILが手放される処理なら効きます。ただ、計算主体の処理ではGILの奪い合いになり、並列になりません。
サブインタプリタが変える前提
PEP 684(Python 3.12)で、インタプリタごとにGILを分離できるようになりました。3.14 の PEP 734 は、その仕組みを標準ライブラリ concurrent.interpreters として公開したものです。1プロセスの中に独立したインタプリタを複数立て、それぞれが自分のGILを持って同時に走る。公式のWhat’s Newでは “PEP 734: Multiple interpreters in the standard library” の節にあたります。
InterpreterPoolExecutorで並列化する
いちばん手軽な入口が concurrent.futures.InterpreterPoolExecutor。ThreadPoolExecutor のサブクラスで、書き方はそのまま、ワーカーごとに独立インタプリタで動きます。
ThreadPoolExecutorからの置き換え
from concurrent.futures import InterpreterPoolExecutor
def count_primes(limit):
total = 0
for n in range(2, limit):
i = 2
while i * i <= n:
if n % i == 0:
break
i += 1
else:
total += 1
return total
with InterpreterPoolExecutor(max_workers=4) as ex:
results = list(ex.map(count_primes, [500_000] * 8))
クラス名を ThreadPoolExecutor から差し替えただけ。submit() と map() のインターフェースは共通です。
直列3.74秒が1.02秒に
同じ処理を4つの方法で回した実測値です(18コア、Python 3.14.6、8タスクを4ワーカー)。
| 実行方法 | 8タスクの合計時間 | 直列比 |
|---|---|---|
| 直列(ベースライン) | 3.74秒 | ×1.00 |
| ThreadPoolExecutor(4) | 3.75秒 | ×1.00 |
| ProcessPoolExecutor(4) | 1.05秒 | ×3.56 |
| InterpreterPoolExecutor(4) | 1.02秒 | ×3.66 |
スレッドは ×1.00、つまり直列と同じ。プロセスとサブインタプリタはどちらも約3.6倍で、GILの制約を越えて4コアぶんに近い並列度が出ています。
max_workers を増やせば無限に速くなるわけではありません。並列度はマシンのコア数と max_workers の小さいほうで頭打ちになります。18コアの環境でも、4ワーカーなら上限は理論上4倍前後。ここで4に揃えたのは、プロセスとサブインタプリタを同じ条件で並べるため。CPUバウンドな処理なら、ワーカー数をコア数付近まで上げるほど効果は伸びます。
引数と戻り値はpickleされる
InterpreterPoolExecutor は、渡した関数と引数を pickle でシリアライズしてワーカーのインタプリタへ送り、戻り値も pickle で返します。制約は ProcessPoolExecutor と同じ。関数と引数は picklable でなければなりません。ラムダやローカル関数は渡せないので、モジュール直下に定義した関数を使います。
concurrent.interpreters の低レベルAPI
プールを介さず、インタプリタを直接触るのが concurrent.interpreters。公式ドキュメントの節構成が、そのまま機能の地図になっています。
create・exec・call
基本は “Running in an Interpreter” の節にある3つ。create() でインタプリタを作り、exec() で文字列のコードを流し込み、call() で関数を呼びます。
from concurrent import interpreters
interp = interpreters.create()
interp.exec("x = 40 + 2")
interp.exec("print('from sub:', x)")
def square(n):
return n * n
print("call:", interp.call(square, 9))
interp.close()
from sub: 42
call: 81
exec() で定義した x は、そのインタプリタの __main__ に残ります。別インタプリタなので、呼び出し側の名前空間とは完全に別。使い終わったら close() で破棄します。生成済みのインタプリタは interpreters.list_all() で一覧でき、いま動いているインタプリタは get_current() で取得。
Queueでインタプリタ間をつなぐ
“Communicating Between Interpreters” の節が受け持つのが create_queue()。作ったキューを prepare_main() で相手の __main__ に束ね、両側から put() と get() します。
q = interpreters.create_queue()
interp = interpreters.create()
interp.prepare_main(q=q)
interp.exec("q.put(sum(range(1000)))")
print("受信:", q.get())
受信: 499500
何が別インタプリタへ渡せるのか
サブインタプリタでいちばん引っかかるのが、この「何を渡せるか」です。公式は複数インタプリタを “threads but with opt-in sharing”(共有はオプトイン)と表現します。既定はコピー、共有したいものだけ明示的に、という設計。ここを外すと実行時に落ちます。
渡るのはコピーで共有ではない
キューに dict を入れて取り出し、受信側で書き換えても、送信側の dict は変わりません。put() と get() がオブジェクトをコピーして渡すからです。
q = interpreters.create_queue()
d = {"n": 1}
q.put(d)
got = q.get()
got["n"] = 999
print(d, got is d)
{'n': 1} False
取り出した got は元の d と別オブジェクトで、got is d は False。スレッドのように同じオブジェクトを共有し、ロックで守るという発想は通用しません。状態は各インタプリタが自分のコピーとして持つ。共有メモリ前提のコードをそのまま持ち込むと、静かに食い違います。この境界はドキュメントの “‘Sharing’ Objects” の節が説明しています。
NotShareableErrorが出る条件
コピーできないオブジェクトを渡すと NotShareableError(TypeError のサブクラス)。OSリソースや実行状態に結びついたものが該当します。
import threading
q = interpreters.create_queue()
q.put(threading.Lock())
concurrent.interpreters.NotShareableError: <unlocked _thread.lock object at 0x...>
does not support cross-interpreter data
| 渡すもの | 挙動 |
|---|---|
| str / int / tuple / list / dict | コピーされて渡る |
| threading.Lock / open(…) / socket / ジェネレータ | NotShareableError |
数値や文字列、それらを詰めた list や dict は問題なく渡ります。引っかかるのはロック、開いたファイル、ソケット、ジェネレータのような「その場に固有の状態」を持つオブジェクト。ワーカーへ渡すものを値だけに寄せておくと、この落とし穴を踏みません。
では “opt-in sharing” の「共有」はどこにあるか。コピーではなく本当にメモリを共有したいときは、バッファプロトコルに対応したオブジェクト、つまり memoryview 経由に限られます。大きな bytes や配列を毎回コピーせず渡したいときの逃げ道が、これ。公式もオブジェクト共有の手段は現状 memoryview どまりだと制約に挙げています。既定はコピー、共有だけ memoryview で明示。この二段構えが設計の分かれ目です。
スレッド・プロセス・サブインタプリタの使い分け
並列化の選択肢は、いま3つあります。狙いが近いのに性質は逆、という組み合わせが混ざります。ざっくりの入口はこう。I/O待ちが主役ならスレッドか asyncio、CPUを回し続けるならプロセス・サブインタプリタ・free-threadingのどれか。後者3つの中では、次のように分かれます。
free-threadingとどちらを選ぶか
CPU処理を速くしたいとき、GILそのものを外す free-threadingビルドと、GILをインタプリタごとに分けるサブインタプリタは、目的は近い一方で発想が逆。free-threading は1つのインタプリタでメモリを共有したまま並列化するので、共有ゆえのデータ競合を自分で守る必要があります。サブインタプリタは共有を断ち、コピーで受け渡す。競合を設計で避けたいならサブインタプリタ、既存の共有前提コードを速くしたいなら free-threading、という分かれ方になります。
まだ避けたほうがいい場面
手元の18コア環境で interpreters.create() と close() は1回あたり3.03ミリ秒、spawn のプロセス起動は18.30ミリ秒。プロセスより約6倍軽い一方、スレッド生成よりは桁違いに重い。短命なタスクを大量にさばく用途では、この起動コストが響きます。
- C拡張の多くが未対応。公式も制約として「多くのPyPI拡張モジュールが未対応(標準ライブラリはすべて対応)」を挙げます。numpy のような拡張を各インタプリタで使う設計は、現状で詰まりやすい
- インタプリタごとにメモリを別に持つため、プロセスほどではないがスレッドより重い
- I/O待ちが主なら素直にスレッドか asyncio。サブインタプリタの出番はCPUバウンドな処理です
裏を返せば、CPUバウンドで標準ライブラリ中心、かつワーカーを使い回せる処理なら、サブインタプリタは multiprocessing より軽く、スレッドより速い。numpy などC拡張に踏み込む前の、純Python処理の並列化から試すのが安全です。
まとめ
ThreadPoolExecutorはCPU処理を速くしない。GILを1スレッドしか握れないためInterpreterPoolExecutorは各スレッドが独立インタプリタで動く。4ワーカーで直列比 ×3.66 と、ProcessPoolExecutorにほぼ並ぶ- 低レベルは
concurrent.interpretersのcreate()/exec()/call()とcreate_queue() - インタプリタ間の受け渡しはコピー。ロックやソケットは
NotShareableErrorで弾かれる - C拡張依存や短命タスク中心の処理では、まだプロセスやスレッドが有利

