Еще раз о многопоточности и Python

в 9:00, , рубрики: embedded, GIL, multithreading, python, python3, sub-interpreter, многопоточность, Питон, метки: , , , , , , , ,

Как известно, в основной реализации Питона CPython (python.org) используется Global Interpreter Lock (GIL). Эта штука позволяет одновременно запускать только один питоновский поток — остальные обязаны ждать переключения GIL на них.

Коллега Qualab недавно опубликовал на Хабре бойкую статью, предлагая новаторский подход: создавть по субинтерпретатору Питона на поток операционной системы, получая возможность запускать все наши субинтерпретаторы параллельно. Т.е. GIL как бы уже и не мешает совсем.

Идея свежая, но имеет один существенный недостаток — она не работает…

Позвольте мне сначала рассмотреть GIL чуть подробней, а потом перейдем к разбору ошибок автора.

GIL

Тезисно опишу существенные для рассмотрения детали GIL в реализации Python 3.2+ (более подробное изложение предмета можете найти тут).

Версия 3.2 выбрана для конкретики и сокращения объема изложения. Для 1.x и 2.x отличия незначительны.

  • GIL, как следует из названия — это объект синхронизации. Предназначен для блокирования одномоментного доступа к внутреннему состоянию Python из разных потоков.
  • Он может быть захвачен каким-либо потоком или оставаться свободным (незахваченным).
  • Одновременно захватить GIL может только один поток.
  • GIL один единственный на весь процесс, в котором выполняется Python. Еще раз подчеркну: GIL спрятан не в субинтерпретаторе или где-то еще — он реализован в виде набора static variables, общими для всего кода процесса.
  • С точки зрения GIL каждому потоку, выполняющему Python C API вызовы, должна соответствовать структура PyThreadState. GIL указывает на один из PyThreadState (работающий) или не указывает ни на что (GIL отпущен, потоки работают независимо и параллельно).
  • После старта интерпретатора единственная операция, позволенная над Python C API при незахваченном GIL — это его захват. Всё остальное запрещено (технически безопасен также Py_INCREF, Py_DECREF может вызвать удаление объекта, что может вызвать бесконтрольное незащищенное одновременное изменение того самого внутреннего состояния Python, которое и пытается предотвратить GIL). В DEBUG сборке проверок на неправильную работу с GIL больше, в RELEASE часть отключена для повышения производительности.
  • Переключается GIL по таймеру (по умолчанию 5 мс) или явным вызовом (
    PyThreadState_Swap, PyEval_RestoreThread, PyEval_SaveThread, PyGILState_Ensure, PyGILState_Release и т.д.)

Как видим, запускать одноременное параллельное выполнение кода можно, нельзя при этом делать вызовы Python C API (это касается выполнения кода написанного на питоне тоже, естественно).

При этом «нельзя» означает (особенно в RELEASE сборке, используемой всеми) что такое поведение нестабильно. Может и не сломаться сразу. Может на этой программе вообще работать замечательно, а при небольшом безобидном изменении выполняемого питоновского кода завершаться с segmentation fault и кучей побочных эффектов.

Почему субинтепретаторы не помогают

Что же делает коллега Qualab (ссылку на архив с кодом можете найти в его статье, исходник я продублировал на gist: gist.github.com/4680136)?

В главном потоке сразу же отпускается GIL через PyEval_SaveThread(). Главный поток больше с питоном не работает — он создает несколько рабочих потоков и ждет их завершения.

Рабочий поток захватывает GIL. Код вышел странноватым, но сейчас это не принципиально. Главное — GIL зажат у нас в кулаке.

И сразу же параллельное исполнение рабочих потоков превращается в последовательное. Можно было и не городить конструкцию с субинтерпретаторами — толку от них в нашем контексте ровно ноль, как и ожидалось.

Не знаю, почему автор этого не заметил сразу, до опубликования статьи. А потом долго упорствовал, предпочитая называть черное белым.

Вернуться к параллельному исполнению просто — нужно отпустить GIL. Но тогда нельзя будет работать с интерпретатором Питона.

Если всё же наплевать на запрет и вызывать Python C API без GIL — программа сломается, причем не обязательно прямо сразу и не факт что без неприятных побочных эффектов. Если хотите выстрелить себе в ногу особенно замысловатым способом — это ваш шанс.

Повторюсь опять: GIL один на весь процесс, не на интерпретатор-субинтерпретатор. Захват GIL означает, что все потоки выполняющие питоновский код приостановлены.

Заключение

Нравится GIL или не очень — он уже есть и я настоятельно рекомендую научиться правильно с ним работать.

  1. Либо захватываем GIL и вызываем функции Python C API.
  2. Или отпускаем его и делаем что хотим, но Питон трогать в этом режиме нельзя.
  3. Параллельная работа обеспечивается одновременным запуском нескольких процессов через multiprocessing или каким другим способом. Детали работы с процессами выходят за рамки этой статьи.

Правила простые, исключений и обходных лазеек нет.

Автор: svetlov

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js