Pull to refresh

Comments 50

Согласен, GIL — просто ужас. С ним я бился, нашел еще несколько проблем связанные с ним (например unix сигналы) и пришел к старому доброму fork. Теперь если нужно что-то распаралелить на python — создаю несколько процессов с одним потоком. С одной стороны затратно, с другой — лучшая изоляция, больше безопасности, проблема в одном потоке не влияет на другой.
Это здорово! GIL уже останавливал от использования python в качестве встроенного скриптового движка. А как там насчет накладных расходов? Насколько неэффективным будет создать к примеру 50 интерпретаторов в приложении?
Если подразумевается 50 интерпретаторов для 50 потоков, то это будет конечно медленнее, поскольку 50-ядерных процессоров пока в продаже не так много. Тем не менее заменив сейчас в тестовом проекте THREAD_NUM на 50, всё благополучно завершилось, выполнялось всё параллельно.
Единственное что каждый import в каждом отдельном под-интерпретаторе будет выполнен заново, поэтому нужно стараться не импортировать в каждом потоке по гигабайту или то что импортируется по несколько минут.
Важно! В C++ приложении не будет запущен никакой отдельный процесс python.exe, точно так же никакой запуск под-интерпретатора в потоках не породит никаких процессов питона!
Можно смело использовать Python-модули из C++ кода в полной уверенности, что это будет один-единственный процесс.
Под-интерпретаторы изолированы почти полностью, единственное что взаимодействие с ОС будет разумеется из одного процесса, текущего, это следует учитывать.
Интересует вопрос производительности. Насколько создание таких потоков медленне потоков, созданных в самом Питоне? Сколько памяти уходит на один поток с импортированным, скажем, модулем logging?
Любая скорость питоновых потоков нивелируется 94479365439678430697207586230756403765084760437643087606**4837564307964306743068740387564035764057640538754035764075 где-нибудь в коде треда.
Если используете большие цифры и хотите предсказуемости, то либо форк, либо вот такая техника как тут.
Потоки созданные в самом питоне через модуль threading используют один общий GIL, в один момент времени выполняется строго один из них. С удовольствием могу померять насколько это медленнее параллельного выполнения, если действительно сомневаетесь.
Я понимаю, чем ваш метод принципиально отличается от создания потоков из Питона. Мне интересно, насколько это затратно, создавать отдельный интерпретатор Питона.
Копирование пустого интерпретатора функцией Py_NewInterpreter у меня заняло около 200 мс в среднем, один раз на старте потока. Что разумеется мелочи для инициализации приложения, в сравнении с тем, что для Python версии ниже 3.2 мы получим блокировку вплоть до завершения работы потока, что для длительных операций приводит параллельное исполнение к линейному. В Python 3.2+ разница уже не так заметна, благодаря новой схеме GIL, однако всё равно порядка 2 сек разницы в пользу под-интерпретаторов при условиях: 4 потока, 10 повторов, 0,5 сек. паузы.
200 мс совсем не кисло. У меня старт и завершение полноценного интерпретатора занимает примерно 20 мс.
Хм, попробуйте обогнать моё приложение своим скриптом, при условии что они оба будут делать одинаковую работу.
Разумеется оба должны быть многопоточными и оба должны параллелить некоторый ряд действий.
Уверен, что ни при каких прочих равных модуль threading не сможет сравниться с нативными потоками, где делается аналогичная работа в уже подготовленных для работы интерпретаторах параллельно без общего GIL.
При чем тут threading. Я же про старт полноценного процесса Питона говорю. А форк должен быть еще быстрее.
Ну а смысл? Как я вижу, никакие преимущества многопоточности (та же разделяемая память) тут не удастся применить. Ну разве что накладные расходы немного поменьше.
Почему не даёт? Та же разделяемая память вне питона вполне видна. Многопоточность даёт честное параллельное выполнение. То что под-интерпретаторы Python изолированы друг от друга — скорее плюс чем минус. Учитывая что физически это один процесс можно сделать модуль на C++ который даёт потокобезопасный доступ к общим переменным.
Собственно смысл в параллельном выполнении и есть. Все остальные плюшки единого процесса остаются.
Т.е. профит может проявиться только если работу с общими данными реализовать на стороне C? Если работы с общими данными не требуется, проще процессы запускать же?
Межпроцессное взаимодействие на порядок сложнее чем внутрипроцессное между потоками. Проще запустить, но сложнее согласовать и завершить.
Этот путь был изначально в таком языке как Perl, он же поддерживается

