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

    Как известно, в основной реализации Питона 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 или каким другим способом. Детали работы с процессами выходят за рамки этой статьи.


    Правила простые, исключений и обходных лазеек нет.
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 18
    • +2
      Собственно вопрос разработчику ядра:
      Когда допилите GIL до изоляции на уровне суб-интерпретаторов?

      Насчёт GIL — это просто bool, который устанавливается используя блокировку и не мешает многопоточно выполнять C++ коду в отдельном потоке.
      • +1
        Полагаю, к версии 4.0
        bugs.python.org/issue15751 если будет сделан поможет написать работу с субинтерпретаторами правильно (PyGILState_Ensure умеет жить только с главным интерпретатором если что).

        Основная беда следующая: субинтерпретаторы разделяют слишком много данных. Если питоновский код еще можно как-то развести по углам, то с C Extensions полная беда.

        По сложившейся практике данные модуля все хранят как static variables. Существует PEP 3121 для module state, но пока его никто не реализовывает (благо что необязательный).
        • 0
          Py_BEGIN_ALLOW_THREADS/Py_END_ALLOW_THREADS позволяют параллельное выполнение даже в питоновских потоках.

          И, естественно, ничто не мешает создавать отдельные потоки, о которых Питон ничего не знает.

          Только Python API без GIL вызывать нельзя. Хоть и очень хочется.

          К слову, PyGILState_Ensure/PyGILState_Release — довольно дорогая операция.
          Лучше делать её один раз на поток, используя PyEval_SaveThread/PyEval_RestoreThread для временного отпускания GIL.
        • +1
          Можно ли объявить эти static variables как threadlocal? Будет ли в этом случае работать описанная выше схема?
          • 0
            Субинтерпретаторы не обязаны работать в разных потоках. Т.е. должна поддерживаться в том числе и схема, при которой несколько субинтерпретаторов разделяют один поток. threadlocal здесь не помогут.
            • 0
              Я так понимаю, что в этом случае с собой надо всегда носить контекст, а для этого нужно перелопатить кучу кода как CPython, так и его модулей.

              PEP-3121, упомянутый Вами, это и предлагает.
              • 0
                Всё верно, нужно перелопатить много кода, причем основной объем в третьесторонних библиотеках. PEP 3121 не предлагает «контекст», он только дает место для хранения переменных модуля и всё.
          • +1
            На multiprocessing можно реализовать практически любые вещи, которые делаются, например, в java при помощи «нормальных» тредов. Может написать пару статтей, как обмениваться данными между питоническими процессами, делать их синхронными и асинхронными, делать процессы-слушатели и т.п.?
            • 0
              А разве есть какие-то хитрые питонические способы обмена данными между процессами, кроме как средствами ОС?
              (а значит, со всяческими сериализациями)
              • +2
                Конечно есть. Например можно делать общее пространство имён между процессами:
                import multiprocessing
                
                def producer(ns, event):
                    ns.value = 'This is the value'
                    event.set()
                
                def consumer(ns, event):
                    try:
                        value = ns.value
                    except Exception, err:
                        print 'Before event, consumer got:', str(err)
                    event.wait()
                    print 'After event, consumer got:', ns.value
                
                if __name__ == '__main__':
                    mgr = multiprocessing.Manager()
                    namespace = mgr.Namespace()
                    event = multiprocessing.Event()
                    p = multiprocessing.Process(target=producer, args=(namespace, event))
                    c = multiprocessing.Process(target=consumer, args=(namespace, event))
                    
                    c.start()
                    p.start()
                    
                    c.join()
                    p.join()
                
                • 0
                  Тогда да, статьи были бы интересны.
                  Особенно с прикручиванием к twisted.
                  • +3
                    Если быть точным, namespace внутри использует pickle или xmlrpclib.
                  • 0
                    В multiprocessing очень много всего.

                    Мне, например, нравится работа через Pipe для передачи комплексных переменных между процессами.
                    • 0
                      а питоновые объекты так передавать можно?
                  • +2
                    Фактически все нетривиальные (не int, float, str) типы используют сериализацию. Т.к. picke обычно замечательно работает для multiprocessing «из коробки» — никаких неудобств это не доставляет и происходит незаметно для программиста.
                • 0
                  Подскажите, когда планировщик забирает у питоноского потока слайс через 5 миллисекунд — он это может сделать в любом месте? Например, если у меня есть франмент кода на питоне:

                  somelist.insert( 0, somelist.pop() )
                  


                  Поток может быть остановлен после того как элемент изъят из конца списка но до того, как он добавлен в начало списка? Есть какие-то ограничения на то, когда поток можно останавливать — или в абсолютно любое время, так же как в операционных системах?
                  • +1
                    Переключение GIL может произойти перед выполнением любого байткода. Так что всё верно: ваш пример не атомарный.

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.