Pythonの GIL とは何か、パフォーマンスへの影響

1. 概要

JavaやGoからPythonに移行する多くの人は、同じ問題に直面します。8コアのマシンで4つのスレッドを実行しても、プログラムが高速化されず、むしろ遅くなることもあります。犯人はしばしば**GIL (Global Interpreter Lock)**です。

この記事ではGILが何であり、なぜ存在するのかを説明します。GILがCPUバウンドなコードとI/Oバウンドなコードに及ぼす影響を測定できます。最後に、threadingをいつ使うべきか、いつmultiprocessingを使うべきか、そしてPython 3.13+のfree-threadedビルドが何を変えるのかがわかります。

2. GILとは?

GILはCPython(最も一般的なPython実装)内のmutexです。各時点で、1つのプロセス内で、1つのスレッドだけがPythonバイトコードを実行します。

複数のスレッドを作成できます。スレッドは本物のOSスレッドです。しかし、バイトコードを実行するには、スレッドはGILを保持する必要があります。他のスレッドは待機する必要があります。

2.1. CPythonがなぜGILが必要なのか?

主な理由は参照カウントを使用したメモリ管理メカニズムです。各オブジェクトには、それを参照する数のカウンタがあります:

import sys

order = {"id": 1, "total": 250_000}
print(sys.getrefcount(order))

alias = order
print(sys.getrefcount(order))
2
3

sys.getrefcountは参照の数を返します。結果は常に1ユニット大きいです。これは関数呼び出し自体が一時的な参照を作成するためです。alias = orderを割り当てると、カウンタが1増加します。

ロックなしに2つのスレッドがこのカウンタを同時に増減させると、カウンタが不正確になる可能性があります。その結果、メモリリークが発生するか、オブジェクトが使用中に解放されるという最悪の事態になります。グローバルロックを配置することは、すべてのオブジェクトを保護する最も単純な方法です。シングルスレッドのコード実行を高速化し、C extensionを記述しやすくします。

2.2. スレッドがGILを解放するのはいつ?

GILを保持しているスレッドは、2つのケースで解放します:

  1. I/Oを待機するとき:ファイルの読み取り、ネットワーク呼び出し、time.sleepなど。これらの関数はブロックする前にGILを解放します。
  2. 「ターン」が終わるとき:interpreterは定期的に実行中のスレッドにGILを解放するよう要求し、他のスレッドが実行する機会を得られるようにします。

1ターンの期間を見ることができます:

import sys

print(sys.getswitchinterval())
0.005

デフォルトは5msです。sys.setswitchinterval()で変更できますが、ほぼ必要ありません。

3. CPUバウンドなコードへの影響

試しに測定してみましょう。以下の関数は素数を数えます。これは純粋な計算作業です:

import time
from concurrent.futures import ThreadPoolExecutor


def count_primes(limit: int) -> int:
    count = 0
    for n in range(2, limit):
        if all(n % d for d in range(2, int(n ** 0.5) + 1)):
            count += 1
    return count


def run_sequential(jobs: list[int]) -> float:
    start = time.perf_counter()
    for job in jobs:
        count_primes(job)
    return time.perf_counter() - start


def run_threads(jobs: list[int]) -> float:
    start = time.perf_counter()
    with ThreadPoolExecutor(max_workers=4) as pool:
        list(pool.map(count_primes, jobs))
    return time.perf_counter() - start


if __name__ == "__main__":
    jobs = [200_000] * 4
    print(f"シーケンシャル: {run_sequential(jobs):.2f}s")
    print(f"4スレッド    : {run_threads(jobs):.2f}s")

run_sequentialは4つのジョブを順番に実行します。run_threadsは4つのジョブを4つのスレッドに配布します。8コアマシン上での結果(参考用):

シーケンシャル: 2.31s
4スレッド    : 2.40s

4つのスレッドが高速化されません。むしろ若干遅くなります。GILのため、スレッドは1つのコアを代わりばんこに使用します。追加の遅さはGILの競合とコンテキストスイッチングのオーバーヘッドです。

注意:これは最も一般的な誤りです。純粋なPythonの計算コードにスレッドを追加すると、GILを備えたCPythonで実行が高速化されません。

4. I/Oバウンドなコードへの影響

I/Oを主に待機するコードでは、状況が大きく異なります。リモートから支払いサービスを呼び出すのに0.5秒かかるとシミュレートします:

import time
from concurrent.futures import ThreadPoolExecutor