В Perl потоки не имеют никакого преимущества перед fork() (под Linux). Т.к. «общие» данные на самом деле не общие, а синхронизируются между потоками через сокеты итд.
Ну здесь на самом деле если не учитывать слой C++ интерпретаторы между собой никак взаимосвязаны, так что решение аналогичное. Но то что на уровне C++ можно устроить диспетчер обмена данными между потоками, конечно плюс.
синхронизируются между потоками через сокеты итд
really?
Оно так не работает.
GIL один на процесс, а не на субинтерпретатор.
Лежит с ./Python/ceval.c как

static PyThread_type_lock pending_lock = 0; /* for pending calls */
static long main_thread = 0;
/* This single variable consolidates all requests to break out of the fast path
in the eval loop. */
static _Py_atomic_int eval_breaker = {0};
/* Request for dropping the GIL */
static _Py_atomic_int gil_drop_request = {0};
/* Request for running pending calls. */
static _Py_atomic_int pendingcalls_to_do = {0};
/* Request for looking at the `async_exc` field of the current thread state.
Guarded by the GIL. */
static int pending_async_exc = 0;

Ваш код работал потому что в примере вы не вызывали сложный код написанный на Питоне.
Переключения GIL не было — вот и ничего не ломалось.
Пробовали на Python 3.2+?
Секунду, если он внутри и переключится, то для PyThreadState уже нового под-интерпретатора, и вернётся на выполнение к нему же. Кто сказал что переключения не было? С Python 3.2 переключения идут уже по времени, во время sleep могло быть переключение.
Действительно ли было падение, если да, то покажите код, на котором схема приведённая выше не работает.
Надо же, я даже испугался, если бы не одно но:
> в каждом потоке используется PyGILState_Ensure в начале выполнения и отпускается уже только в самом конце, если бы GIL был глобален, ничего бы не получилось, все бы зависли.
На самом деле ключевое значение здесь PyGILState_STATE приводящее к пониманию когда берётся состояние глобальное, а когда нет. Значение UNLOCKED приходящее в порождённые потоки позволяет зажать GIL по-потоково для под-интерпретаторов.
Еще раз: GIL не лежит в PyInterpreterState. Он в static variables, которые общие для всего процесса.

Переключение GIL происходит в PyEval_EvalFrameEx.

Пока вы не начали запускать питоновский байткод — переключение GIL не происходит. И ничего не ломается.
Не всё можно понять глядя на статичный код. Я бы крайне рекомендовал вам заглянуть в пошаговую отладку в данном приложении. Вам, как человеку любознательному, было бы крайне полезно узнать, что после выполнения макроса Py_BEGIN_ALLOW_THREADS (аналог выполняется в конце конструктора PyMainThread) значение возвращаемое запросом на GIL через PyGILState_Ensure будет PyGILState_UNLOCKED.
Это фактически означает, что для каждого потока в отдельности разрешается хапнуть свой PyGILState_STATE одновременно.
Представляете какая красота, одни и те же функции и два поведения. Фактически ваш метод анализа кода даёт сбой именно в этот момент.
Эта маленькая тонкость даёт возможность запустить одновременно N под-интерпретаторов со своим PyThreadState, для которого в каждом потоке делается фактически свой запрос на GIL.
И снова: GIL в Питоне один, все потоки запускающие питоновский код разделяют владение им.
subinterpreters все работают с этим единственным GIL.
PyGILState_Ensure() из PyMainThread возвращает PyGILState_UNLOCKED потому что вызывается сразу после Py_Initialize и, естественно, GIL никем еще не захвачен.

