Пользователь
0,0
рейтинг
18 февраля 2010 в 12:29

Разработка → Как устроен GIL в Python перевод

Почему после распараллеливания выполнение вашей программы может замедлиться вдвое?
Почему после создания потока перестает работать Ctrl-C?
Представляю вашему вниманию перевод статьи David Beazley «Inside the Python GIL». В ней рассматриваются некоторые тонкости работы потоков и обработки сигналов в Python.

GIL


Вступление


Как известно, в Python используется глобальная блокировка интерпретатора (Global Interpreter Lock — GIL), накладывающая некоторые ограничения на потоки. А именно, нельзя использовать несколько процессоров одновременно. Это избитая тема для холиваров о Python, наряду с tail-call оптимизацией, lambda, whitespace и т. д.

Дисклеймер


Я не испытываю глубокого возмущения по поводу использования GIL в Python. Но для параллельных вычислений с использованием нескольких CPU я предпочитаю передачу сообщений и межпроцессное взимодействие использованию потоков. Однако меня интересует неожиданное поведение GIL на многоядерных процессорах.

Тест производительности


Рассмотрим тривиальную CPU-зависимую функцию (т.е. функцию, скорость выполнения которой зависит преимущественно от производительности процессора):
def count(n):
  while n > 0:
    n -= 1

Сначала запустим ее дважды по очереди:
count(100000000)
count(100000000)

Теперь запустим ее параллельно в двух потоках:
t1 = Thread(target=count,args=(100000000,))
t1.start()
t2 = Thread(target=count,args=(100000000,))
t2.start()
t1.join(); t2.join()

Следующие результаты получены на двухъядерном MacBook:
  • последовательный запуск — 24,6 с
  • параллельный запуск — 45,5 с (почти в 2 раза медленнее!)
  • параллельный запуск после отключения одного из ядер — 38,0 с

Мне не нравятся необъяснимые магические явления. В рамках проекта, запущенного мной в мае, я начал разбираться в реализации GIL, чтобы понять, почему я получил такие результаты. Я прошел все этапы, начиная с Python-скриптов и заканчивая исходным кодом библиотеки pthreads (да, возможно, мне стоит выходить на улицу чаще). Итак, давайте разберемся по порядку.

Подробнее о потоках


Python threads — это настоящие потоки (POSIX threads или Windows threads), полностью контролируемые ОС. Рассмотрим поточное выполнение в процессе интерпретатора Python (написанного на C). При создании поток просто выполняет метод run() объекта Thread или любую заданную функцию:
import time
import threading

class CountdownThread(threading.Thread):
  def __init__(self,count):
    threading.Thread.__init__(self)
    self.count = count
→ def run(self):
    while self.count > 0:
      print "Counting down"self.count
      self.count -= 1
      time.sleep(5)
    return

На самом деле происходит гораздо большее. Python создает маленькую структуру данных (PyThreadState), в которой указаны: текущий stack frame в коде Python, текущая глубина рекурсии, идентификатор потока, некоторая информация об исключениях. Структура занимает менее 100 байт. Затем запускается новый поток (pthread), в котором код на языке C вызывает PyEval_CallObject, который запускает то, что указано в Python callable.

Интерпретатор хранит в глобальной переменной указатель на текущий активный поток. Выполняемые действия всецело зависят от этой переменной:
/* Python/pystate.c */
...
PyThreadState *_PyThreadState_Current = NULL;

Печально известный GIL


В этом вся загвоздка: в любой момент может выполняться только один поток Python. Глобальная блокировка интерпретатора — GIL — тщательно контролирует выполнение тредов. GIL гарантирует каждому потоку эксклюзивный доступ к переменным интерпретатора (и соответствующие вызовы C-расширений работают правильно).

Принцип работы прост. Потоки удерживают GIL, пока выполняются. Однако они освобождают его при блокировании для операций ввода-вывода. Каждый раз, когда поток вынужден ждать, другие, готовые к выполнению, потоки используют свой шанс запуститься.
GIL

