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 снимет узкое место мьютекса; когда это невозможно, дисциплинированный захват и освобождение вокруг каждой задачи — надёжный путь.