PyThreadState у каждго субинтерпретатора свой (как и у каждого потока). При этом GIL общий, одновременно запущен только один поток. Остальные спят, пытаясь плучить блокировку.
Да нет же, PyGILState_Ensure() который вызывается самым первым в конструкторе PyMainThread как раз возвращает PyGILState_LOCKED. Как раз захватывая GIL. После этого я его в главном потоке отпускаю аналогом Py_BEGIN_ALLOW_THREADS уже следующей инструкцией.
Во всех порождённых потоках вызов PyGILState_Ensure() возвращает уже PyGILState_UNLOCKED — причём ОДНОВРЕМЕННО. Без этой операции в потоке нельзя даже получить текущее состояние потока, нельзя сделать PyThreadState_Swap. Я его делаю сразу после PyGILState_Ensure, без этого получите падение.
После того как я завершаю работу по созданию под-интерпретатора, я подменяю состояние потока и освобождаю предыдущий hold. После чего спокойно делаю PyGILState_Ensure уже на новое состояние потока PyThreadState.
Далее изоляция происходит уже на уровне под-интерпетаторов. GIL действительно общий, уж простите что обозвал всё то что происходит множеством GIL, на самом деле всё это просто хитрый запуск множества изолированных под-интерпретаторов. Я не понимаю где это может не сработать, если есть нерабочий пример, прошу показать.
Хорошо. Есть несколько интерпретаторов, разделяющих GIL.
Потоки взаимно им блокируются, то есть работают не параллельно.
Так в чем профит?
Вообще-то потоки работают на 4 разных ядрах у меня на Core i5, ладно отлаживаться, запускать приложение пробовали?
В общем, чем именно блокируется выполнение нативных потоков? Кроме как при импорте модулей, запрос к глобальным переменным нигде не происходит, там мы попадаем в мьютекс и спокойно всё получаем. Далее всевозможное API фактически уже не использует и не проверяет GIL.
PyGILState_Ensure мы делаем перед выполнением потока, import вполне можно сделать однажды на поток.
Собственно снова вопрос, что мешает потокам порождённым через boost::thread выполнятся параллельно?
Ничего.
Dict у каждого под-интерпретатора свой, обращение к нему безопасно для каждого из своего потока.
svetlov видимо из личной скромности не стал говорить, что он Python Core Developer ;)
Я в курсе, спасибо, но даже разработчик ядра питона может пару раз сказать вещи, которые расходятся с действительностью. Всё вышесказанное проверяется простой отладкой.
В коде я увидел, что субинтерпретаторы захватывают GIL перед использованием (отчего-то два раза, но это не принципиально).

После этого код в ThreadWork::operator() выполняется уже не параллельно с другими ThreadWork (GIL-то один).

Для демонстрации вызов ctimer.wait() можно заменить на boost::this_thread::sleep()

Опять же для усугубления эффекта рекомендую увеличить паузу с полусекунды раз в 10-100.
Тем, что процесс один, и из приложения на C++ не создаётся ни одного процесса Python. По сути получаете приложение, свободно использующее по интерпретатору в каждом потоке, однако при этом кроме Вашего процесса ни одного не будет создано. Легко использовать общие данные, согласовать между собой задания потоков и завершать их куда проще чем процессы.
Не понимаю такой боязни создания процессов. POSIX-системы их создают на каждый чих, и всё работает отлично.

Видимо, это у Вас от какой-то не POSIX операционки такой страх перед процессами, я прав?

(Это кроме того, что для решения этой задачи есть Pypy, stackless и Twisted.)
По роду деятельности я должен поддерживать работоспособность на Windows. Там всё довольно печально. Поэтому же решение с fork меня не устраивало. Сейчас я уже этому даже рад, моё решение кроссплатформенное, использует один процесс и изолирует память интерпретатора (под-интерпретаторов) между потоками. Почти идеал. Осталось только найти косяки.
Для windows — да, это вариант.

