Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

PythonのGILとはなんだったのか

サムネイル

本記事はSansan Advent Calendar 2025、23日目の記事です。

こんにちは、技術本部研究開発部の川波です。

2025年10月にPython 3.14の正式版がリリースされ、GILなしのPythonである「Free-threaded Python」が正式サポートされることになりました。

年の瀬ということもあり、このGIL廃止の波を書き納めて1年を締め括ろうと思います。

GILとは

基本概念

GIL(Global Interpreter Lock)とは、複数のスレッドが同時にPythonコードを実行することを防ぐためのものです。

具体的には「1つのPythonプロセス内で、同時に(並列に)Pythonバイトコードを実行できるスレッドは、1つだけ」という制約を持っています。

これにより、たとえ同一プロセス内でマルチスレッドに並列処理しようとしても最初にロックをかけたスレッドが解放されるまで、他のスレッドを実行できないということです。

GILイメージ

GILの利点

このGILによって次のような利点がもたらされました。

  1. シングルスレッド性能の向上:複雑なロック機構を排除し、ロック操作をインタープリタ全体で1回のみにすることでシングルスレッドの実行速度を向上
  2. C拡張ライブラリの開発しやすさ:複雑なスレッドセーフ管理をGILに任せることでC拡張モジュール開発がしやすくなり、データサイエンス領域の言語として覇権を取るに至ったNumPy, Pandas, PyTorchといった有力ライブラリの誕生に貢献
  3. メモリ管理のシンプルさ:GILによってスレッド内でカウントが閉じているため、メモリ管理に使用している参照カウントがスレッド間を考慮する必要がなく、実装を極めてシンプルかつ高速に保つことができた

GILによる弊害

一方で、GILによる弊害もあります。

「1つのPythonプロセス内で、同時に(並列に)Pythonバイトコードを実行できるスレッドは、1つだけ」という制約のため、スレッドAが動いている間、スレッドBは待機状態になります。これがI/O待ち(通信やディスク読み書き)の時は、待ち時間にロックを解放して別のスレッドが動けるため効率的でしたが、計算を続けるCPUバウンドな処理では、スレッド切り替えのオーバーヘッドだけで逆に遅くなることさえありました。

GILに関する動向

上記弊害に加えて次のような背景からPythonのGIL廃止の動きが活発化してきています。

  • ムーアの法則の限界と、CPUの多コア化が進んだ現代において、1コアしか使えない制約が大きくなりすぎた
  • Meta社(Sam Gross氏)による提案(PEP 703)がコミュニティに受け入れられた

実際にGILなしのスレッドフリーなPythonはPython3.13から試験的にサポートされ、Python3.14で正式にサポートされるようになりました。

次のセクションでは実際にGILなしのマルチスレッドでどの程度CPUバウンドな処理が高速化されるのかを確認していきます。

準備

まず、GILあり・なしの場合におけるCPUバウンドな処理を実行・比較するための準備をしていきます。

環境

以下が筆者の環境になります。1~10までの並列数で比較するため、もし手元でお試しになる際はコア数が10以上がおすすめです。

  • 機種:MacBook Pro
  • チップ: Apple M3 Max
  • メモリ:36GB
  • macOS:14.7.4(23H420)
  • 論理コア数:14

uvのインストール

今回はGILあり・なしのバージョン切り替えを容易にするためuvを使用します。手元で試したい場合は次の手順でご準備ください。

  1. ターミナルで以下を実行

     curl -LsSf https://astral.sh/uv/install.sh | sh
    
  2. ターミナルに表示されたパスを通す、または別のターミナルを開く

ベンチマークスクリプト

NumPyやPandasなどのライブラリは内部でGILをリリースしていることが多いので、今回は外部ライブラリを使わない素数を数える関数を用意し比較に用います。

# benchmark.py

import sys
import time
import math
import statistics
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

# ==========================================
# 設定: マシンスペックに合わせて調整してください
# ==========================================
RANGE_START = 100_000
RANGE_END = 1_000_000   # 時間がかかりすぎる場合は減らしてください
WORKERS = 4             # 1~10にワーカー数を変更
N_TRIALS = 10           # 試行回数
# ==========================================