def charge_payment(order_id: int) -> str:
    time.sleep(0.5)  # 支払いゲートウェイへのHTTP呼び出しをシミュレートします
    return f"order-{order_id}: OK"


if __name__ == "__main__":
    order_ids = list(range(10))

    start = time.perf_counter()
    for oid in order_ids:
        charge_payment(oid)
    print(f"シーケンシャル: {time.perf_counter() - start:.2f}s")

    start = time.perf_counter()
    with ThreadPoolExecutor(max_workers=10) as pool:
        list(pool.map(charge_payment, order_ids))
    print(f"10スレッド   : {time.perf_counter() - start:.2f}s")
シーケンシャル: 5.01s
10スレッド   : 0.50s

10倍高速です。time.sleep、ソケット、ファイル読み取りなどはすべてGILを解放します。10個のスレッドが並行して待機します。それらはバイトコードを実行する際にGILが必要なだけです。

結論:Pythonのスレッドはまだ非常にI/Oバウンドなタスクに有用です。APIの呼び出し、データベースクエリ、ファイルのダウンロードなど。大規模なI/Oがある場合、asyncioも適切な選択肢です。

5. multiprocessingでGILを回避

複数のコアを計算に活用したい場合、標準的な方法は複数のプロセスを使用することです。各プロセスは独自のインタプリタとそれ自身のGILを持ちます。

import time
from concurrent.futures import ProcessPoolExecutor


def count_primes(limit: int) -> int:
    count = 0
    for n in range(2, limit):
        if all(n % d for d in range(2, int(n ** 0.5) + 1)):
            count += 1
    return count


if __name__ == "__main__":
    jobs = [200_000] * 4
    start = time.perf_counter()
    with ProcessPoolExecutor(max_workers=4) as pool:
        results = list(pool.map(count_primes, jobs))
    print(results)
    print(f"4プロセス: {time.perf_counter() - start:.2f}s")
[17984, 17984, 17984, 17984]
4プロセス: 0.68s

シーケンシャル実行の2.31秒と比べると、約3.4倍高速化しています。4倍に達していないのはプロセス初期化のオーバーヘッドが原因です。

if __name__ == "__main__": ブロックは必須です。WindowsとmacOSでは、子プロセスはモジュールを再度importすることで作成されます。このブロックがない場合、子プロセスは新しいプールを作成し、プログラムがエラーになります。

注意: multiprocessingには独自のコストがあります。パラメータと結果は、プロセス間で転送するためにpickleを使用してシリアル化される必要があります。数百MBのDataFrameのような大きなオブジェクトをスモールタスクに送信する場合、コピーのコストが獲得した利益を上回る可能性があります。ラムダとネストされた関数も pickle できません。

6. C extensionはGILを解放できます

NumPy、hashlib、zlibなどの多くのC言語で記述されたライブラリは、重い計算を実行するときに自主的にGILを解放します。この場合、スレッドは本当に並行して実行されます:

import hashlib
import os
import time
from concurrent.futures import ThreadPoolExecutor

payloads = [os.urandom(50_000_000) for _ in range(4)]


