Зачем нужен Python Global Interpreter Lock и как он работает
Авторизуйтесь
Зачем нужен Python Global Interpreter Lock и как он работает
Python Global Interpreter Lock (GIL) — это своеобразная блокировка, позволяющая только одному потоку управлять интерпретатором Python. Это означает, что в любой момент времени будет выполняться только один конкретный поток.
Работа GIL может казаться несущественной для разработчиков, создающих однопоточные программы. Но во многопоточных программах отсутствие GIL может негативно сказываться на производительности процессоро-зависымых программ.
Поскольку GIL позволяет работать только одному потоку даже в многопоточном приложении, он заработал репутацию «печально известной» функции.
В этой статье будет рассказано о том, как 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 вводит накладные расходы, которые замедляют работу многопоточных программ, и это может повлиять даже на потоки, связанные с вводом-выводом.
В этом посте я хотел бы рассказать вам больше о неочевидных эффектах GIL. По пути мы обсудим, что такое GIL на самом деле, почему он существует, как он работает и как он повлияет на параллелизм в будущих реализациях Python.
Примечание: В этом посте рассматривается CPython версии 3.9.
Потоки ОС, потоки Python и GIL
Когда вы запускаете python-исполняемый файл, ОС запускает новый процесс с одним потоком выполнения, называемым основным потоком. Как и в случае любой другой программы на языке Си, основной поток начинает выполнение с main() функции. Все, что делает основной поток дальше, можно свести к трем шагам:
Компилирует код Python в байт-код;
Запускает цикл выполнения байт-кода.
Основной поток-это обычный поток операционной системы, который выполняет скомпилированный код на языке Си. Его состояние включает значения регистров процессора и стек вызовов функций языка C. Однако поток Python должен захватывать стек вызовов функций Python, состояние исключений и другие вещи, связанные с Python. Итак, что делает CPython, так это помещает эти вещи в структуру состояния потока и связывает состояние потока с потоком ОС. Другими словами, Python thread = OS thread + Python thread state.
Время от времени поток должен приостанавливать исполнение байт-кода. Он проверяет, есть ли какие-либо причины для этого в начале каждой итерации цикла. Нас интересует одна из таких причин: другой поток запросил GIL. Вот как эта логика реализована в коде:
Это самый минимум того, что нам нужно знать о GIL. Позвольте мне теперь продемонстрировать его эффекты, о которых я говорил ранее.
Эффекты GIL
Первый эффект GIL хорошо известен: несколько потоков Python не могут работать параллельно. Таким образом, многопоточная программа не будет быстрее, чем ее однопоточный эквивалент, даже на многоядерной машине. В качестве наивной попытки распараллелить код Python рассмотрим следующую CPU-bound функцию, которая выполняет операцию уменьшения заданное количество раз:
Теперь предположим, что мы хотим выполнить 100 000 000 декрементов. Мы можем запустить countdown(100_000_000) в одном потоке или countdown(50_000_000) в двух потоках, или countdown(25_000_000) в четырех потоках, и так далее. В языке без GIL, таком как C, мы бы увидели ускорение по мере увеличения количества потоков. Запустив Python на своем MacBook Pro с двумя ядрами и гиперпоточностью, я вижу следующее:
Количество потоков
Декрементов на поток (n)
Время в секундах (лучшее из 3)
Время не меняется. На самом деле многопоточные программы могут работать медленнее из-за накладных расходов, связанных с переключением контекста. Интервал переключения по умолчанию составляет 5 мс, поэтому переключение контекста происходит не так часто. Но если мы уменьшим интервал переключения, мы увидим замедление.
Хотя потоки Python не могут помочь нам ускорить код с интенсивным использованием процессора, они полезны, когда мы хотим выполнять несколько задач ввода-вывода одновременно. Рассмотрим сервер, который прослушивает входящие соединения и, когда он получает соединение, запускает функцию обработчика в отдельном потоке. Функция обработчика взаимодействует с клиентом путем чтения и записи в сокет клиента. При чтении из сокета поток просто зависает, пока клиент что-то не отправит. Вот где помогает многопоточность: тем временем может работать другой поток.
Чтобы разрешить выполнение других потоков, пока поток, удерживающий GIL, ожидает ввода-вывода, CPython реализует все операции ввода-вывода, используя следующий шаблон:
Таким образом, поток может добровольно освободить GIL до того, как другой поток установит eval_breaker и gil_drop_request. В общем случае поток может удерживать GIL только во время работы с объектами Python. Таким образом, CPython применяет шаблон release-perform-acquire не только к операциям ввода-вывода, но и к другим блокирующим вызовам в ОС, таким как select() и pthread_mutex_lock (), а также к тяжелым вычислениям в чистом C. Например, хэш-функции в стандартном модуле hashlib освобождают GIL. Это позволяет нам ускорить код Python, который вызывает такие функции, используя многопоточность.
Предположим, мы хотим вычислить хэши SHA-256 из восьми сообщений объемом 128 МБ. Мы можем вычислять hashlib.sha256(message) для каждого сообщения в одном потоке, но мы также можем распределить работу между несколькими потоками. Если я проведу сравнение на своей машине, я получу следующие результаты:
Количество потоков
Общий размер сообщений в потоке
Время в секундах (лучшее из 3)
Переход от одного потока к двум почти в 2 раза ускоряет выполнение, потому что потоки выполняются параллельно. Добавление дополнительных потоков не сильно помогает, потому что на моей машине всего два физических ядра. Вывод здесь заключается в том, что можно ускорить процессорно-интенсивный код Python с помощью многопоточности, если код вызывает функции C, которые освобождают GIL. Обратите внимание, что такие функции можно найти не только в стандартной библиотеке, но и в мощных сторонних модулях, таких как NumPy. Вы даже можете самостоятельно написать расширение C, которое выпустит GIL.
Сколько запросов в секунду может обрабатывать такой сервер? Я написал простую клиентскую программу, которая просто отправляет и получает 1-байтовые сообщения на сервер так быстро, как только может, и получил что-то около 30 тысяч RPS. Это, скорее всего, неточное число, поскольку клиент и сервер работают на одной машине, но суть не в этом. Смысл в том, чтобы увидеть, как RPS падает, когда сервер выполняет какую-либо CPU-bound задачу в отдельном потоке.
Рассмотрим точно такой же сервер, но с дополнительным потоком, который увеличивает и уменьшает переменную в бесконечном цикле (фактически, любая задача, связанная с процессором, будет делать что-то похожее):
Насколько, как вы думаете, изменится RPS? Слегка? Станет в 2 раза меньше? В 10 раз меньше? Нет. RPS падает до 100, что в 300 раз меньше! И это станет сюрпризом, если вы привыкли к тому, как операционные системы управляют потоками. Чтобы понять, что я имею в виду, давайте запустим сервер и CPU-bound поток, как отдельные процессы, чтобы на них не влиял GIL. Мы можем разделить код на два разных файла или просто использовать стандартный модуль multiprocessing для создания нового процесса. Например:
И это дает около 20 тысяч RPS. Более того, если мы запустим два, три или четыре CPU-bound процесса, RPS останется примерно таким же. Планировщик ОС определяет приоритет I/O потока, что является правильным в данном случае.
В примере с сервером, I/O поток ожидает, пока сокет будет готов для чтения и записи, и производительность любого другого I/O потока будет снажаться соответственно. Рассмотрим поток пользовательского интерфейса, который ожидает ввода данных пользователем. Он будет регулярно зависать, если вы запустите его вместе с потоком, связанным с процессором. Очевидно, что это не похоже на то, как работают обычные потоки ОС, и причина в GIL. Это мешает работе планировщика операционной системы.
Эта проблема на самом деле хорошо известна среди разработчиков CPython. Они называют это эффектом конвоя. Дэвид Бизли выступил с докладом об этом в 2010 году, а также открыл соответствующий выпуск на bugs.python.org. В 2021 году, спустя 11 лет, этот вопрос был закрыт. Однако это не было исправлено. В остальной части этого поста мы попытаемся выяснить, почему.
Эффект конвоя
Эффект конвоя происходит потому, что каждый раз, когда поток, связанный с вводом-выводом, выполняет операцию ввода-вывода, он освобождает GIL, и когда он пытается повторно получить GIL, то GIL, скорее всего, уже будет занят потоком, связанным с процессором. Таким образом, поток, связанный с вводом-выводом, должен подождать не менее 5 мс, прежде чем он сможет установить eval_breaker и gil_drop_request и заставить поток, связанный с процессором, освободить GIL.
На многоядерной машине ОС не нужно решать, какой из двух потоков запланировать. Он может планировать запуск и одного, и второго на разных ядрах. В результате поток, связанный с процессором, почти гарантированно получит GIL первым, и каждая операция ввода-вывода в I/O потоке стоит дополнительных 5 мс.
Обратите внимание, что поток, который вынужден освободить GIL, ждет, пока его не примет другой поток, поэтому I/O поток получает GIL после одного интервала переключения. Без этой логики эффект конвоя был бы еще более серьезным.
Итак, сколько стоит 5 мс? Это зависит от того, сколько времени занимают операции ввода-вывода. Если поток ожидает несколько секунд, пока данные в сокете не станут доступны для чтения, дополнительные 5 мс не имеют большого значения. Но некоторые операции ввода-вывода выполняются очень быстро. Например, send() блокируется только тогда, когда буфер отправки заполнен, и возвращается немедленно в противном случае. Поэтому, если операции ввода-вывода занимают микросекунды, то миллисекунды ожидания GIL могут оказать огромное влияние.
Эхо-сервер без CPU-bound потока обрабатывает 30 кб/с, что означает, что один запрос занимает около 1/30 кб ≈ 30 мкс. С CPU-bound потоком recv(), и send() добавляют дополнительные 5 мс = 5000 мкс к каждому запросу, и теперь один запрос занимает 10 030 мкс. Это примерно в 300 раз больше. Таким образом, пропускная способность в 300 раз меньше. Цифры совпадают.
Вы можете спросить: является ли эффект конвоя проблемой в реальных приложениях? Я не знаю. Я никогда не сталкивался с этим и не мог найти доказательств того, что это сделал кто-то другой. Люди не жалуются, и это одна из причин, по которой проблема не была устранена.
Но что, если эффект конвоя действительно вызывает проблемы с производительностью в вашем приложении? Вот два способа исправить это.
Исправление эффекта конвоя
Поскольку проблема заключается в том, что поток, связанный с вводом-выводом, ожидает интервала переключения, пока не запросит GIL, мы можем попытаться установить интервал переключения на меньшее значение. Python предоставляет функцию sys.setswitchinterval(interval) для этой цели. Аргумент interval представляет собой значение с плавающей точкой, представляющее секунды. Интервал переключения измеряется в микросекундах, поэтому наименьшее значение равно 0.000001. Вот RPS, который я получаю, если я изменяю интервал переключения и количество CPU-bound потоков:
Интервал переключения в секундах
RPS без потоков процессора
RPS с одним потоком
RPS с двумя CPU-bound потоками
RPS с четырьмя CPU-bound потоками
Как устроен 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, которые было бы хорошо перевести. У меня есть на примете пара статей, но хочется еще вариантов.