А для Linux — fork() давно уже не копируюет всю память процесса — происходит copy on write, почти так же быстро как создание потоков. Единственный недостаток — данными нужно как-то обмениваться между процессами…
Я бы согласился, если бы не одно но. Что по-вашему делает эта функция: PyOS_AfterFork
Боюсь для C++ приложения использующего Python в общем случае можно упустить такую мелочь как
«to update some internal state after a process fork; this should be called in the new process if the Python interpreter will continue to be used. If a new executable is loaded into the new process, this function does not need to be called»
Понятно что для multiprocessing это не актуально, а вот для fork боюсь тут надо разобраться, что это за «some internal state after a process fork».
Не хотелось бы наступить на невидимые грабли с внутренней кухней данных в Python.
Вопрос от чайника. По собственным ощущениям чистый Python C API мне показался очень мозгодробильным с его Py_BuildValue(), рефкаунтингом, макросами для описания типов и т.д. Дело привычки, впрочем. Все это, кстати, и на C++ работает без экстернов. В Boost все выглядит намного наглядней и изящней. И у меня вопрос, накладывает ли Boost какие-либо ограничения? Исходники Blender'а (C++), например, используют чистый API без обвесок. (Мне это нужно для ускорения расчетов матана модулем, потому мне GIL как бы и удобнее, если OpenCL нет в системе).
Если нужно быстро считать — может вам cython.org/ подойдет?
А так — нет, никаких специфических boost ограничений нет
Я в этом особенного смысла не вижу. Если я с PyArg_ParseTuple() я могу конвертировать данные в нативные типы и дальше иметь полный контроль над байтиками памяти в стеке и куче, и все возможности сишных библиотек по обходу графов, и т.д., после макроса Py_BEGIN_ALLOW_THREADS считать матан в несколько потоков, и там же, если че, сгенерировать нужное исключение, и потом результат с помощью Py_BuildValue() передать обратно в Python. В Boost, мне показалось, приятней работа с кастомными типами (наследование структур и т.д.) и это все лучше, чем cython.
Boost.Python замечательная библиотека. Единственное ограничение: постарайтесь обойтись без глобальный и статических переменных типа boost::python::object и его наследников. Иначе после Py_Finalize в деструкторе ~object() вы поимеете кучу неприятных последствий. В целом Boost.Python показал себя замечательной библиотекой, по сути ничего лишнего с одной стороны, даже mpl там очень в тему, через fusion красиво передаются аргументы и всё это очень здорово упрощает конечный код.
По сути на стороне C++ всё сводится к тройке: import(), attr(), extract().
Единственное где может ещё потребоваться Си-API — при извлечения информации об исключении из Python, когда поймаете catch( boost::python::error_already_set& ) { }, но это пожалуй единственное явное белое пятно в Boost.Python при работе с Python из C++.
Как питонист «со стажем» недавно открыл для себя Groovy, в котором легко решаются задачи, требующие тяжеловесной работы с тредами. Синтаксис после питона дается очень легко. Если пишете что-то маленькое, но полезное, и на питоне упирающееся в GIL, попробуйте. :)

Ссылочка для получения представления: groovy.codehaus.org/Concurrency+with+Groovy
А для чего вы из С++ используете Python? Это какие то внутренние нужны, типа автотестирования или прям что то в продакшен?
Прямо в продакшн: расширения функционала в виде классов и методов на Python.
Удобно: не требует компиляции и подцепляется простым перезапуском.
Опять же можно через Python подключиться и в режиме командной строки узнать много интересного о состоянии сервиса.
По сути сводим слой бизнес-логики к скриптовой обвязке над функциональным ядром скомпилированным на С++.
Отличная статья, ставлю плюс! Получился такой missing manual по управлению GIL.

Вот что качается самого GIL, то я считаю его большим благом. Можно, кончено, сказать, что он удобно и лаконично защищает мощный интерпретатор (внутренние структуры), и т.п., но я не о том. GIL показывает нам тех, в ком скрыт быдлокодер! Простите, если кого обидел. Поясню.

Помните эпоху распространения Win XP? Уверен, у многих был тогда один процессор с одним ядром. И вы запускали на нём программы, которые, казалось работают параллельно. Фантиастика! Программистам того времени приходилось писать грамотный код, чтобы не «убить» компьютер. А что теперь? Каждый, кто не в состоянии подумать об архитектуре программы, плодит десятки (сотни) потоков, на выполнение любой псевдо-параллельной задачи. Нужно посылать запросы, когда пользователь кликнул мышью, ок, давайте сделаем отдельный поток, который будет стоять 99.999% времени! Спрашивается, зачем? Не жалко ресурсов?! У вас не так много ядер, как выдумаете.

Так вот. GIL учит писать хорошие программы, используя волокна и сопрограммы. Нужно выполнить сложную вычислительную задачу? Используйте CUDA, напишите её на Fortran, или создайте отдельный процесс, разверните задачу в PiCloud. При необходимости используйте межпроцессное взаимодействие и Google Compute Engine. Но не ругайте GIL, только потому, что не пробовали использовать шаблон Reactor и вычислять penalty для приостановки потока.
>>Помните эпоху распространения Win XP? Уверен, у многих был тогда один процессор с одним ядром. И вы запускали на нём программы, которые, казалось работают параллельно. Фантиастика! Программистам того времени приходилось писать грамотный код, чтобы не «убить» компьютер

Что вы имеете ввиду? Эпоха XP как раз принесла разделение адресных пространств kernel и user и уронить систему стало намного сложнее.

Всё хорошо в этом подходе, кроме одного случая. Представьте некий скрипт с массой колбеков написанных на питоне, и эти колбеки вызываются из разных потоков. Как быть? У вас один скрипт, и вы должны запустить его в одном потоке, где уже сам скрипт должен зарегистрировать свои функции как колбеки, и вы даже не знаете изначально из какого потока они будут вызываться.

Sign up to leave a comment.

Articles