def is_prime(n):
    """Pure Pythonによる素数判定 (計算負荷用)"""
    if n <= 1: return False
    if n <= 3: return True
    if n % 2 == 0 or n % 3 == 0: return False
    i = 5
    while i * i <= n:
        if n % i == 0 or n % (i + 2) == 0:
            return False
        i += 6
    return True

def count_primes(start, end):
    """指定範囲の素数をカウントする"""
    count = 0
    for i in range(start, end):
        if is_prime(i):
            count += 1
    return count

def run_single_benchmark(executor_class, ranges):
    """1回分のベンチマーク実行"""
    start_time = time.time()
    total_primes = 0
    
    if executor_class is None:
        # Sequential
        for r_start, r_end in ranges:
            total_primes += count_primes(r_start, r_end)
    else:
        # Parallel
        with executor_class(max_workers=WORKERS) as executor:
            futures = []
            for r_start, r_end in ranges:
                futures.append(executor.submit(count_primes, r_start, r_end))
            for future in futures:
                total_primes += future.result()

    duration = time.time() - start_time
    return duration, total_primes

def run_experiment(executor_class, name, ranges):
    """指定回数試行して統計を取る"""
    print(f"🚀 [{name}] Starting {N_TRIALS} trials with {WORKERS} workers...")
    times = []
    
    for i in range(N_TRIALS):
        duration, count = run_single_benchmark(executor_class, ranges)
        times.append(duration)
        # 進捗表示(改行なしで上書きするとプロっぽいですが、ログに残すため普通に出力)
        print(f"   Trial {i+1:02d}/{N_TRIALS}: {duration:.4f} sec")

    # 統計計算
    mean_time = statistics.mean(times)
    stdev_time = statistics.stdev(times) if N_TRIALS > 1 else 0.0
    
    print(f"   👉 Average: {mean_time:.4f} s | Stdev: ±{stdev_time:.4f} s")
    print("-" * 60)
    return mean_time, stdev_time

def main():
    # GILの状態確認
    gil_status = "Enabled"
    try:
        if hasattr(sys, "_is_gil_enabled") and not sys._is_gil_enabled():
            gil_status = "DISABLED (Free-Threading Mode!)"
    except AttributeError:
        pass

    print("=" * 60)
    print(f"Python Version : {sys.version.split()[0]}")
    print(f"GIL Status     : {gil_status}")
    print(f"Task           : Count primes {RANGE_START} -> {RANGE_END}")
    print(f"Configuration  : {WORKERS} Workers / {N_TRIALS} Trials")
    print("=" * 60 + "\n")

    # タスク分割
    step = (RANGE_END - RANGE_START) // WORKERS
    ranges = []
    for i in range(WORKERS):
        start = RANGE_START + i * step
        end = RANGE_START + (i + 1) * step if i < WORKERS - 1 else RANGE_END
        ranges.append((start, end))

    # 実験開始
    # 1. Sequential (基準)
    t_seq, _ = run_experiment(None, "Sequential", ranges)

    # 2. Multi-Thread (今回の主役)
    t_thread, sd_thread = run_experiment(ThreadPoolExecutor, "Multi-Thread", ranges)

    # 3. Multi-Process (比較対象)
    t_process, sd_process = run_experiment(ProcessPoolExecutor, "Multi-Process", ranges)

    # 最終サマリー表示
    print("\n" + "="*20 + " FINAL RESULTS " + "="*20)
    print(f"{'Mode':<15} | {'Avg Time':<10} | {'Speedup':<8} | {'Stability(Stdev)'}")
    print("-" * 55)
    
    print(f"{'Sequential':<15} | {t_seq:.4f}s   | {'1.00x':<8} | -")
    
    speedup_th = t_seq / t_thread
    print(f"{'Multi-Thread':<15} | {t_thread:.4f}s   | {speedup_th:.2f}x     | ±{sd_thread:.4f}s")
    
    speedup_pr = t_seq / t_process
    print(f"{'Multi-Process':<15} | {t_process:.4f}s   | {speedup_pr:.2f}x     | ±{sd_process:.4f}s")
    print("=" * 55)

if __name__ == "__main__":
    main()

実行

uvでPythonバージョンを指定した上で次のように実行します。

3.14の部分がPythonのバージョンに相当しています。

