Пользователь
0,0
рейтинг
4 февраля 2013 в 13:00

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


Правила простые, исключений и обходных лазеек нет.
Андрей Светлов @svetlov
карма
84,2
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

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

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


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

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