Разрушая миф о многопоточности в Python

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

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

Потоки в python идеально подходят для выполнения длительных операций ввода-вывода, таких как вызовы базы данных или удаленного API, файловые операции и т. Д. Там, где они показывают довольно низкую производительность, являются тяжелые операции ЦП (например, вложенные циклы, рекурсия и т. Д.)

Если есть одна вещь, которую вы должны помнить из всей этой статьи, так это то, что многопоточность идеально подходит для Python, как и все другие языки, которые ее используют. Да, есть некоторые предостережения, за которыми вам нужно следить, но вы не должны бояться использовать их только потому, что кто-то вам так сказал. В зависимости от реализации Python, которую вы используете, может не быть GIL, поэтому вы можете усложнить жизнь разработки, не используя потоки.

OK? Хороший. Позвольте мне начать с:

Глобальная блокировка интерпретатора (GIL) - это функция (да, на самом деле функция) CPython (реализация Python по умолчанию), которая сильно связана с тем, как CPython управляет памятью. Существует два популярных способа сборки мусора в программных приложениях: отслеживание сборки мусора (также известное как сборка мусора) и подсчет ссылок. CPython использует подсчет ссылок, который представляет собой простой и детерминированный способ управления памятью. Каждый раз, когда кто-то получает ссылку на переменную, счетчик ссылок автоматически увеличивается. Каждый раз, когда переменная выходит за пределы области видимости, счетчик ссылок автоматически уменьшается. Когда счетчик ссылок достигает 0, указанная память удаляется.

Звучит красиво и легко, и по большей части так оно и есть. И так до тех пор, пока не появится многопоточность. Видите ли, если два потока обращаются к одной и той же ссылке одновременно, может произойти несколько вещей. Уменьшение и увеличение счетчика ссылок требует некоторого технологического времени. Это немного, но и не происходит мгновенно. Представьте, что один поток завершает выполнение, а процесс декремента только начинается. Другой поток входит прямо в середину этого и пытается получить ссылку на ту же часть памяти. Если разыменование завершается первым, но когда второй поток получает ссылку, связанная память могла быть удалена, что приведет к SEGFAULT.

Также может быть случай, когда два или более потока пытаются параллельно увеличить счетчик ссылок. Это открывает возможность перезаписать счетчик меньшим значением, не зная, что оно уже было увеличено. Указанная память никогда не будет утилизирована, что является классической утечкой памяти.

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

Помните о слове РАБОТАЕТ. Я пишу его заглавными буквами, чтобы различать состояния потока RUNNING и WAITING. Когда поток выполняет длительную операцию ввода-вывода, такую ​​как вызов базы данных, он переключается из состояния RUNNING в состояние WAITING и сбрасывает блокировку GIL. Поскольку во время состояния WAITING в этом потоке не будут выполняться никакие дальнейшие операции, ни один из сценариев, упомянутых выше, не может произойти, поэтому другой поток может захватить GIL и продолжить. Когда поток выходит из состояния WAITING обратно в RUNNING, он снова получает блокировку GIL и выполняет быстрое обновление счетчиков ссылок заявленного состояния.

В идеале, если код, выполняемый в потоке, состоит только из блокирующего вызова (БД, удаленный API, диск и т. Д.), Это идеальный кандидат для параллельного выполнения. Когда код начинает усложняться и вычисляются операции с высокой нагрузкой на ЦП, производительность начинает падать до скорости последовательного выполнения или даже хуже (из-за накладных расходов на блокировку GIL). Здесь на помощь приходит многопроцессорность. Хотя процессы ограничены количеством доступных ядер ЦП, они не разделяют память, и, следовательно, нет необходимости в GIL. Если тяжелая операция включает опрос нескольких источников ввода-вывода для данных, перед объединением форматированного результата вы предпочитаете выполнять операции ввода-вывода в отдельных потоках, а при необходимости - окончательные преобразования в отдельных процессах.

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

Примечание. В отличие от распространенного мнения, PyPy (JIT-компилятор Python) на самом деле имеет GIL, хотя вместо подсчета ссылок он использует трассировку сборки мусора. Это было скорее дизайнерское решение придерживаться оригинального дизайна языка, чем реальная технологическая потребность. Однако существуют и другие реализации, такие как Jython и IronPython, в которых вообще отсутствует GIL.

Первоначально опубликовано на https://preslav.me 3 июня 2019 г.