2025, Nov 18 06:02

Почему встроенный Python зависает в графе задач C++ и как это исправить

Разбираем зависания встроенного Python в C++ Taskflow: как работает GIL, почему блокируются потоки и что делать. Пошаговое исправление, код примеров и советы.

Python, встроенный в граф задач C++: почему он зависает и как это исправить

Запуск встроенного Python внутри конкурентного графа задач на C++ выглядит очевидным — пока внезапно всё не замирает. На Windows 10 с MSVC и Python 3.13.5 интерпретатор успешно инициализируется, но задача, которая обращается к Python, никогда не возвращается. Процесс перестаёт реагировать, и даже Ctrl+C не помогает. Причина тонкая, но вполне конкретная: то, как обрабатывается GIL между потоками.

Сценарий проблемы

Следующая программа инициализирует Python, добавляет несколько путей и планирует несколько задач через Taskflow. Одна из задач захватывает GIL, чтобы выполнить работу, связанную с Python. Когда запускается эта задача, приложение зависает.

#include <iostream>
#include <taskflow/taskflow.hpp>
#define PY_SSIZE_T_CLEAN
#include <Python.h>
int main(int argc, char* argv[]) {
    wchar_t venvRoot[] = L".venv";
    Py_SetPythonHome(venvRoot);
    Py_Initialize();
    PyEval_InitThreads();
    if (!Py_IsInitialized()) {
        std::cerr << "Python failed to initialize\n";
        return 1;
    }
    PyRun_SimpleString(
        "import sys\n"
        "sys.path.insert(0, '.venv/Lib')\n"
        "sys.path.insert(0, '.venv/Lib/site-packages')\n"
    );
    PyRun_SimpleString(
        "from time import time, ctime\n"
        "print('Today is', ctime(time()))\n"
    );
    PyObject* py_main_mod = PyImport_AddModule("__main__");
    PyObject* globals_dict = PyModule_GetDict(py_main_mod);
    tf::Executor runPool;
    tf::Taskflow graph;
    auto [T1, T2, T3, T4] = graph.emplace(
        [] () { std::cout << "TaskA\n"; PyGILState_STATE s = PyGILState_Ensure(); PyGILState_Release(s); },
        [] () { std::cout << "TaskB\n"; },
        [] () { std::cout << "TaskC\n"; },
        [] () { std::cout << "TaskD\n"; }
    );
    T1.precede(T2, T3);
    T4.succeed(T2, T3);
    runPool.run(graph).wait();
    if (Py_FinalizeEx() < 0) {
        return 120;
    }
    return 0;
}

Что на самом деле идёт не так

Встроенный интерпретатор стартует с тем, что главный поток владеет GIL. Когда рабочий поток позже вызывает PyGILState_Ensure, он должен получить GIL, чтобы продолжить. Если главный поток так и не освободил своё исходное владение, рабочий поток не сможет его захватить — и программа остановится. Каждый раз, когда вы захватываете GIL, его нужно отпускать, а после инициализации необходимо сбросить исходное состояние GIL, чтобы фоновые потоки могли работать.

Иными словами, после инициализации Python главный поток должен явно отпустить исходный GIL с помощью PyEval_SaveThread. Затем любой поток, которому нужно выполнить код Python, временно захватывает GIL через PyGILState_Ensure и освобождает его через PyGILState_Release. Перед завершением работы интерпретатора главный поток должен восстановить состояние своего потока с PyEval_RestoreThread.

Устраняем зависание

Исправление минимально: освободить исходный GIL после инициализации, захватывать и отпускать его вокруг участков с Python в задачах и восстановить перед финализацией. PyEval_InitThreads с версии Python 3.9 имеет пустое тело и на эту логику не влияет.

#include <iostream>
#include <taskflow/taskflow.hpp>
#define PY_SSIZE_T_CLEAN
#include <Python.h>
int main(int argc, char* argv[]) {
    wchar_t venvRoot[] = L".venv";
    Py_SetPythonHome(venvRoot);
    Py_Initialize();
    PyEval_InitThreads();
    if (!Py_IsInitialized()) {
        std::cerr << "Python failed to initialize\n";
        return 1;
    }
    PyRun_SimpleString(
        "import sys\n"
        "sys.path.insert(0, '.venv/Lib')\n"
        "sys.path.insert(0, '.venv/Lib/site-packages')\n"
    );
    PyRun_SimpleString(
        "from time import time, ctime\n"
        "print('Today is', ctime(time()))\n"
    );
    PyObject* py_main_mod = PyImport_AddModule("__main__");
    PyObject* globals_dict = PyModule_GetDict(py_main_mod);
    PyThreadState* main_state = PyEval_SaveThread();
    tf::Executor runPool;
    tf::Taskflow graph;
    auto [T1, T2, T3, T4] = graph.emplace(
        [] () { std::cout << "TaskA\n"; PyGILState_STATE g = PyGILState_Ensure(); PyGILState_Release(g); },
        [] () { std::cout << "TaskB\n"; },
        [] () { std::cout << "TaskC\n"; },
        [] () { std::cout << "TaskD\n"; }
    );
    T1.precede(T2, T3);
    T4.succeed(T2, T3);
    runPool.run(graph).wait();
    PyEval_RestoreThread(main_state);
    if (Py_FinalizeEx() < 0) {
        return 120;
    }
    return 0;
}

Есть и вариант с областью видимости для участков, где нужно временно отпустить GIL в том же потоке, — Py_BEGIN_ALLOW_THREADS, который под капотом сводится к PyEval_SaveThread и затем повторно захватывает его в конце блока.

Варианты, которые можно рассмотреть

Несколько интерпретаторов в одном процессе доступны через API субинтерпретаторов. По духу это похоже на модуль multiprocessing, но остаётся в одном процессе; данные, передаваемые между интерпретаторами, нужно сериализовать и десериализовать.

Python 3.13 предлагает сборку без GIL. Она убирает поведение мьютекса, хотя захват по-прежнему нужен для настройки состояния, привязанного к потоку. Такой подход работает только с совместимыми библиотеками; если вы не контролируете зависимости, захватывайте и освобождайте GIL в каждой задаче.

Почему этот нюанс важен

Управление GIL определяет, будет ли ваш встроенный рантайм сотрудничать с планировщиком C++. Если главный поток удерживает GIL, фоновые задачи блокируются, и ваш экзекьютор кажется застрявшим. Явное владение GIL предотвращает взаимоблокировки, делает выполнение задач предсказуемым и помогает понимать, где Python можно безопасно запускать. Если поведение непонятно, упростите пример до минимально воспроизводимого — с двумя потоками и вызовами встраивания — так отладка станет проще.

Итоги

Инициализируйте Python, затем сразу отпустите исходный GIL через PyEval_SaveThread. В каждом рабочем потоке, который взаимодействует с Python, оборачивайте участки с Python в пары PyGILState_Ensure и PyGILState_Release. Перед завершением восстановите состояние потока с PyEval_RestoreThread и вызовите Py_FinalizeEx. Если нужна большая изоляция, рассмотрите субинтерпретаторы. Если на Python 3.13 вы можете перейти на совместимую стековую конфигурацию, сборка без GIL снимет узкое место мьютекса; когда это невозможно, дисциплинированный захват и освобождение вокруг каждой задачи — надёжный путь.