def digest(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


if __name__ == "__main__":
    start = time.perf_counter()
    for p in payloads:
        digest(p)
    print(f"シーケンシャル: {time.perf_counter() - start:.2f}s")

    start = time.perf_counter()
    with ThreadPoolExecutor(max_workers=4) as pool:
        list(pool.map(digest, payloads))
    print(f"4スレッド    : {time.perf_counter() - start:.2f}s")
シーケンシャル: 0.98s
4スレッド    : 0.27s

hashlibは大容量データ(約2KB以上)をハッシュするときにGILを解放します。そのため4スレッドは4コアで実行されます。実際には、GILはPythonバイトコードのみをブロックし、ネイティブライブラリのマシンコードはブロックしません。

7. GILはあなたのコードをスレッドセーフにしません

多くの人は「GILがあるので、ロックが必要ない」と考えています。これは間違いです。GILはインタプリタの内部状態を保護し、あなたのロジックは保護しません。任意の2つのステートメント間でスレッドが切り替わる可能性があります。

import threading
import time


class BankAccount:
    def __init__(self) -> None:
        self.balance = 0
        self._lock = threading.Lock()

    def deposit_unsafe(self, amount: int) -> None:
        current = self.balance
        time.sleep(0)  # GILを解放し、中に割り込むI/O操作をシミュレートします
        self.balance = current + amount

    def deposit_safe(self, amount: int) -> None:
        with self._lock:
            current = self.balance
            time.sleep(0)
            self.balance = current + amount


def run(method_name: str, threads: int = 10, times: int = 100) -> int:
    account = BankAccount()
    method = getattr(account, method_name)

    def worker() -> None:
        for _ in range(times):
            method(1)

    pool = [threading.Thread(target=worker) for _ in range(threads)]
    for t in pool:
        t.start()
    for t in pool:
        t.join()
    return account.balance


if __name__ == "__main__":
    print("unsafe:", run("deposit_unsafe"))
    print("safe  :", run("deposit_safe"))
unsafe: 112
safe  : 1000

10 × 100 = 1000を期待しています。deposit_unsafeでは、複数のスレッドが古いcurrent値を読み取ってお互いの書き込みを上書きするため、預金の大部分が失われます。time.sleep(0)は問題を確実に露出させるのに役立ちます。実際のコードでは、GILを解放する場所はログ出力やデータベース呼び出しかもしれません。deposit_safeはthreading.Lockを使用して、読み取り-変更-書き込み操作をアトミックにします。

pytestを使用してこれを修正するテストを書きます。インストール:

pip install pytest
# test_bank_account.py
from bank_account import run


def test_safe_deposit_keeps_correct_balance():
    assert run("deposit_safe", threads=8, times=200) == 1600


def test_unsafe_deposit_loses_updates():
    assert run("deposit_unsafe", threads=8, times=200) < 1600
pytest -q test_bank_account.py
..                                                       [100%]
2 passed in 0.41s

2番目のテストは、CPythonがGILを持っていても、競合状態が実在することを証明しています。

8. Python free-threaded:GILのない未来

Python 3.13以降、CPythonは、GILをオフにできるfree-threaded (PEP 703)ビルドを備えています。通常、python3.13tまたはpython3.14tコマンドでこのバージョンを実行します。Python 3.14では、このビルドは正式にサポートされています (PEP 779)が、まだデフォルトではありません。

実行時のGILステータスをチェック:

import sys
import sysconfig

print("Build supports free-threading:", bool(sysconfig.get_config_var("Py_GIL_DISABLED")))
print("GIL is currently enabled:", sys._is_gil_enabled())

free-threadedビルドで実行:

python3.14t check_gil.py
Build supports free-threading: True
GIL is currently enabled: False

このビルドでは、セクション3のcount_primes例がThreadPoolExecutorで複数のコアを実際に利用します。

しかし、切り替える前に考慮すべき点がいくつかあります:

  • シングルスレッドのコードはやや遅い。インタプリタはより多くの洗練された同期メカニズムを使用する必要があります。
  • C extensionは互換性が必要。未対応のextensionをインポートすると、CPythonはGILを自動的に再度有効にし、警告を印刷します。PYTHON_GIL=0で強制できますが、自分のリスクです。
  • 競合状態がより可能性が高い。セクション7の例は、「一度に1つのスレッドのみが実行できる」という保護がなくなるため、より簡単に露出します。

9. どのツールを選ぶか?

タスクの種類 使用すべきもの
I/Oバウンド (HTTP、DB、ファイル) threading、ThreadPoolExecutor、asyncio
純粋なPython CPU-bound ProcessPoolExecutor / multiprocessing
NumPy、hashlib上のCPU-bound スレッドはまだ良い。ライブラリはGILを解放しますから
CPU-bound、メモリ共有が必要、ライブラリが互換 Python free-threadedの検討 (3.13t/3.14t)

**注意:**直感で最適化しないでください。time.perf_counter()またはcProfileで測定してください。「GILで遅い」と思われるプログラムの多くは、実は遅いアルゴリズムか、遅いデータベースクエリのせいです。

10. まとめ

GILはCPythonのグローバルロックです。Pythonバイトコードを実行できるスレッドは一度に1つだけになり、主に参照カウントを保護します。

実際の結果は4つのポイントに要約できます:

  • スレッドは純粋なPythonのCPUバウンドなコードを加速しません。
  • スレッドはI/Oバウンドなタスク、およびGILを解放するネイティブライブラリには非常に有効です。
  • 計算のために複数のコアを活用したい場合、multiprocessingを使用するか、Python 3.13+のfree-threadedビルドを試してください。
  • GILはロックを置き換えません。スレッド間で共有されるデータは、適切に同期される必要があります。
pythonGILconcurrencymultithreadingperformance