uv run --python 3.14 benchmark.py

比較対象

今回はPython3.14を試したいのでuvから次のバージョンを指定します。

  1. 3.14: GILあり
  2. 3.14t: GILなし(フリースレッド)

比較結果

マルチプロセスの比較

本題の前にまずはPythonでCPUバウンドな処理を並列化する際に使われるマルチプロセスの比較結果を確認します。

並列数 3.14 (sec) 3.14t (sec)
1 0.6071 0.6342
2 0.3855 0.3979
3 0.2838 0.3004
4 0.2301 0.246
5 0.1998 0.2113
6 0.1755 0.1893
7 0.1594 0.1773
8 0.1479 0.1618
9 0.1405 0.156
10 0.1357 0.1523

マルチプロセスの比較

並列数に関わらず 3.143.14t に大きな差がないことから、プロセスごとにメモリ空間もインタープリタも独立しているため、GILの有無が影響していないことがわかります。

マルチスレッドの比較

本題のマルチスレッドの比較結果です。

並列数 3.14 (sec) 3.14t (sec)
1 0.5604 0.6037
2 0.5526 0.3493
3 0.5522 0.2458
4 0.5561 0.1965
5 0.5548 0.1666
6 0.5511 0.1608
7 0.5547 0.1489
8 0.546 0.1363
9 0.5677 0.1286
10 0.555 0.1241

マルチスレッドの比較

  • GILあり版では並列数を増やしても横ばい

    3.14 (GILあり) は並列数を増やしてもタイムが約0.55秒で横ばい(むしろスレッド切り替えで微増)であることからGILによる制約が効いていることがわかる。

  • マルチスレッドの性能向上

    CPUバウンドな処理にも関わらず、GILなし版の方が、4並列で約2.8倍、8並列で約4倍高速になっている。

  • シングルスレッドの性能低下

    並列数1の結果を見ると、3.14 (0.56s) に対し 3.14t (0.60s) となり、GILなし版の方が約7~8%遅くなっている。

    これはGILを廃止した代わりに、より粒度の細かいロック機構やメモリ管理によるオーバーヘッドが発生しているためであり、普段使用するシングルスレッドの性能が下がったことから、無意識に使用してきたGILの恩恵がわかる。

シングルスレッドの性能低下は有意な差か❓ 標準偏差付きで再度計測(10回試行)を行ったところ、GILあり: 0.5561s (±0.0048s)、GILなし: 0.5654s (±0.0023s)と、平均値の差(約0.01秒)が標準偏差(約0.002~0.004秒)よりも大きく統計的に有意な差(確実に遅くなっている) ことを確認しました。(※後日計測したためマシンの状況により計測時間が異なっています)

GILとはなんだったのか

検証の結果、フリースレッドモード(GILなし)では、これまでPythonの苦手分野とされていた「CPUバウンドな処理のマルチスレッド化」において、コア数に応じた明確な高速化が確認できました。

「GILとは何だったのか」を振り返ると、シングルコア時代のPythonを支え、NumPyなどのエコシステムをシンプルに発展させるための滑走路のようなものだったと言えるかもしれません。 しかし、マルチコアが当たり前の現代において、GILは廃止の方向で進んでいます。まだシングルスレッド性能の低下やライブラリの対応など課題は残りますが、計算資源をフルに活かせるPythonの時代はすぐそこまで来ていると感じました。

おわりに

今回は「Free-threaded Python」が正式サポートされたことを機にGILについて紹介しました。

Sansan Advent Calendar 2025では、他のメンバーからもさまざまな取り組みが紹介されています。 他の記事もぜひご覧ください。

Sansan技術本部ではカジュアル面談を実施しています

https://cdn.blog.st-hatena.com/files/4207112889963095046/6801883189090195406

Sansan技術本部では中途の方向けにカジュアル面談を実施しています。Sansan技術本部での働き方、仕事の魅力について、現役エンジニアの視点からお話しします。「実際に働く人の話を直接聞きたい」「どんな人が働いているのかを事前に知っておきたい」とお考えの方は、ぜひエントリーをご検討ください。

参考

https://docs.python.org/3/howto/free-threading-python.html

https://peps.python.org/pep-0703/

© Sansan, Inc.