Как устроен GIL в Python
Почему после распараллеливания выполнение вашей программы может замедлиться вдвое?
Почему после создания потока перестает работать Ctrl-C?
Представляю вашему вниманию перевод статьи David Beazley «Inside the Python GIL». В ней рассматриваются некоторые тонкости работы потоков и обработки сигналов в Python.
Вступление
Как известно, в Python используется глобальная блокировка интерпретатора (Global Interpreter Lock — GIL), накладывающая некоторые ограничения на потоки. А именно, нельзя использовать несколько процессоров одновременно. Это избитая тема для холиваров о Python, наряду с tail-call оптимизацией, lambda, whitespace и т. д.
Дисклеймер
Я не испытываю глубокого возмущения по поводу использования GIL в Python. Но для параллельных вычислений с использованием нескольких CPU я предпочитаю передачу сообщений и межпроцессное взимодействие использованию потоков. Однако меня интересует неожиданное поведение GIL на многоядерных процессорах.
Тест производительности
Рассмотрим тривиальную CPU-зависимую функцию (т.е. функцию, скорость выполнения которой зависит преимущественно от производительности процессора):
Сначала запустим ее дважды по очереди:
Теперь запустим ее параллельно в двух потоках:
Подробнее о потоках
Python threads — это настоящие потоки (POSIX threads или Windows threads), полностью контролируемые ОС. Рассмотрим поточное выполнение в процессе интерпретатора Python (написанного на C). При создании поток просто выполняет метод run() объекта Thread или любую заданную функцию:
На самом деле происходит гораздо большее. Python создает маленькую структуру данных (PyThreadState), в которой указаны: текущий stack frame в коде Python, текущая глубина рекурсии, идентификатор потока, некоторая информация об исключениях. Структура занимает менее 100 байт. Затем запускается новый поток (pthread), в котором код на языке C вызывает PyEval_CallObject, который запускает то, что указано в Python callable.
Интерпретатор хранит в глобальной переменной указатель на текущий активный поток. Выполняемые действия всецело зависят от этой переменной:
Печально известный GIL
В этом вся загвоздка: в любой момент может выполняться только один поток Python. Глобальная блокировка интерпретатора — GIL — тщательно контролирует выполнение тредов. GIL гарантирует каждому потоку эксклюзивный доступ к переменным интерпретатора (и соответствующие вызовы C-расширений работают правильно).
Принцип работы прост. Потоки удерживают GIL, пока выполняются. Однако они освобождают его при блокировании для операций ввода-вывода. Каждый раз, когда поток вынужден ждать, другие, готовые к выполнению, потоки используют свой шанс запуститься.
При работе с CPU-зависимыми потоками, которые никогда не производят операции ввода-вывода, интерпретатор периодически проводит проверку («the periodic check»).
По умолчанию это происходит каждые 100 «тиков», но этот параметр можно изменить с помощью sys.setcheckinterval(). Интервал проверки — глобальный счетчик, абсолютно независимый от порядка переключения потоков.
При периодической проверке в главном потоке запускаются обработчики сигналов, если таковые имеются. Затем GIL отключается и включается вновь. На этом этапе обеспечивается возможность переключения нескольких CPU-зависимых потоков (при кратком освобождении GIL другие треды имеют шанс на запуск).
Тики примерно соответствуют выполнению инструкций интерпретатора. Они не основываются на времени. Фактически, длинная операция может заблокировать всё:
Тики нельзя прервать, Ctrl-C в данном случае не остановит выполнение программы.
Сигналы
Когда поступает сигнал, интерпретатор запускает «check» после каждого тика, пока не запустится главный поток. Так как обработчики сигналов могут быть запущены только в главном потоке, интерпретатор часто выключает и включает GIL, пока не запустится главный поток.
Планировщик потоков
У Python нет средств для определения, какой поток должен запуститься следующим. Нет приоритетов, вытесняющей многозадачности, round-robin и т.п. Эта функция целиком возлагается на операционную систему. Это одна из причин странной работы сигналов: интерпретатор никак не может контроллировать запуск потоков, он просто переключает их как можно чаще, надеясь, что запустится главный поток.
Ctrl-C часто не срабатывает в многопоточных программах, потому что главный поток обычно заблокирован непрерываемым thread-join или lock. Пока он заблокирован, он не сможет запуститься. Как следствие, он не сможет выполнить обработчик сигнала.
В качестве дополнительного бонуса, интерпретатор остается в состоянии, где он пытается переключить поток после каждого тика. Мало того, что вы не можете прервать программу, она еще и работает медленнее.
Реализация GIL
Задержка между отправкой сигнала и запуском потока может быть довольно существенной, это зависит от операционной системы. А она учитывает приоритет выполнения. При этом задачи, требующие выполнения операций ввода-вывода, имеют более высокий приоритет, чем CPU-зависимые. Если сигнал посылается потоку с низким приоритетом, а процессор занят более важными задачами, то этот поток не будет выполняться довольно долго.
В результате сигналов, которые посылает поток GIL, становится слишком много.
Каждые 100 тиков интерпретатор блокирует мьютекс, посылает сигнал в переменную или семафор процессу, который всё время этого ждет.
Измерим количество системных вызовов.
Для последовательного выполнения: 736 (Unix), 117 (Mac).
Для двух потоков: 1149 (Unix), 3,3 млн. (Mac).
Для двух потоков на двухъядерной системе: 1149 (Unix), 9,5 млн. (Mac).
На многоядерной системе CPU-зависимые процессы переключаются одновременно (на разных ядрах), в результате происходит борьба за GIL:
Ожидающий поток при этом может сделать сотни безуспешных попыток захватить GIL.
Мы видим, что происходит битва за две взаимоисключающие цели. Python просто хочет запускать не больше одного потока в один момент. А операционная система («Ооо, много ядер!») щедро переключает потоки, пытаясь извлечь максимальную выгоду из всех ядер.
Даже один CPU-зависимый поток порождает проблемы — он увеличивает время отклика I/O-зависимого потока.
Последний пример — причудливая форма проблемы смены приоритетов. 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, которые было бы хорошо перевести. У меня есть на примете пара статей, но хочется еще вариантов.
Потоковые и многопроцессорные модули на Python
Apr 23, 2020 · 6 min read
Главная идея потоков заключается в выполнении последовательности таких инструкций внутри программы, которые могут выполняться независимо от другого кода.
Так в чём же разница между потоковой и многопроцессорной обработкой данных? При одновременном выполнении нескольких задач обычно используется потоковая обработка, а при процессно-ориентированном параллелизме задействуется многопроцессорная обработка.
Задачи с ограничением скорости вычислений и ввода-вывода
Время выполнения задач, ограниченных скоростью вычислений, полностью зависит от производительности процессора, тогда как в задачах I/O Bound скорость выполнения процесса ограничена скоростью системы ввода-вывода.
В задачах с ограничением скорос т и вычислений программа расходует большую часть времени на использование центрального процессора, то есть на выполнение вычислений. К таким задачам можно отнести программы, занимающиеся исключительно перемалыванием чисел и проведением расчётов.
В задачах, ограниченных скоростью ввода-вывода, программы обрабатывают большие объёмы данных с диска в сравнении с необходимым объёмом вычислений. К таким задачам можно отнести, например, подсчёт количества строк в файле.
Проблема GIL на Python
Обычно на Python используется только один поток для выполнения нескольких записанных инструкций, то есть одновременно выполняется только один поток. Производительность однопоточного и многопоточного процессов здесь одинакова, и происходит это из-за GIL (Global Interpreter Lock — глобальной блокировки интерпретатора). Эта глобальная блокировка интерпретатора сама действует как поток и ограничивает другие потоки, делая невозможной многопоточность на Python.
Процессы ускоряют операции на Python, которые создают интенсивную вычислительную нагрузку на центральный процессор, используя сразу несколько ядер и избегая GIL, в то время как потоки лучше подходят для задач ввода-вывода или задач, связанных со внешними системами, потому что потоки могут более эффективно работать вместе. Для объединения процессов им нужно сериализовывать свои результаты, на что требуется время.
Потоки на Python не дают никаких преимуществ для задач, создающих интенсивную вычислительную нагрузку на процессор, именно из-за GIL.
Зачем нужен GIL?
Потоковый модуль использует потоки, многопроцессорный модуль использует процессы. Разница в том, что потоки выполняются в одном и том же пространстве памяти, а у процессов отдельная память. Это немного затрудняет совместное использование объектов процессами с многопроцессорной обработкой. В этом случае обычно выполняется сериализация объектов. Но потоки используют одну память, поэтому нужно быть осторожным, иначе два потока будут записывать данные в одну и ту же память одновременно. Именно для этого и существует глобальная блокировка интерпретатора.
Если бы мы запустили на Python скрипт, выполняющий простую задачу — спать (ну очень времязатратную!), он выглядел бы так:
Получаем результат, который и ожидали:
Рабочий процесс этого скрипта будет выглядеть примерно так:
Зачем нужен Python Global Interpreter Lock и как он работает
Авторизуйтесь
Зачем нужен Python Global Interpreter Lock и как он работает
Python Global Interpreter Lock (GIL) — это своеобразная блокировка, позволяющая только одному потоку управлять интерпретатором Python. Это означает, что в любой момент времени будет выполняться только один конкретный поток.
Работа GIL может казаться несущественной для разработчиков, создающих однопоточные программы. Но во многопоточных программах отсутствие GIL может негативно сказываться на производительности процессоро-зависымых программ.
Поскольку GIL позволяет работать только одному потоку даже в многопоточном приложении, он заработал репутацию «печально известной» функции.
27–28 ноября, Онлайн, Беcплатно
В этой статье будет рассказано о том, как GIL влияет на производительность приложений, и о том, как это самое влияние можно смягчить.
Что за проблему в Python решает GIL?
Python подсчитывает количество ссылок для корректного управления памятью. Это означает, что созданные в Python объекты имеют переменную подсчёта ссылок, в которой хранится количество всех ссылок на этот объект. Как только эта переменная становится равной нулю, память, выделенная под этот объект, освобождается.
Вот небольшой пример кода, демонстрирующий работу переменных подсчёта ссылок:
Проблема, которую решает GIL, связана с тем, что в многопоточном приложении сразу несколько потоков могут увеличивать или уменьшать значения этого счётчика ссылок. Это может привести к тому, что память очистится неправильно и удалится тот объект, на который ещё существует ссылка.
Счётчик ссылок можно защитить, добавив блокираторы на все структуры данных, которые распространяются по нескольким потокам. В таком случае счётчик будет изменяться исключительно последовательно.
Но добавление блокировки к нескольким объектам может привести к появлению другой проблемы — взаимоблокировки (англ. deadlocks), которая получается только если блокировка есть более чем на одном объекте. К тому же эта проблема тоже снижала бы производительность из-за многократной установки блокираторов.
GIL — эта одиночный блокиратор самого интерпретатора Python. Он добавляет правило: любое выполнение байткода в Python требует блокировки интерпретатора. В таком случае можно исключить взаимоблокировку, т. к. GIL будет единственной блокировкой в приложении. К тому же его влияние на производительность процессора совсем не критично. Однако стоит помнить, что GIL уверенно делает любую программу однопоточной.
Несмотря на то, что GIL используется и в других интерпретаторах, например в Ruby, он не является единственным решением этой проблемы. Некоторые языки решают проблему потокобезопасного освобождения памяти с помощью сборки мусора.
С другой стороны это означает, что такие языки часто должны компенсировать потерю однопоточных преимуществ GIL добавлением каких-то дополнительных функций повышения производительности, например JIT-компиляторов.
Почему для решения проблемы был выбран именно GIL?
Итак, почему же это не очень «хорошее» решение используется в Python? Насколько для разработчиков это решение критично?
По словам Larry Hastings, архитектурное решение GIL — это одна из тех вещей, которые сделали Python популярным.
Python существует с тех времён, когда в операционных системах не существовало понятия о потоках. Этот язык разрабатывался в расчёте на лёгкое использование и ускорение процесса разработки. Всё больше и больше разработчиков переходило на Python.
Много расширений, в которых нуждался Python, было написано для уже существующих библиотек на C. Для предотвращения несогласованных изменений, язык C требовал потокобезопасного управления памятью, которое смог предоставить GIL.
GIL можно было легко реализовать и интегрировать в Python. Он увеличивал производительность однопоточных приложений, поскольку управление велось только одним блокиратором.
Те библиотеки на C, которые не были потокобезопасными, стало легче интегрировать. Эти расширения на C стали одной из причин, почему Python-сообщество стало расширяться.
Как можно понять, GIL — фактическое решение проблемы, с которой столкнулись разработчики CPython в начале жизни Python.
Влияние GIL на многопоточные приложения
Если смотреть на типичную программу (не обязательно написанную на Python) — есть разница, ограничена ли эта программа производительностью процессора или же I/O.
Операции, ограниченные производительностью процессора (англ. CPU-bound) — это все вычислительные операции: перемножение матриц, поиск, обработка изображений и т. д.
Операции, ограниченные производительностью I/O (англ. I/O-bound) — это те операции, которые часто находятся в ожидании чего-либо от источников ввода/вывода (пользователь, файл, БД, сеть). Такие программы и операции иногда могут ждать долгое время, пока не получат от источника то, что им нужно. Это связано с тем, что источник может проводить собственные (внутренние) операции, прежде чем он будет готов выдать результат. Например, пользователь может думать над тем, что именно ввести в поисковую строку или же какой запрос отправить в БД.
Ниже приведена простая CPU-bound программа, которая попросту ведёт обратный отсчёт:
Запустив это на 4х-ядерном компьютере получим такой результат:
Ниже приведена та же программа, с небольшим изменением. Теперь обратный отсчёт ведётся в двух параллельных потоках:
Как видно из результатов, оба варианта затратили примерно одинаковое время. В многопоточной версии GIL предотвратил параллельное выполнение потоков.
GIL не сильно влияет на производительность I/O-операций в многопоточных программах, т. к. в процессе ожидания от I/O блокировка распространяется по потокам.
Однако программа, потоки которой будут работать исключительно с процессором (например обработка изображения по частям), из-за блокировки не только станет однопоточной, но и на её выполнение будет затрачиваться больше времени, чем если бы она изначально была строго однопоточной.
Такое увеличение времени — это результат появления и реализации блокировки.
Почему GIL всё ещё используют?
Разработчики языка получили уйму жалоб касательно GIL. Но такой популярный язык как Python не может провести такое радикальное изменение, как удаление GIL, ведь это, естественно, повлечёт за собой кучу проблем несовместимости.
В прошлом разработчиками были предприняты попытки удаления GIL. Но все эти попытки разрушались существующими расширениями на C, которые плотно зависели от существующих GIL-решений. Естественно, есть и другие варианты, схожие с GIL. Однако они либо снижают производительность однопоточных и многопоточных I/O-приложений, либо попросту сложны в реализации. Вам бы не хотелось, чтобы в новых версиях ваша программа работала медленней, чем сейчас, ведь так?
Создатель Python, Guido van Rossum, в сентябре 2007 года высказался по поводу этого в статье «It isn’t Easy to remove the GIL»:
«Я был бы рад патчам в Py3k только в том случае, если бы производительность однопоточных приложений или многопоточных I/O-приложений не уменьшалась.»
С тех пор ни одна из предпринятых попыток не удовлетворяла это условие.
Почему GIL не был удалён в Python 3?
Python 3 на самом деле имел возможность переделки некоторых функций с нуля, хотя из-за этого многие расширения на С попросту сломались бы и их пришлось бы переделывать. Именно из-за этого первые версии Python 3 так слабо расходились по сообществу.
Но почему бы параллельно с обновлением Python 3 не удалить GIL?
Его удаление сделает однопоточность в Python 3 медленней по сравнению с Python 2 и просто представьте, во что это выльется. Нельзя не заметить преимущества однопоточности в GIL. Именно поэтому он всё ещё не удалён.
Но в Python 3 действительно появились улучшения для существующего GIL. До этого момента в статье рассказывалось о влиянии GIL на многопоточные программы, которые затрагивают только процессор или только I/O. А что насчёт тех программ, у которых часть потоков идут на процессор, а часть на I/O?
В таких программах I/O-потоки «страдают» из-за того, что у них нет доступа к GIL от процессорных потоков. Это связано со встроенным в Python механизмом, который принуждал потоки освобождать GIL после определённого интервала непрерывного использования. В случае, если никто другой не используют GIL, эти потоки могли продолжать работу.
Но тут есть одна проблема. Почти всегда GIL занимается процессорными потоками и остальные потоки не успевают занять место. Этот факт был изучен David Beazley, визуализацию этого можно увидеть здесь.
Проблема была решена в Python 3.2 в 2009 разработчиком Antoine Pitrou. Он добавил механизм подсчёта потоков, которые нуждаются в GIL. И если есть другие потоки, нуждающиеся в GIL, текущий поток не занимал бы их место.
Как справиться GIL?
Если GIL у вас вызывает проблемы, вот несколько решений, которые вы можете попробовать:
После запуска получаем такой результат:
Можно заметить приличное повышение производительности по сравнению с многопоточной версией. Однако показатель времени не снизился до половины. Всё из-за того, что управление процессами само по себе сказывается на производительности. Несколько процессов более сложны, чем несколько потоков, поэтому с ними нужно работать аккуратно.
Альтернативные интерпретаторы Python. У Python есть много разных реализаций интерпретаторов. CPython, Jyton, IronPython и PyPy, написанные на C, Java, C# и Python соответственно. GIL существует только на оригинальном интерпретаторе — на CPython.
Вы просто можете использовать преимущества однопоточности, в то время, пока одни из самых ярких умов прямо сейчас работают над устранением GIL из CPython. Вот одна из попыток.
Зачастую, GIL рассматривается как нечто-то сложное и непонятное. Но имейте ввиду, что как python-разработчик, вы столкнётесь с GIL только если будете писать расширения на C или многопоточные процессорные программы.
На этом этапе вы должны понимать все аспекты, необходимые при работе с GIL. Если же вам интересна низкоуровневая структура GIL — посмотрите Understanding the Python GIL от David Beazley.
Глобальная блокировка интерпретатора (GIL) и её воздействие на многопоточность в Python
Как вы, наверное, знаете, глобальная блокировка интерпретатора (GIL, Global Interpreter Lock) — это механизм, обеспечивающий, при использовании интерпретатора CPython, безопасную работу с потоками. Но из-за GIL в конкретный момент времени выполнять байт-код Python может лишь один поток операционной системы. В результате нельзя ускорить Python-код, интенсивно использующий ресурсы процессора, распределив вычислительную нагрузку по нескольким потокам. Негативное влияние GIL на производительность Python-программ, правда, на этом не заканчивается. Так, GIL создаёт дополнительную нагрузку на систему. Это замедляет многопоточные программы и, что выглядит достаточно неожиданно, может даже оказать влияние на потоки, производительность которых ограничена подсистемой ввода/вывода.
Прим. Wunder Fund: в статье рассказано, зачем появилась и существует глобальная блокировка интерпретатора в Питоне, как она работает, и как она влияет на скорость работы Питона, а также о том, куда в будущем, вероятно, будет двигаться Питон. У нас в фонде почти всё, что не написано на плюсах — написано на Питоне, мы пристально следим за тем, куда движется язык, и если вы тоже — вы знаете, что делать )
Здесь я опираюсь на особенности CPython 3.9. По мере развития CPython некоторые детали реализации GIL, определённо, изменятся. Материал опубликован 22 сентября 2021 года, после публикации в него внесено несколько дополнений.
Потоки операционной системы, потоки Python и GIL
Время от времени потоку нужно приостановить исполнение байт-кода. Поток, в начале каждой итерации вычислительного цикла, проверяет, имеются ли какие-нибудь причины для остановки выполнения байт-кода. Нам интересна одна из таких причин, которая заключается в том, что другой поток хочет захватить GIL. Вот как это всё реализовано в коде:
В однопоточной Python-программе главный поток — это ещё и единственный поток. Он никогда не освобождает глобальную блокировку интерпретатора. А что же происходит в многопоточных программах? Воспользуемся стандартным модулем threading для создания нового Python-потока:
Собственно говоря, это — абсолютный минимум сведений, которые нам нужно знать о GIL. А теперь я собираюсь рассказать о том, как GIL влияет на производительность Python-программ. Если то, что вы обнаружите в следующем разделе, покажется вам интересным, вас могут заинтересовать и следующие части этой статьи, где мы подробнее рассмотрим некоторые аспекты GIL.
Последствия существования GIL
Первое последствие существования GIL широко известно: это невозможность параллельного выполнения Python-потоков. А значит — многопоточные программы, даже на многоядерных машинах, работают не быстрее, чем их однопоточные эквиваленты.
Рассмотрим следующую функцию, производительность которой зависит от скорости процессора. Она выполняет операцию декремента переменной заданное количество раз:
Мы, не мудрствуя лукаво, попробуем распараллелить выполнение соответствующего Python-кода.
Представим, что нам нужно выполнить 100,000,000 операций декрементирования переменной. Мы можем запустить countdown(100_000_000) в одном потоке, или countdown(50_000_000) в двух потоках, или countdown(25_000_000) в четырёх потоках и так далее. В языках, где нет GIL, вроде C, мы, увеличивая число потоков, смогли бы наблюдать ускорение вычислений. Я запустил Python-код на своём MacBook Pro. В моём распоряжении были два ядра и технология hyper-threading. Вот что у меня получилось:
Количество потоков
Операций декрементирования на поток (n)
Время в секундах (лучшее из 3 попыток)
Сколько потоков мы не использовали бы, время выполнения вычислений, в сущности, остаётся одним и тем же. На самом деле, многопоточные варианты программы могут оказаться даже медленнее однопоточного из-за дополнительной нагрузки на систему, вызванной операциями переключения контекста. Стандартный интервал переключения составляет 5 мс, в результате переключения контекста выполняются не слишком часто. Но если уменьшить этот интервал, мы увидим замедление многопоточных вариантов программы. Ниже мы поговорим о том, зачем может понадобиться уменьшать интервал переключения.
Хотя использование Python-потоков не может помочь нам в деле ускорения программ, интенсивно использующих ресурсы процессора, потоки могут принести пользу в том случае, когда нужно одновременно выполнять множество операций, производительность которых привязана к подсистеме ввода/вывода. Представим себе сервер, который ожидает входящих подключений и, когда к нему подключается клиентская система, запускает функцию-обработчик в отдельном потоке. Эта функция «общается» с клиентом, считывая данные из клиентского сокета и записывая данные в сокет. При чтении данных функция бездействует до тех пор, пока клиент ей что-нибудь не отправит. Именно в подобных ситуациях многопоточность оказывается очень кстати: пока один поток бездействует, другой может сделать что-то полезное.
Для того чтобы позволить другому потоку выполнить код в то время, когда поток, удерживающий GIL, ожидает выполнения операции ввода/вывода, в CPython все операции ввода/вывода реализованы с использованием следующего паттерна:
Предположим, что нам нужно вычислить хэши SHA-256 для восьми 128-мегабайтных сообщений. Мы можем вызвать hashlib.sha256(message) для каждого сообщения, обойдясь одним потоком, но можно и распределить нагрузку по нескольким потокам. Вот результаты исследования этой задачи, полученные на моём компьютере:
Количество потоков
Общий размер сообщений на поток
Время в секундах (лучшее из 3 попыток)
Переход от одного потока к двум даёт ускорение почти в 2 раза из-за того, что эти два потока работают параллельно. Правда, дальнейшее увеличение числа потоков не особенно сильно улучшает ситуацию, так как на моём компьютере всего два физических процессорных ядра. Тут можно сделать вывод о том, что, прибегнув к многопоточности, можно ускорить Python-код, выполняющий «тяжёлые» вычисления, в том случае, если в этом коде осуществляется вызов C-функций, которые освобождают GIL. Обратите внимание на то, что подобные функции можно обнаружить не только в стандартной библиотеке, но и в модулях сторонних разработчиков, рассчитанных на серьёзные вычисления, вроде NumPy. Можно даже самостоятельно писать C-расширения, освобождающие GIL.
Мы упоминали о потоках, скорость работы которых привязана к производительности CPU, то есть — о потоках, которые, большую часть времени, заняты некими вычислениями. Мы говорили и о потоках, производительность которых ограничена подсистемой ввода/вывода — о тех, которые большую часть времени заняты ожиданием операций ввода/вывода. Самые интересные последствия существования GIL появляются при смешанном использовании и тех и других потоков. Рассмотрим простой эхо-сервер TCP, который ожидает входящих подключений. Когда к нему подключается клиент — он запускает новый поток для работы с этим клиентом:
Сколько запросов в секунду «потянет» этот сервер? Я написал простую программу-клиент, которая, настолько быстро, насколько это возможно, отправляет серверу 1-байтовые сообщения и принимает их от него. У меня получилось что-то около 30 тысяч запросов в секунду (RPS, Requests Per Second). Это, скорее всего, не особенно надёжный результат, так как и сервер, и клиент работали на одном и том же компьютере. Но тут к надёжности этого результата я и не стремился. А интересовало меня то, как упадёт RPS в том случае, если сервер будет, во время обработки запросов клиентов, выполнять в отдельном потоке какую-нибудь серьёзную вычислительную задачу.
Рассмотрим тот же самый серверный код, к которому теперь добавлен код, запускающий дополнительный поток, устроенный довольно примитивно. Код, выполняемый в этом потоке, инкрементирует и декрементирует переменную в бесконечном цикле (при выполнении любого кода, интенсивно использующего ресурсы процессора, в сущности, происходит то же самое):
Как думаете — насколько сильно изменится RPS? Упадёт лишь немного? Или, может, снизится в 2 раза? А может — в 10? Нет. Показатель RPS упал до 100, что в 300 раз меньше первоначального показателя. И это крайне удивительно для того, кто привык к тому, как операционная система планирует выполнение потоков. Для того чтобы проиллюстрировать то, что я имею в виду, давайте запустим код сервера и код потока, выполняющего вычисления, в виде отдельных процессов, что приведёт к тому, что на них не будет действовать GIL. Можно разделить код на два отдельных файла, или просто воспользоваться стандартным модулем multiprocessing для создания новых процессов. Например, это может выглядеть так:
Этот код выдаёт около 20 тысяч RPS. Более того, если запустить два, три или четыре процесса, интенсивно использующих процессор, RPS почти не меняется. Планировщик ОС отдаёт приоритет процессам, производительность которых привязана к подсистеме ввода/вывода. И это правильно.
В нашем примере серверного кода поток, привязанный к подсистеме ввода/вывода, ожидает, когда сокет будет готов к чтению и записи, но производительность любого другого подобного потока будет ухудшаться по тому же сценарию. Представим себе поток, отвечающий за работу пользовательского интерфейса, который ожидает пользовательского ввода. Он, если рядом с ним запустить поток, интенсивно использующий процессор, будет регулярно «подвисать». Ясно, что обычные потоки операционной системы работают не так, и что причиной этого является GIL. Глобальная блокировка интерпретатора мешает планировщику ОС.
Разработчики CPython, на самом деле, хорошо осведомлены об этой проблеме. Они называют её «эффектом сопровождения» (convoy effect). Дэвид Бизли сделал об этом доклад в 2010 году и открыл обращение о проблеме на bugs.python.org. Через 11 лет, в 2021 году, это обращение было закрыто. Но проблема так и не была исправлена. Далее мы попытаемся разобраться с тем, почему это так.
Эффект сопровождения
Операционная система может запланировать выполнение потока, привязанного к возможностям CPU, сразу же после того, как поток, привязанный к вводу/выводу, освободит GIL. А выполнение потока, зависящего от подсистемы ввода/вывода, может быть запланировано только после завершения операции ввода/вывода, поэтому у него меньше шансов первым захватить GIL. Если операция ввода/вывода является по-настоящему быстрой, скажем — это неблокирующая команда send(), то шансы потока на захват GIL, на самом деле, довольно-таки высоки, но только на одноядерном компьютере, где ОС нужно принимать решения о том, выполнение какого потока ей запланировать.
На многоядерных компьютерах ОС не нужно принимать решения о том, выполнение какого из этих двух потоков требуется запланировать. Она может запланировать выполнение обоих этих потоков на разных ядрах. В результате окажется, что поток, производительность которого привязана к CPU, почти гарантированно, первым захватит GIL, а на проведение каждой операции ввода/вывода, выполняемой в потоке, привязанном к подсистеме ввода/вывода, будет необходимо 5 дополнительных миллисекунд.
Обратите внимание на то, что поток, который принуждают освободить GIL, ждёт до того момента, пока другой поток не захватит блокировку. В результате поток, привязанный к подсистеме ввода/вывода, захватывает GIL после одного интервала переключения. Если бы этого механизма не существовало, последствия эффекта сопровождения были бы ещё хуже.
А 5 мс — много это или мало? Это зависит от того, сколько времени занимают операции ввода/вывода. Если поток несколько секунд ждёт появления в сокете данных, которые можно прочитать, то дополнительные 5 мс особой роли не сыграют. Но некоторые операции ввода/вывода выполняются очень и очень быстро. Например, команда send() выполняет блокировку только тогда, когда буфер отправки полон, а в противном случае осуществляется немедленный возврат из неё. В результате если выполнение операций ввода/вывода занимает микросекунды, это значит, что миллисекунды ожидания GIL могут оказать огромное влияние на производительность программы.
Наш эхо-сервер без потока, сильно нагружающего процессор, способен обработать 30 тысяч запросов в секунду. Это значит, что обработка одного запроса занимает примерно 1/30000 = 30 мкс. А если речь идёт о сервере с потоком, привязанным к производительности процессора, команды recv() и send() добавляют, каждая, по 5 мс (5000 мкс) к времени обработки каждого запроса. Теперь на выполнение одного запроса требуется 10030 мкс. Это — примерно в 300 раз больше, чем в первом случае. В результате пропускная способность сервера падает в 300 раз. Как видите, эти цифры совпадают.
Тут можно задаться вопросом о том, приводит ли наличие эффекта сопровождения к проблемам в реальных приложениях. Ответа на этот вопрос я не знаю. Я никогда с подобными проблемами не сталкивался и не встречал свидетельств того, что с ними сталкивался кто-то ещё. Никто на это не жалуется, и это — одна из причин, по которой данная проблема до сих пор не исправлена.
Но что если эффект сопровождения вызывает проблемы с производительностью вашего приложения? Есть два способа исправления этих проблем.
Устранение последствий эффекта сопровождения
Интервал переключения в секундах
RPS без CPU-потоков
RPS с одним CPU-потоком
RPS с двумя CPU-потоками
RPS с четырьмя CPU-потоками







