地方エンジニアの学習日記

興味ある技術の雑なメモだったりを書いてくブログ。たまに日記とガジェット紹介。

【Python】スレッドの並列性とGIL

この記事は「渡部 Advent Calendar 2025」の4日目の記事です。


ざっくりいうとCPU の「コア数」を使って、複数のスレッドが同時に物理的に実行されている状態。

  • 「並列(parallel)」=物理的に同時
  • 「並行(concurrent)」=論理的に同時(実際は切り替え)

並列性とは具体的に何を指す?

複数の CPU コアが存在し、それぞれが同時に処理を実行している

たとえば 4 コア CPU だと

Core0: スレッドA を実行
Core1: スレッドB を実行
Core2: スレッドC を実行
Core3: スレッドD を実行

これは 完全な並列実行。4 つのタスクが本当に同時に動いている。

GIL が存在しない言語では、CPU-bound 処理がスレッドで高速化できる

  • C, C++, Go, Java, Rust → どれもスレッドで普通に並列化可能
  • Python でも C拡張が重めの処理(NumPy, OpenCV など)は GIL を release して並列性を獲得できる。

Python は GIL で基本「並列性」がない

Python(CPython)は GIL によ同時に動ける Python bytecode は 1 つだけ。よって Python 純粋コードは「並列」は不可能で「並行」止まり

Thread-1: |----実行----|
Thread-2: |----実行----|

実際は CPU が高速に切り替えているだけ(並行・concurrency)。

GILの実装

GIL は「Python バイトコード実行を 1 スレッドに制限するためのミューテックス」。内部的には以下で構成されています

  • POSIX threads(pthread)や OS ネイティブの mutex
  • 条件変数(Condition Variable)による“GIL の譲渡”メカニズム
  • 「チェック間隔 / タイムスライス」によるスレッド切り替えの仕組み
  • GIL の保持・解放を自動管理するマクロ(PyGILState_Ensure など)

GIL の状態は _gil_runtime_state という構造体で管理されている。

typedef struct {
    PyMutex mutex;            // GIL 本体(排他ロック)
    PyCondVar cond;           // スレッド切り替え用の条件変数
    int locked;               // ロックされているか?
    PyThreadState *owner;     // GIL を保持しているスレッド
    _Py_atomic_address last_holder; // デバッグ用
    int switch_number;        // スレッド切り替え回数
} _gil_runtime_state;

スレッドはどうやって GIL を取っているのか?

Python のスレッド(PyThreadState)はバイトコードを実行する前に 必ず GIL の取得処理を行う。

内部コードでは次のように mutex を lock する。

PyMutex_Lock(&gil.mutex);
gil.locked = 1;
gil.owner = PyThreadState_GET();

これでそのスレッドは Python バイトコードを実行できる。

GILの解放

自動解放メカニズム

In order to emulate concurrency of execution, the interpreter regularly
tries to switch threads (see :func:`sys.setswitchinterval`).  The lock is also
released around potentially blocking I/O operations like reading or writing
a file, so that other Python threads can run in the meantime.

ブロッキングI/O操作時

GILはファイル読み書きなどのブロッキングI/O操作の前後に自動的に解放・再取得されます init.rst:784-786 。これによりI/O待機中に他のスレッドがPythonコードを実行できます。以下はIO操作時のスレッドの解放処理です。

static int
_enter_buffered_busy(buffered *self)
{
    int relax_locking;
    PyLockStatus st;
    if (self->owner == PyThread_get_thread_ident()) {
        PyErr_Format(PyExc_RuntimeError,
                     "reentrant call inside %R", self);
        return 0;
    }
    PyInterpreterState *interp = _PyInterpreterState_GET();
    relax_locking = _Py_IsInterpreterFinalizing(interp);
    Py_BEGIN_ALLOW_THREADS
    if (!relax_locking)
        st = PyThread_acquire_lock(self->lock, 1);
    else {
        /* When finalizing, we don't want a deadlock to happen with daemon
         * threads abruptly shut down while they owned the lock.
         * Therefore, only wait for a grace period (1 s.).
         * Note that non-daemon threads have already exited here, so this
         * shouldn't affect carefully written threaded I/O code.
         */
        st = PyThread_acquire_lock_timed(self->lock, (PY_TIMEOUT_T)1e6, 0);
    }
    Py_END_ALLOW_THREADS
    if (relax_locking && st != PY_LOCK_ACQUIRED) {
        PyObject *ascii = PyObject_ASCII((PyObject*)self);
        _Py_FatalErrorFormat(__func__,
            "could not acquire lock for %s at interpreter "
            "shutdown, possibly due to daemon threads",
            ascii ? PyUnicode_AsUTF8(ascii) : "<ascii(self) failed>");
    }
    return 1;
}

タイムスライスによる強制スイッチ

sys.setswitchinterval()で設定された間隔(デフォルト5ms)で強制的にスレッド切り替えが発生し、GILが解放されます

手動解放メカニズム

C 拡張で GIL を解放する処理

// example.c
#define PY_SSIZE_T_CLEAN
#include <Python.h>
#include <unistd.h>  // sleep()

// Python 側から呼び出される関数: sleep(seconds) するだけ
static PyObject* my_sleep(PyObject* self, PyObject* args)
{
    int seconds;

    if (!PyArg_ParseTuple(args, "i", &seconds)) {
        return NULL;
    }

    // ここから GIL を解放
    Py_BEGIN_ALLOW_THREADS

    // ---- ここは GIL なしで実行される領域 ----
    // ・他スレッドの Python コードと真の並列実行が可能
    // ・ただし Python オブジェクトへのアクセスは禁止
    sleep(seconds);
    // -------------------------------------

    // ここで GIL を再取得
    Py_END_ALLOW_THREADS

    Py_RETURN_NONE;
}

// モジュール定義
static PyMethodDef ExampleMethods[] = {
    {"my_sleep", my_sleep, METH_VARARGS, "Sleep without holding the GIL"},
    {NULL, NULL, 0, NULL}
};

static struct PyModuleDef examplemodule = {
    PyModuleDef_HEAD_INIT,
    "example",
    NULL,
    -1,
    ExampleMethods
};

PyMODINIT_FUNC PyInit_example(void)
{
    return PyModule_Create(&examplemodule);
}

OSのプリエンプションとGILのタイムスライスは異なる抽象レベルで動作する

Linux でも macOS でも Windows でも、スレッドは OS によってプリエンプティブにスケジュールされます。つまりスレッドAが CPU を使っていても、OS は強制的に A を止めて B に切り替えるこれは Python だろうと Java だろうと C だろうと同じでPython は OS のスケジューラを変更することはできないです。

相互作用の詳細

  • 実行フロー
    • OSがPythonスレッドをCPUにスケジュール
    • スレッドがGILを取得してPythonバイトコードを実行
    • OSがスレッドをプリエンプトしても、GILは保持されたまま
    • スレッドが再開されると、引き続きGILを持って実行を継続
    • setswitchintervalの時間が経過すると、GILが解放されて他のPythonスレッドに譲渡

重要な違いとしてはこんな感じ。

まとめ

  • 並列(parallel)=CPU の複数コアで“物理的に同時実行”。
  • 並行(concurrent)=実際は1つを高速に切り替えて“同時に見えるだけ”。

Python(CPython)は GIL により1つのスレッドしか Python バイトコードを実行できないため、基本は「並行」止まりで「並列」はできない。

GIL は OS の mutex+条件変数で実装され、I/O や C拡張では 自動的に解放されるため、その間は並列になる。

OS のプリエンプション(強制切替)とGIL のスレッド切替(協調動作)はまったく別レイヤー。