При работе с CPU-зависимыми потоками, которые никогда не производят операции ввода-вывода, интерпретатор периодически проводит проверку («the periodic check»).
GIL
По умолчанию это происходит каждые 100 «тиков», но этот параметр можно изменить с помощью sys.setcheckinterval(). Интервал проверки — глобальный счетчик, абсолютно независимый от порядка переключения потоков.
GIL

При периодической проверке в главном потоке запускаются обработчики сигналов, если таковые имеются. Затем GIL отключается и включается вновь. На этом этапе обеспечивается возможность переключения нескольких CPU-зависимых потоков (при кратком освобождении GIL другие треды имеют шанс на запуск).
/* Python/ceval.c */
...
if (--_Py_Ticker < 0) {
  ...
  _Py_Ticker = _Py_CheckInterval;
  ...
  if (things_to_do) {
    if (Py_MakePendingCalls() < 0) {
      ...
    }
  }
  if (interpreter_lock) {
    /* даем шанс другому потоку */
    ...
    PyThread_release_lock(interpreter_lock);
    /* сейчас могут запуститься другие потоки */
    PyThread_acquire_lock(interpreter_lock, 1);
    ...
}

Тики примерно соответствуют выполнению инструкций интерпретатора. Они не основываются на времени. Фактически, длинная операция может заблокировать всё:
>>> nums = xrange(100000000)
>>> -1 in nums # 1 тик (6,6 с)
False
>>>

Тики нельзя прервать, Ctrl-C в данном случае не остановит выполнение программы.

Сигналы


Давайте поговорим о Ctrl-C. Очень распространенная проблема заключается в том, что программа с несколькими потоками не может быть прервана с помощью keyboard interrupt. Это очень раздражает (вам придется использовать kill -9 в отдельном окне). (От переводчика: у меня получалось убивать такие программы по Ctrl+F4 в окне терминала.) Удивительно, почему Ctrl-C не работает?

Когда поступает сигнал, интерпретатор запускает «check» после каждого тика, пока не запустится главный поток. Так как обработчики сигналов могут быть запущены только в главном потоке, интерпретатор часто выключает и включает GIL, пока не запустится главный поток.
GIL

Планировщик потоков


У Python нет средств для определения, какой поток должен запуститься следующим. Нет приоритетов, вытесняющей многозадачности, round-robin и т.п. Эта функция целиком возлагается на операционную систему. Это одна из причин странной работы сигналов: интерпретатор никак не может контроллировать запуск потоков, он просто переключает их как можно чаще, надеясь, что запустится главный поток.

Ctrl-C часто не срабатывает в многопоточных программах, потому что главный поток обычно заблокирован непрерываемым thread-join или lock. Пока он заблокирован, он не сможет запуститься. Как следствие, он не сможет выполнить обработчик сигнала.

В качестве дополнительного бонуса, интерпретатор остается в состоянии, где он пытается переключить поток после каждого тика. Мало того, что вы не можете прервать программу, она еще и работает медленнее.

Реализация GIL


GIL — это не обычный мьютекс. Это либо безымянный POSIX-семафор, либо условная переменная pthreads. Блокировка интерпретатора основана на отправке сигналов.
  • Чтобы включить GIL, проверить, свободен ли он. Если нет, ждать следующего сигнала.
  • Чтобы выключить GIL, освободить его и послать сигнал.

Переключение потоков таит в себе больше тонкостей, чем обычно думают программисты.
GIL
Задержка между отправкой сигнала и запуском потока может быть довольно существенной, это зависит от операционной системы. А она учитывает приоритет выполнения. При этом задачи, требующие выполнения операций ввода-вывода, имеют более высокий приоритет, чем CPU-зависимые. Если сигнал посылается потоку с низким приоритетом, а процессор занят более важными задачами, то этот поток не будет выполняться довольно долго.

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

Измерим количество системных вызовов.
Для последовательного выполнения: 736 (Unix), 117 (Mac).
Для двух потоков: 1149 (Unix), 3,3 млн. (Mac).
Для двух потоков на двухъядерной системе: 1149 (Unix), 9,5 млн. (Mac).

На многоядерной системе CPU-зависимые процессы переключаются одновременно (на разных ядрах), в результате происходит борьба за GIL:
GIL


Ожидающий поток при этом может сделать сотни безуспешных попыток захватить GIL.
Мы видим, что происходит битва за две взаимоисключающие цели. Python просто хочет запускать не больше одного потока в один момент. А операционная система («Ооо, много ядер!») щедро переключает потоки, пытаясь извлечь максимальную выгоду из всех ядер.

Даже один CPU-зависимый поток порождает проблемы — он увеличивает время отклика I/O-зависимого потока.
GIL
Последний пример — причудливая форма проблемы смены приоритетов. CPU-зависимый процесс (с низким приоритетом) блокирует выполнение I/O-зависимого (с высоким приоритетом). Это происходит только на многоядерных процессорах, потому что I/O-поток не может проснуться достаточно быстро и заполучить GIL раньше CPU-зависимого.

Заключение


Реализация GIL в Python за последние 10 лет почти не изменилась. Соответствующий код в Python 1.5.2 выглядит практически так же, как в Python 3.0. Я не знаю, было ли поведение GIL достаточно хорошо изучено (особенно на многоядерных процессорах). Полезнее удалить GIL вообще, чем изменять его. Мне кажется, этот предмет требует дальнейшего изучения. Если GIL остается с нами, стоит исправить его поведение.

Как же всё-таки избавиться от этой проблемы? У меня есть несколько смутных идей, но все они «сложные». Нужно, чтобы в Python появился свой собственный диспетчер потоков (или хотя бы механизм взаимодействовия с диспетчером ОС). Но это требует нетривиального взаимодействия между интерпретатором, планировщиком ОС, библиотекой потоков и, что самое страшное, модулями C-расширений.

Стоит ли оно того? Исправление поведения GIL сделало бы выполнение потоков (даже с GIL) более предсказуемым и менее требовательным к ресурсам. Возможно, улучшится производительность и уменьшится время отклика приложений. Надеюсь, при этом удастся избежать полного переписывания интерпретатора.

Послесловие от переводчика


Оригинал был оформлен как презентация, поэтому мне пришлось немного изменить порядок повествования, чтобы статью было легче читать. Также я исключил трассировки работы интерпретатора — если вам интересно, посмотрите в оригинале.

Хабралюди, посоветуйте интересные английские статьи по Python, которые было бы хорошо перевести. У меня есть на примете пара статей, но хочется еще вариантов.
Перевод: David Beazley
Павел @Riateche
карма
226,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

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

Комментарии (20)

  • +4
    Из англоязычных мне понравилась презентация про новый GIL: www.dabeaz.com/blog/2010/01/presentation-on-new-python-gil.html
  • +1
    Я как бы не самый большой специалист по питону (можно сказать, вообще не специалист), но из прочтенной статьи сделал следующие выводы:

    1. В питоне нет многопоточности в привычном понимании.
    2. Использование потоков очень сильно замедляет выполнение программы.

    Это так?
    • +2
      2. Верно для тех программ где скорость выполнения которой зависит преимущественно от производительности процессора.
      Для операций последовательного и параллельного (в тредах) скачивания файлов из интернета выигрывает конечно второй способ.
      • 0
        Ухты, хабракат в коментах уже)

        спс за разъяснение
    • +4
      Если уж случилось такое горе, что надо писать многопоточное приложения, проводящее активные вычисления (скажем, перекодирование чего-нуть во что-нибудь, да еще с большим количеством исходных данных); то эффективней использовать несколько процессов с модулем multiprocessing или ручками.

      В некритичных приложениях потоки использовать вполне можно.

      В критичных случаях используется наиболее эффективная асинхронная (не-б-б-б-б-блокирующая) модель, когда пускается несколько процессов с неблокирующим IO.

      Чтобы вы не думали, что GIL появился с потолка, могу добавить, что его наличие в полтора-два раза ускоряет работу однопоточных приложений. Гвидо в свое время заявил, что GIL не уйдет до тех пор, пока не будет предложено решение, не снижающее производительность обыкновенных однопоточных программ.

  • +6
    import multiprocessing и никаких проблем. Вообще проблема GIL сильно преувеличена: попытки заменить GIL на отдельные локи провалилась, так как GIL просто быстрее.

    Так что GIL — скорее очередной повод попинать питон, нежели реальная проблема.
    • +3
      > import multiprocessing и никаких проблем.

      Тем более учитывая то, что в Unix создание процесса обходится очень дёшево.

      Не помню кто, один из гуру C++, сказал, что даже после многих лет работы с потоками неспособен написать корректную многопоточную программу.

      Вообще, это скорее дискуссия на тему процессы vs потоки :-)
      • 0
        Это был Брюс Эккель, он помоему жабист www.artima.com/weblogs/viewpost.jsp?thread=214112

        А вообще я на практике однопоточным приложением в 2 раза убирал многопоточные при перемножении матриц на C
        • 0
          У него несколько книжек по плюсам, он в нём хорошо разбирается. Насчёт явы я не интересовался, а вот C++ он точно знает лучше, чем большинство других программистов.

          Про умножение — каким образом?
          • 0
            Да просто. У процессора есть кэш. Несколько потоков снижают эффективность кеша. Если матрицы представить в виде векторов (строка за строкой), то все становится очень быстро и много времени кеш экономит
    • +2
      Вы смяли немного тему — не провалилась, а Гвидо дал отворот, потому что однопоточная программа стала медленнее работать. А в мире гораздо больше однопоточных python-программ. В многопоточных программах вариант с локами работал быстрее.
  • 0
    ИМХО, самое главное, нужно определиться, для чего нужна многопоточность. Нужна она для выполнения нескольких задач одновременно. Но не для ускорения вычислительных задач.
    Просто нужно понимать ограничения и применимость подхода. Без знания специфики, вообще в многопоточное программирования лучше не соваться.
  • +2
    у меня вот вопрос…
    я выполнил Ваш скрипт (Тест производительности) своего ничего не дописывал, только копипаст:
    выполнения скрипта без потоков:
    real 1m45.761s
    user 1m45.563s
    sys 0m0.112s

    выполнение скрипта с потоками:
    real 0m12.954s
    user 0m12.201s
    sys 0m0.808s

    это я что-то не так сделал или у меня неправильная машина?
    ubuntu, intel core2duo работают 2 ядра

    может все-таки в маке дело?
    • 0
      Возможно. В маке количество сигналов возрастает явно быстрее, чем в линуксе.
      • 0
        было бы неплохо проверить этот тест на винде, возможно на самом деле проблема в маке?:)
        • 0
          Проверил
          WinXP, Intel Core Duo

          29,5 с. в однопоточном режиме
          43,9 с. в двухпоточном

          Похоже что Убунта знает волшебное слово :)
          • +1
            Дико извиняюсь, я протупил, в однопоточком скрипте я ошибся ноликом :( мне стыдно, убунта, увы, не знает волшебного слова
      • +1
        Дико извиняюсь, я протупил, в однопоточком скрипте я ошибся ноликом :( мне стыдно, Вы правы, потоки медленней
  • +3
    Тоже порекомендую перевести в тему презентацию о новом GIL предложенный в версии Python 3.2:
    www.dabeaz.com/python/NewGIL.pdf
  • 0
    Надо, наверное, смотреть в сторону кооперативных потоков, (Stackless Pyhton, например). Есть ли в питоне что-то похожее на GNU Pth для C?

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