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.
Цикл выполнения байт-кода — это бесконечный цикл, который содержит гигантский switch всех возможных инструкций байт-кода. Чтобы запустить цикл, поток должен удерживать GIL. Основной поток получает GIL во время инициализации. Когда он входит в цикл, то просто начинает выполнять инструкции байт-кода одну за другой.
Время от времени поток должен приостанавливать исполнение байт-кода. Он проверяет, есть ли какие-либо причины для этого в начале каждой итерации цикла. Нас интересует одна из таких причин: другой поток запросил GIL. Вот как эта логика реализована в коде:
PyObject* _PyEval_EvalFrameDefault(PyThreadState *tstate, PyFrameObject *f, int throwflag) < // . declaration of local variables and other boring stuff // the evaluation loop for (;;) < // `eval_breaker` tells whether we should suspend bytecode execution // e.g. other thread requested the GIL if (_Py_atomic_load_relaxed(eval_breaker)) < // `eval_frame_handle_pending()` suspends bytecode execution // e.g. when another thread requests the GIL, // this function drops the GIL and waits for the GIL again if (eval_frame_handle_pending(tstate) != 0) < goto error; >> // get next bytecode instruction NEXTOPARG(); switch (opcode) < case TARGET(NOP) < FAST_DISPATCH(); // next iteration >case TARGET(LOAD_FAST) < // . code for loading local variable FAST_DISPATCH(); // next iteration >// . 117 more cases for every possible opcode > // . error handling > // . termination >
В однопоточной программе на Python основной поток является единственным потоком, и он никогда не выпускает GIL. Давайте теперь посмотрим, что происходит в многопоточной программе. Мы используем threading — стандартный модуль для запуска нового потока Python:
import threading def f(a, b, c): # do something pass t = threading.Thread(target=f, args=(1, 2), kwargs=) t.start()
Метод start() экземпляра Thread создает новый поток ОС. В Unix-подобных системах, включая Linux и macOS, для этой цели он вызывает функцию pthread_create (). Вновь созданный поток начинает выполнение функции t_bootstrap() с аргументом boot. Аргумент boot представляет собой структуру, содержащую целевую функцию, переданные аргументы и состояние потока для нового потока ОС. Функция t_bootstrap() выполняет ряд действий, но самое главное — она получает GIL, после чего запускает цикл выполнения байт-кода целевой функции.
Чтобы получить GIL, поток сначала проверяет, захвачен ли GIL каким-либо другим потоком. Если это не так, поток немедленно получает GIL. В противном случае он ждет, пока GIL не будет освобожден. Он ожидает фиксированного интервала времени, называемого интервалом переключения (по умолчанию 5 мс), и если GIL не будет выпущен в течение этого времени, он устанавливает флаги eval_breaker и gil_drop_request. Флаг eval_breaker указывает потоку, удерживающему GIL, приостановить выполнение байт-кода, а gil_drop_request объясняет, почему. Поток, удерживающий GIL, видит флаги, когда при запуске следующей итерации цикла, и освобождает GIL. Он уведомляет об этом потоки, ожидающие GIL, и один из них захватывает GIL. Какой именно поток получит GIL — зависит от операционной системы, так что это может быть поток, который установил флаги, а может быть и другой поток, также ожидающий GIL.
Это самый минимум того, что нам нужно знать о GIL. Позвольте мне теперь продемонстрировать его эффекты, о которых я говорил ранее.
Эффекты GIL
Первый эффект GIL хорошо известен: несколько потоков Python не могут работать параллельно. Таким образом, многопоточная программа не будет быстрее, чем ее однопоточный эквивалент, даже на многоядерной машине. В качестве наивной попытки распараллелить код Python рассмотрим следующую CPU-bound функцию, которая выполняет операцию уменьшения заданное количество раз:
def countdown(n): while n > 0: n -= 1
Теперь предположим, что мы хотим выполнить 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;
- выполните операцию, например write(), recv(), accept();
- захватить GIL.
Таким образом, поток может добровольно освободить 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.
Мы упоминали потоки CPU-bound — потоки, которые большую часть времени что–то вычисляют. И потоки I/O – потоки, которые большую часть времени ожидают ввода-вывода. Самый интересный эффект GIL имеет место, когда мы смешиваем их. Рассмотрим простой TCP эхо-сервер, который прослушивает входящие соединения и, когда клиент подключается, создает новый поток для обработки клиента:
from threading import Thread import socket def run_server(host='127.0.0.1', port=33333): sock = socket.socket() sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind((host, port)) sock.listen() while True: client_sock, addr = sock.accept() print('Connection from', addr) Thread(target=handle_client, args=(client_sock,)).start() def handle_client(sock): while True: received_data = sock.recv(4096) if not received_data: break sock.sendall(received_data) print('Client disconnected:', sock.getpeername()) sock.close() if __name__ == '__main__': run_server()
Сколько запросов в секунду может обрабатывать такой сервер? Я написал простую клиентскую программу, которая просто отправляет и получает 1-байтовые сообщения на сервер так быстро, как только может, и получил что-то около 30 тысяч RPS. Это, скорее всего, неточное число, поскольку клиент и сервер работают на одной машине, но суть не в этом. Смысл в том, чтобы увидеть, как RPS падает, когда сервер выполняет какую-либо CPU-bound задачу в отдельном потоке.
Рассмотрим точно такой же сервер, но с дополнительным потоком, который увеличивает и уменьшает переменную в бесконечном цикле (фактически, любая задача, связанная с процессором, будет делать что-то похожее):
# . the same server code def compute(): n = 0 while True: n += 1 n -= 1 if __name__ == '__main__': Thread(target=compute).start() run_server()
Насколько, как вы думаете, изменится RPS? Слегка? Станет в 2 раза меньше? В 10 раз меньше? Нет. RPS падает до 100, что в 300 раз меньше! И это станет сюрпризом, если вы привыкли к тому, как операционные системы управляют потоками. Чтобы понять, что я имею в виду, давайте запустим сервер и CPU-bound поток, как отдельные процессы, чтобы на них не влиял GIL. Мы можем разделить код на два разных файла или просто использовать стандартный модуль multiprocessing для создания нового процесса. Например:
from multiprocessing import Process # . the same server code if __name__ == '__main__': Process(target=compute).start() run_server()
И это дает около 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.
ОС может запланировать запуск CPU-bound потока, как только I/O поток выпустит GIL. Поток I/O может быть запланирован только после завершения операции ввода-вывода, поэтому у него меньше шансов захватить GIL первым. Если операция действительно быстрая , например, неблокирующий send(), шансы на самом деле довольно высоки, но только на одноядерной машине, где ОС должна решить, какой поток запустить.
На многоядерной машине ОС не нужно решать, какой из двух потоков запланировать. Он может планировать запуск и одного, и второго на разных ядрах. В результате поток, связанный с процессором, почти гарантированно получит 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 потоками
0.005
30,000
100
50
30
Результаты показывают несколько вещей:
- Интервал переключения не имеет значения, если поток, связанный с вводом-выводом, является единственным потоком.
- По мере добавления одного потока, связанного с процессором, скорость передачи данных значительно снижается.
- По мере того как мы удваиваем количество потоков, связанных с процессором, RPS уменьшается вдвое.
- По мере уменьшения интервала переключения, RPS увеличивается почти пропорционально, пока интервал переключения не станет слишком маленьким. Это связано с тем, что стоимость переключения контекста становится значительной.
Меньшие интервалы переключения делают потоки, связанные с вводом-выводом, более отзывчивыми. Но слишком малые интервалы переключения приводят к большим накладным расходам, вызванным большим количеством переключений контекста. Вспомните функцию countdown(). Мы увидели, что мы не можем ускорить её с помощью нескольких потоков. Если мы установим слишком малый интервал переключения, то также увидим замедление:
Интервал переключения в секундах
Время в секундах (потоки: 1)
Время в секундах (потоки: 2)
Время в секундах (потоки: 4)
Время в секундах (потоки: 8)
0.005
6.53
6.58
7.20
7.19
Опять же, интервал переключения не имеет значения, если есть только один поток. Кроме того, количество потоков не имеет значения, если интервал переключения достаточно ли велик.
Вывод состоит в том, что изменение интервала переключения — это вариант устранения эффекта конвоя. Но нужно быть осторожными и измерить, как это изменение повлияет на ваше приложение.
Второй способ исправить эффект конвоя еще более банален. Поскольку проблема гораздо менее серьезна на одноядерных машинах, мы могли бы попытаться ограничить все потоки Python одним ядром. Это вынудило бы ОС выбрать, какой поток запланировать, и поток, связанный с вводом-выводом, имел бы приоритет.
Не каждая ОС предоставляет способ ограничить группу потоков определенными ядрами. Насколько я понимаю, macOS предоставляет только механизм для предоставления подсказок планировщику ОС. Механизм, который нам нужен, доступен в Linux. Это функция pthread_setaffinity_np(). Она принимает поток и маску ядер процессора, и сообщает ОС запланировать поток только на ядрах, указанных в маске.
pthread_setaffinity_np() является функцией C. Чтобы вызвать её из Python вы можете использовать что-то вроде ctypes. Я не хотел связываться ctypes, поэтому я просто изменил исходный код CPython. Затем я скомпилировал исполняемый файл, запустил эхо-сервер на двухъядерной машине Ubuntu и получил следующие результаты:
Количество CPU-bound потоков
0
1
2
4
8
Сервер может довольно хорошо переносить один поток, связанный с процессором. Но поскольку поток, связанный с вводом-выводом, должен конкурировать за GIL со всеми потоками, связанными с процессором, по мере добавления новых потоков производительность значительно падает. Это исправление больше похоже на взлом. Почему разработчики CPython просто не реализуют правильный GIL?
Обновление от 7 октября 2021 года: теперь я узнал, что ограничение потоков одним ядром помогает с эффектом конвоя только в том случае, если клиент ограничен одним и тем же ядром — именно так, как я настроил тест.
Правильный GIL
Основная проблема с GIL заключается в том, что он мешает работе планировщика ОС. В идеале мы хотели бы запустить поток, связанный с вводом-выводом, как только завершится ожидаемая операция ввода-вывода. И это то, что обычно делает планировщик ОС. Однако в CPython поток немедленно застревает в ожидании GIL, поэтому решение планировщика ОС на самом деле ничего не значит. Вы можете попытаться избавиться от интервала переключения, чтобы поток, которому нужен GIL, получал его без задержки, но тогда у вас возникают проблемы с потоками, связанными с процессором, потому что они все время хотят GIL.
Правильное решение состоит в том, чтобы различать потоки. Поток I/O должен иметь возможность отобрать GIL у CPU-bound потока без ожидания, но потоки с одинаковым приоритетом должны ждать друг друга. Планировщик ОС умеет различать потоки, но вы не можете полагаться на него, потому что он ничего не знает о GIL. Похоже, что единственный вариант — реализовать логику планирования в интерпретаторе.
После того, как Дэвид Бизли открыл проблему, разработчики CPython предприняли несколько попыток ее решить. Сам Бизли предложил простой патч. Короче говоря, этот патч позволяет потоку, связанному с вводом-выводом, вытеснять поток, связанный с процессором. По умолчанию все потоки считаются связанными с вводом-выводом. Как только поток вынужден освободить GIL, он помечается как связанный с процессором. Когда поток добровольно освобождает GIL, флаг сбрасывается, и поток снова считается связанным вводом-выводом.
Патч Бизли решил все проблемы с GIL, которые мы обсуждали сегодня. Почему он не был применён? По-видимому, существует консенсус, что любая простая реализация GIL потерпит неудачу в некоторых патологических случаях. По видимому, придется приложить немного больше усилий, чтобы найти их. Правильное решение должно выполнять планирование — как ОС. Или, как выразился Nir Aides:
. Python действительно нуждается в планировщике, а не в блокировке.
Aides реализовал полноценный планировщик в своем патче. Патч сработал, но планировщик никогда не бывает тривиальной вещью, поэтому объединение его с CPython потребовало больших усилий. В конце концов работа была прекращена, потому что в то время не было достаточных доказательств того, что это решение не вызвало проблемы в производственном коде. Более подробную информацию смотрите в обсуждении.
У GIL никогда не было большой фанатской базы. То, о чём мы говорили сегодня, только усугубляет ситуацию. Мы возвращаемся к вечным вопросам.
Разве мы не можем убрать GIL?
Первый шаг к удалению GIL-понять, почему он существует. Подумайте, почему вы обычно используете блокировки в многопоточной программе, и вы получите ответ. Это делается для предотвращения условий гонки и делает определенные операции атомарными с точки зрения других потоков. Допустим, у вас есть последовательность операторов, которая изменяет некоторую структуру данных. Если вы не окружите последовательность блокировкой, то другой поток может получить доступ к структуре данных где-то в середине производимых изменений, и получить неверное представление.
Или, скажем, вы увеличиваете одну и ту же переменную из нескольких потоков. Если операция приращения не является атомарной и не защищена блокировкой, то конечное значение переменной может быть меньше общего числа приращений. Это типичная гонка данных:
- Поток 1 считывает значение x.
- Поток 2 считывает значение x.
- Поток 1 записывает обратно значение x + 1.
- Поток 2 записывает обратно значение x + 1, тем самым отбрасывая изменения, внесенные потоком 1.
В Python операция += не является атомарной, поскольку она состоит из нескольких инструкций байт-кода. Чтобы увидеть, как это может привести к скачкам данных, установите интервал переключения равным 0.000001 и запустите следующую функцию в нескольких потоках:
sum = 0 def f(): global sum for _ in range(1000): sum += 1
Аналогично, в C увеличение целого числа подобно x++ или ++x не является атомарным, поскольку компилятор преобразует такие операции в последовательность машинных инструкций. Потоки могут чередоваться между ними.
GIL полезен, потому что CPython увеличивает и уменьшает целые числа, которые могут быть разделены между потоками повсюду. Это способ CPython для сборки мусора. Каждый объект Python имеет счётчик ссылок, в котором подсчитывается количество других объектов, ссылающихся на этот объект. Когда количество ссылок достигает нуля, объект удаляется. Если бы не GIL, некоторые декременты могли бы перезаписать друг друга, и объект остался бы в памяти навсегда. Что еще хуже, несогласованные приращения счётчика ссылок могут привести к удалению ещё использующегося объекта.
GIL также упрощает реализацию встроенных изменяемых структур данных. Списки, словари и множества не используют блокировку, но благодаря GIL их можно безопасно использовать в многопоточных программах. Аналогично, GIL позволяет потокам безопасно получать доступ к глобальным данным и данным в масштабах интерпретатора: загруженным модулям, предварительно распределенным объектам и так далее.
Наконец, GIL упрощает написание расширений C. Разработчики могут предположить, что только один поток запускает их расширение C в любой момент времени. Таким образом, им не нужно использовать дополнительную блокировку, чтобы сделать код потокобезопасным. Когда они захотят запустить код параллельно, они могут отпустить GIL.
Подводя итог, можно сказать, что GIL делает следующее потокобезопасным:
- подсчет ссылок;
- изменяемые структуры данных;
- глобальные и общесистемные данные;
- Расширения C.
Чтобы удалить GIL и при этом иметь работающий интерпретатор, вам необходимо найти альтернативные механизмы обеспечения потокобезопасности. Люди пытались сделать это в прошлом. Наиболее заметной попыткой был проект Ларри Хастингса Gilectomy, начатый в 2016 году. Гастингс сделал новую ветку от CPython, удалил GIL, изменил подсчет ссылок, чтобы использовать атомарные приращения и уменьшения, и установил множество мелкозернистых блокировок для защиты изменяемых структур данных и данных на уровне интерпретатора.
Gilectomy может запустить код на Python и запустить его параллельно. Однако однопоточная производительность CPython была поставлена под угрозу. Только атомарные приращения и уменьшения добавляли около 30% накладных расходов. Гастингс попытался решить эту проблему, реализовав буферизованный подсчет ссылок. Короче говоря, этот метод ограничивает все обновления количества ссылок одним специальным потоком. Другие потоки фиксируют только приращения и уменьшения в журнале, а специальный поток считывает журнал. Это сработало, но накладные расходы все равно были значительными.
В конце концов, стало очевидно, что Gilectomy не будет объединена с CPython. Гастингс прекратил работу над проектом. Однако это не было полным провалом. Это научило нас, почему трудно удалить GIL из CPython. Есть две основные причины:
- Сборка мусора на основе подсчета ссылок не подходит для многопоточности. Единственным решением является реализация трассирующего сборщика мусора, который реализуют JVM, CLR, Go и другие среды выполнения без GIL.
- Удаление GIL затрагивает существующие расширения на C. Нет никакого способа обойти это.
В наши дни никто всерьез не думает о том, чтобы убрать ГИЛ. Означает ли это, что мы должны жить с GIL вечно?
Будущее GIL и параллелизма Python
Это звучит пугающе, но гораздо более вероятно, что у CPython будет несколько GIL, чем вообще никакого GIL. Существует инициатива по внедрению нескольких GIL в CPython. Это называется “под-интерпретаторами”. Идея состоит в том, чтобы иметь несколько интерпретаторов в рамках одного и того же процесса. Потоки в одном интерпретаторе по-прежнему совместно используют GIL, но несколько интерпретаторов могут работать параллельно. Для синхронизации интерпретаторов не требуется GIL, поскольку у них нет общего глобального состояния и они не используют общие объекты Python. Все глобальное состояние создается для каждого интерпретатора, а интерпретаторы общаются только посредством передачи сообщений. Конечная цель состоит в том, чтобы представить в Python модель параллелизма, основанную на связывании последовательных процессов, найденных в таких языках, как Go и Clojure.
Интерпретаторы являются частью CPython начиная с версии 1.5, но только в качестве механизма изоляции. Они хранят данные, относящиеся к группе потоков: загруженные модули, встроенные модули, параметры импорта и так далее. Они не представлены в Python, но расширения на C могут использовать их через Python/C API. Некоторые действительно делают это. mod_wsgi является ярким примером.
Сегодняшние интерпретаторы ограничены тем фактом, что им приходится делить между собой GIL. Это может измениться только тогда, когда все глобальное состояние будет выполнено для каждого интерпретатора. Работа ведется в этом направлении, но несколько вещей остаются глобальными: некоторые встроенные типы, None, True и False, части распределителя памяти. Расширения на C также должны избавиться от глобального состояния, прежде чем они смогут работать с под-интерпретаторами.
Эрик Сноу написал PEP 554, который добавляет модуль interpreters в стандартную библиотеку. Идея состоит в том, чтобы предоставить существующим интерпретаторам C API к Python и предоставить механизмы связи между интерпретаторами. Предложение было нацелено на Python 3.9, но было отложено до тех пор, пока GIL не будет соответствующе модифицирован. Даже в этом случае успех не гарантирован. Вопрос заключается в том, действительно ли Python нуждается в другой модели параллелизма.
Еще один захватывающий проект, который реализуется в настоящее время, — это более быстрый CPython. В октябре 2020 года Марк Шеннон предложил план по ускорению CPython в 5 раз за несколько лет. И на самом деле это гораздо более реалистично, чем может показаться, потому что CPython обладает большим потенциалом для оптимизации. Добавление JIT само по себе может привести к огромному повышению производительности.
Подобные проекты были и раньше, но они провалились из-за отсутствия надлежащего финансирования или опыта. На этот раз Microsoft вызвалась спонсировать более быстрый CPython и позволила Марку Шеннону, Гвидо ван Россуму и Эрику Сноу работать над проектом и соответствующие изменения уже реализуются в CPython.
Более быстрый CPython фокусируется на однопоточной производительности. Команда не планирует менять или удалять GIL. Тем не менее, если проект увенчается успехом, одна из главных болевых точек Python будет устранена, и вопрос GIL может стать более актуальным, чем когда-либо.
P.S.
Контрольные показатели, используемые в этом посте, доступны на GitHub. Особая благодарность Дэвиду Бизли за его удивительные выступления. Беседы Ларри Хастингса о GIL и Gilectomy (раз, два, три) также были очень интересными. Чтобы понять, как работают современные планировщики ОС, я прочитал книгу Роберта Лава «Разработка ядра Linux«. Очень рекомендую!
Если вы хотите изучить GIL более подробно, вам следует прочитать исходный код. Файл Python/ceval_gil.h — идеальное место для начала. Чтобы помочь вам в этом предприятии, я написал следующий раздел.
Детали реализации GIL *
Технически GIL — это флаг, указывающий, заблокирован ли GIL или нет, а также набор мьютексов и условных переменных, которые управляют установкой этого флага, и некоторые другие служебные переменные, такие как интервал переключения. Все эти вещи хранятся в структуре _gil_runtime_state:
struct _gil_runtime_state < /* microseconds (the Python API uses seconds, though) */ unsigned long interval; /* Last PyThreadState holding / having held the GIL. This helps us know whether anyone else was scheduled after we dropped the GIL. */ _Py_atomic_address last_holder; /* Whether the GIL is already taken (-1 if uninitialized). This is atomic because it can be read without any lock taken in ceval.c. */ _Py_atomic_int locked; /* Number of GIL switches since the beginning. */ unsigned long switch_number; /* This condition variable allows one or several threads to wait until the GIL is released. In addition, the mutex also protects the above variables. */ PyCOND_T cond; PyMUTEX_T mutex; #ifdef FORCE_SWITCHING /* This condition variable helps the GIL-releasing thread wait for a GIL-awaiting thread to be scheduled and take the GIL. */ PyCOND_T switch_cond; PyMUTEX_T switch_mutex; #endif >;
Структура _gil_runtime_state является частью глобального состояния. Он хранится в структуре _ceval_runtime_state, которая, в свою очередь, является частью _PyRuntimeState, к которому имеют доступ все потоки Python:
struct _ceval_runtime_state < _Py_atomic_int signals_pending; struct _gil_runtime_state gil; >; typedef struct pyruntimestate < // . struct _ceval_runtime_state ceval; struct _gilstate_runtime_state gilstate; // . >_PyRuntimeState;
Обратите внимание, что _gilstate_runtime_state это структура, отличная от _gil_runtime_state. В ней хранится информация о потоке, удерживающем GIL:
struct _gilstate_runtime_state < /* bpo-26558: Flag to disable PyGILState_Check(). If set to non-zero, PyGILState_Check() always return 1. */ int check_enabled; /* Assuming the current thread holds the GIL, this is the PyThreadState for the current thread. */ _Py_atomic_address tstate_current; /* The single PyInterpreterState used by this process' GILState implementation */ /* TODO: Given interp_main, it may be possible to kill this ref */ PyInterpreterState *autoInterpreterState; Py_tss_t autoTSSkey; >;
Наконец, есть структура _ceval_state, которая является частью PyInterpreterState. В ней хранятся флаги eval_breaker и gil_drop_request:
struct _ceval_state < int recursion_limit; int tracing_possible; /* This single variable consolidates all requests to break out of the fast path in the eval loop. */ _Py_atomic_int eval_breaker; /* Request for dropping the GIL */ _Py_atomic_int gil_drop_request; struct _pending_calls pending; >;
Python/C API предоставляет функции PyEval_RestoreThread() и PyEval_SaveThread() для захвата и освобождения GIL. Эти функции также заботятся об установке gilstate->tstate_current. Под капотом вся работа выполняется функциями take_gil() и drop_gil(). Они вызываются потоком, удерживающим GIL, когда он приостанавливает выполнение байт-кода:
/* Handle signals, pending calls, GIL drop request and asynchronous exception */ static int eval_frame_handle_pending(PyThreadState *tstate) < _PyRuntimeState * const runtime = &_PyRuntime; struct _ceval_runtime_state *ceval = &runtime->ceval; /* Pending signals */ // . /* Pending calls */ struct _ceval_state *ceval2 = &tstate->interp->ceval; // . /* GIL drop request */ if (_Py_atomic_load_relaxed(&ceval2->gil_drop_request)) < /* Give another thread a chance */ if (_PyThreadState_Swap(&runtime->gilstate, NULL) != tstate) < Py_FatalError("tstate mix-up"); >drop_gil(ceval, ceval2, tstate); /* Other threads may run now */ take_gil(tstate); if (_PyThreadState_Swap(&runtime->gilstate, tstate) != NULL) < Py_FatalError("orphan tstate"); >> /* Check for asynchronous exception. */ // . >
В Unix-подобных системах реализация GIL опирается на примитивы, предоставляемые библиотекой pthreads. К ним относятся мьютексы и условные переменные. Короче говоря, они работают следующим образом. Поток вызывает pthread_mutex_lock(mutex) чтобы заблокировать мьютекс. Когда другой поток делает то же самое, он блокируется. ОС помещает его в очередь потоков, которые ожидают мьютекс, и запускает его, когда первый поток вызывает pthread_mutex_unlock(mutex). В каждый момент времени только один поток может запускать защищенный код.
Условные переменные позволяют одному потоку ждать, пока другой поток не выполнит какое-либо условие. Чтобы дождаться условной переменной, поток блокирует мьютекс и вызывает pthread_cond_wait(cond, mutex) или pthread_cond_timedwait(cond, mutex, time). Эти вызовы атомарно разблокируют мьютекс и блокируют поток. ОС помещает поток в очередь ожидания и пробуждает его, когда другой поток вызывает pthread_cond_signal(). Пробужденный поток снова блокирует мьютекс и продолжает работу. Вот как обычно используются условные переменные:
# awaiting thread mutex.lock() while not condition: cond_wait(cond_variable, mutex) # . condition is True, do something mutex.unlock() # signaling thread mutex.lock() # . do something and make condition True cond_signal(cond_variable) mutex.unlock()
Обратите внимание, что ожидающий поток должен проверить условие в цикле, потому что это не гарантирует, что оно будет истинным после уведомления. Мьютекс гарантирует, что ожидающий поток не пропустит условие перехода от ложного к истинному.
Функции take_gil()и drop_gil() используют условную переменную gil->cond для уведомления потоков, ожидающих GIL, о том, что GIL был освобожден, и gil->switch_cond для уведомления потока, удерживающего GIL, о том, что другой поток принял GIL. Эти условные переменные защищены двумя мьютексами: gil->mutex и gil->switch_mutex.
- Заблокировать мьютекс GIL: pthread_mutex_lock(&gil->mutex).
- Проверить gil->locked. Если не установлен, перейти к шагу 4.
- Ожидать GIL, пока gil->locked:
- Запомнить gil->switch_number.
- Подождать, пока поток, удерживающий GIL, освободит GIL: pthread_cond_timedwait(&gil->cond, &gil->mutex, switch_interval).
- Если время истекло, и gil->locked, и gil->switch_number не изменились, то сообщить потоку, удерживающему GIL, чтобы он освободил GIL: установить ceval->gil_drop_request и ceval->eval_breaker.
- Заблокировать мьютекс переключателя: pthread_mutex_lock(&gil->switch_mutex).
- Установить gil->locked.
- Если поток не является gil->last_holder потоком, обновить gil->last_holder и увеличить gil->switch_number.
- Уведомить поток, освобождающий GIL, о том, что мы приняли GIL: pthread_cond_signal(&gil->switch_cond).
- Разблокировать мьютекс: pthread_mutex_unlock(&gil->switch_mutex).
Обратите внимание, что пока поток ожидает GIL, другой поток может принять его, поэтому необходимо проверить gil->switch_number, чтобы поток, который только что принял GIL, не был вынужден его освободить.
- Заблокировать мьютекс GIL: pthread_mutex_lock(&gil->mutex).
- Сбросить gil->locked.
- Уведомить потоки, ожидающие GIL, о том, что мы освобождаем GIL: pthread_cond_signal(&gil->cond).
- Разблокировать мьютекс GIL: pthread_mutex_unlock(&gil->mutex).
- Если установлен ceval->gil_drop_request, подождать, пока другой поток не захватит GIL:
- Заблокировать мьютекс: pthread_mutex_lock(&gil->switch_mutex).
- Если мы все еще gil->last_holder , подождать: pthread_cond_wait(&gil->switch_cond, &gil->switch_mutex).
- Разблокировать мьютекс: pthread_mutex_unlock(&gil->switch_mutex).
Обратите внимание, что потоку, освобождающему GIL, не нужно ждать условия в цикле. Он вызывает pthread_cond_wait(&gil->switch_cond, &gil->switch_mutex) только для того, чтобы убедиться, что он не получит GIL немедленно обратно. Если произошел переход, это означает, что другой поток взял GIL, и можно снова побороться за GIL.
Если у вас есть какие-либо вопросы, замечания или предложения, не стесняйтесь обращаться ко мне по адресу victor@tenthousandmeters.com
Обновление от 7 октября 2021 года: [1] Ограничение потоков одним ядром на самом деле не устраняет эффект конвоя. Да, это заставляет ОС выбирать, какой из двух потоков запланировать, что дает потоку, связанному с вводом-выводом, хороший шанс повторно получить GIL при операции ввода-вывода, но если операция ввода-вывода блокируется, это не помогает. В этом случае поток, связанный с вводом-выводом, не готов к планированию, поэтому ОС планирует поток, связанный с процессором.
В примере с эхо–сервером фактически каждый recv() блокирующий — сервер ждет, пока клиент прочитает ответ и отправит следующее сообщение. Ограничение потоков одним ядром не сможет помочь. Но мы увидели, что RPS улучшился. Почему? Это потому, что эталон ошибочен. Я запустил клиент на той же машине и на том же ядре, что и потоки сервера. Такая настройка вынуждает ОС выбирать между потоком, связанным с процессором сервера, и потоком клиента, когда поток, связанный с вводом-выводом сервера, блокируется на recv(). Поток клиента, скорее всего, будет запланирован. Он отправляет следующее сообщение и блокируется на recv() тоже. Но теперь поток ввода-вывода сервера готов и конкурирует с потоком, связанным с процессором.
Кроме того, вам не нужно изменять исходный код CPython или возиться с ctypes, чтобы ограничить потоки Python определенными ядрами. В Linux функция pthread_setaffinity_np() реализована поверх системного вызова sched_setaffinity(), и стандартный модуль os предоставляет этот системный вызов Python. Спасибо Карлу Бордуму Хансену за то, что указал мне на это.
Существует также команда taskset, которая позволяет установить соответствие процессора процессу, вообще не касаясь исходного кода. Просто запустите программу вот так:
$ taskset -c python program.pyОбновление от 16 октября 2021 года: Сэм Гросс недавно объявил о своём форке CPython, который удаляет GIL. Вы можете думать об этом проекте как о Gilectomy 2.0: он заменяет GIL альтернативными механизмами безопасности потоков, но, в отличие от Gilectomy, не делает однопоточный код намного медленнее. Фактически, Гросс оптимизировал интерпретатор таким образом, чтобы однопоточная производительность его форка без GIL стала даже быстрее, чем у основного CPython 3.9.
Что такое GIL в Python и как с ним работать
GIL (Global Interpreter Lock) – это механизм, используемый в CPython для синхронизации доступа к объектам Python. Он предотвращает одновременное выполнение нескольких нитей интерпретатора, что может привести к проблемам с производительностью в многопоточных приложениях. В этой статье мы поговорим о том, что такое GIL, его влиянии на производительность и как с ним работать.
Влияние GIL на производительность
GIL ограничивает выполнение кода в одну нить, что может замедлить работу многопоточных приложений. Например, если у вас есть программа с двумя потоками, которые выполняют вычисления, они не смогут использовать полностью ресурсы многоядерного процессора, так как только одна нить сможет выполняться в одно и то же время.
Python-разработчик: новая работа через 9 месяцев
Получится, даже если у вас нет опыта в ITКак справиться с GIL
Использование многопроцессорности
Один из способов обойти ограничения GIL – использовать многопроцессорность вместо многопоточности. В Python для этого есть модуль multiprocessing . Пример создания двух процессов, выполняющих задачу параллельно:
from multiprocessing import Process def task(name): print(f'Выполнение задачи ') if __name__ == '__main__': p1 = Process(target=task, args=('Process 1',)) p2 = Process(target=task, args=('Process 2',)) p1.start() p2.start() p1.join() p2.join() print('Завершение работы')Использование других реализаций Python
Еще один способ избавиться от проблем с GIL – использовать другие реализации Python, такие как PyPy, Jython или IronPython. Они не имеют GIL или используют другие механизмы для синхронизации доступа к объектам.
Заключение
GIL является важным механизмом синхронизации в CPython, который обеспечивает безопасность данных и предотвращает одновременное выполнение нескольких потоков. Однако, это может привести к проблемам с производительностью в многопоточных приложениях. Чтобы обойти ограничения GIL, можно использовать многопроцессорность или другие реализации Python.
Зачем нужен Python Global Interpreter Lock и как он работает
Python Global Interpreter Lock (GIL) — блокировка, позволяющая только одному потоку управлять интерпретатором Python. Рассмотрим, как она работает.
Python Global Interpreter Lock (GIL) — это своеобразная блокировка, позволяющая только одному потоку управлять интерпретатором Python. Это означает, что в любой момент времени будет выполняться только один конкретный поток.
Работа GIL может казаться несущественной для разработчиков, создающих однопоточные программы. Но во многопоточных программах отсутствие GIL может негативно сказываться на производительности процессоро-зависымых программ.
Поскольку GIL позволяет работать только одному потоку даже в многопоточном приложении, он заработал репутацию «печально известной» функции.
В этой статье будет рассказано о том, как GIL влияет на производительность приложений, и о том, как это самое влияние можно смягчить.
Что за проблему в Python решает GIL?
Python подсчитывает количество ссылок для корректного управления памятью. Это означает, что созданные в Python объекты имеют переменную подсчёта ссылок, в которой хранится количество всех ссылок на этот объект. Как только эта переменная становится равной нулю, память, выделенная под этот объект, освобождается.
Вот небольшой пример кода, демонстрирующий работу переменных подсчёта ссылок:
>>> import sys >>> a = [] >>> b = a >>> sys.getrefcount(a) 3В этом примере количество ссылок на пустой массив равно 3. На этот массив ссылаются: переменная a , переменная b и аргумент, переданный функции sys.getrefcount() .
Проблема, которую решает 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 программа, которая попросту ведёт обратный отсчёт:
# single_threaded.py import time from threading import Thread COUNT = 50000000 def countdown(n): while n > 0: n -= 1 start = time.time() countdown(COUNT) end = time.time() print('Затраченное время -', end - start)Запустив это на 4х-ядерном компьютере получим такой результат:
Затраченное времяНиже приведена та же программа, с небольшим изменением. Теперь обратный отсчёт ведётся в двух параллельных потоках:
# multi_threaded.py import time from threading import Thread COUNT = 50000000 def countdown(n): while n > 0: n -= 1 t1 = Thread(target=countdown, args=(COUNT//2,)) t2 = Thread(target=countdown, args=(COUNT//2,)) start = time.time() t1.start() t2.start() t1.join() t2.join() end = time.time() print('Затраченное время -', end - start)И вот результат:
$ python multi_threaded.pyКак видно из результатов, оба варианта затратили примерно одинаковое время. В многопоточной версии 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»:
С тех пор ни одна из предпринятых попыток не удовлетворяла это условие.
Почему 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, эти потоки могли продолжать работу.
>>> import sys >>> # По умолчанию интервал выставлен в 100 >>> sys.getcheckinterval() 100Но тут есть одна проблема. Почти всегда GIL занимается процессорными потоками и остальные потоки не успевают занять место. Этот факт был изучен David Beazley, визуализацию этого можно увидеть здесь.
Проблема была решена в Python 3.2 в 2009 разработчиком Antoine Pitrou. Он добавил механизм подсчёта потоков, которые нуждаются в GIL. И если есть другие потоки, нуждающиеся в GIL, текущий поток не занимал бы их место.
Как справиться GIL?
Если GIL у вас вызывает проблемы, вот несколько решений, которые вы можете попробовать:
Многопроцессность против многопоточности. Довольно популярное решение, поскольку у каждого Python-процесса есть собственный интерпретатор с выделенной под него памятью, поэтому с GIL проблем не будет. В Python уже есть модуль multiprocessing , который упрощает создание процессов к такому виду:
from multiprocessing import Pool import time COUNT = 50000000 def countdown(n): while n > 0: n -= 1 if __name__ == '__main__': pool = Pool(processes=2) start = time.time() r1 = pool.apply_async(countdown, [COUNT//2]) r2 = pool.apply_async(countdown, [COUNT//2]) pool.close() pool.join() end = time.time() print('Затраченное время в секундах -', end - start)После запуска получаем такой результат:
Затраченное время в секундахМожно заметить приличное повышение производительности по сравнению с многопоточной версией. Однако показатель времени не снизился до половины. Всё из-за того, что управление процессами само по себе сказывается на производительности. Несколько процессов более сложны, чем несколько потоков, поэтому с ними нужно работать аккуратно.
Альтернативные интерпретаторы 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
Для начала давайте вспомним о том, что такое потоки Python, и о том, как в Python устроена многопоточность. Когда запускают исполняемый файл python — ОС создаёт новый процесс с одним вычислительным потоком, который называется главным потоком. Как и в случае с любой другой С-программой, главный поток начинает выполнение программы python с входа в её функцию main() . Следующие действия главного потока могут быть сведены к трём шагам:
- Инициализация интерпретатора.
- Компиляция Python-кода в байт-код.
- Вход в вычислительный цикл для выполнения байт-кода.
Главный поток — это обычный поток операционной системы, который выполняет скомпилированный C-код. Состояние этого потока включает в себя значения регистров процессора и стек вызова C-функций. А Python-поток должен обладать сведениями о стеке вызовов Python-функций, об исключениях, и о других вещах, имеющих отношение к Python. Для того чтобы всё так и было, CPython помещает всё это в структуру, предназначенную для хранения состояния потока, и связывает состояние Python-потока с потоком операционной системы. Другими словами: Python-поток = Поток ОС + Состояние Python-потока .
Вычислительный цикл — это бесконечный цикл, который содержит оператор switch огромных размеров, умеющий реагировать на все возможные инструкции, встречающиеся в байт-коде. Для входа в этот цикл поток должен удерживать глобальную блокировку интерпретатора. Главный поток захватывает GIL в ходе инициализации, поэтому он может свободно войти в этот цикл. Когда он входит в цикл — он просто начинает, одну за другой, выполнять инструкции байт-кода, задействуя оператор switch .
Время от времени потоку нужно приостановить исполнение байт-кода. Поток, в начале каждой итерации вычислительного цикла, проверяет, имеются ли какие-нибудь причины для остановки выполнения байт-кода. Нам интересна одна из таких причин, которая заключается в том, что другой поток хочет захватить GIL. Вот как это всё реализовано в коде:
PyObject* _PyEval_EvalFrameDefault(PyThreadState *tstate, PyFrameObject *f, int throwflag) < // . объявление локальных переменных и другие скучные дела // вычислительный цикл for (;;) < // eval_breaker сообщает нам о том, нужно ли приостановить выполнение байт-кода // например, если другой поток запросил GIL if (_Py_atomic_load_relaxed(eval_breaker)) < // eval_frame_handle_pending() приостанавливает выполнение байт-кода // например, когда другой поток запрашивает GIL, // эта функция освобождает GIL и снова ожидает доступности GIL if (eval_frame_handle_pending(tstate) != 0) < goto error; >> // получить следующую инструкцию байт-кода NEXTOPARG(); switch (opcode) < case TARGET(NOP) < FAST_DISPATCH(); // следующая итерация >case TARGET(LOAD_FAST) < // . код для загрузки локальной переменной FAST_DISPATCH(); // следующая итерация >// . ещё 117 блоков case, соответствующих всем возможным кодам операций > // . обработка ошибок > // . завершение >В однопоточной Python-программе главный поток — это ещё и единственный поток. Он никогда не освобождает глобальную блокировку интерпретатора. А что же происходит в многопоточных программах? Воспользуемся стандартным модулем threading для создания нового Python-потока:
import threading def f(a, b, c): # делаем что-нибудь pass t = threading.Thread(target=f, args=(1, 2), kwargs=) t.start()Метод start() экземпляра класса Thread создаёт новый поток ОС. В Unix-подобных системах, включая Linux и macOS, данный метод вызывает для этой цели функцию pthread_create(). Только что созданный поток начинает выполнение функции t_bootstrap() с аргументом boot . Аргумент boot — это структура, которая содержит целевую функцию, переданные ей аргументы и состояние потока для нового потока ОС. Функция t_bootstrap() решает множество задач, но, что важнее всего, она захватывает GIL и входит в вычислительный цикл для выполнения байт-кода вышеупомянутой целевой функции.
Поток, прежде чем захватить GIL, сначала проверяет, удерживает ли GIL какой-то другой поток. Если это не так — поток сразу же захватывает GIL. В противном случае он ждёт до тех пор, пока глобальная блокировка интерпретатора не будет освобождена. Ожидание продолжается в течение фиксированного временного интервала, называемого интервалом переключения (по умолчанию — 5 мс). Если GIL за это время не освободится, поток устанавливает флаги eval_breaker и gil_drop_request . Флаг eval_breaker сообщает потоку, удерживающему GIL, о том, что ему нужно приостановить выполнение байт-кода. А флаг gil_drop_request объясняет ему причину необходимости это сделать. Поток, удерживающий GIL, видит эти флаги, начиная следующую итерацию вычислительного цикла, после чего освобождает GIL. Он уведомляет об этом потоки, ожидающие освобождения GIL, а потом один из этих потоков захватывает GIL. Решение о том, какой именно поток нужно разбудить, принимает операционная система, поэтому это может быть тот поток, что установил флаги, а может быть и какой-то другой поток.
Собственно говоря, это — абсолютный минимум сведений, которые нам нужно знать о GIL. А теперь я собираюсь рассказать о том, как GIL влияет на производительность Python-программ. Если то, что вы обнаружите в следующем разделе, покажется вам интересным, вас могут заинтересовать и следующие части этой статьи, где мы подробнее рассмотрим некоторые аспекты GIL.
Последствия существования GIL
Первое последствие существования GIL широко известно: это невозможность параллельного выполнения Python-потоков. А значит — многопоточные программы, даже на многоядерных машинах, работают не быстрее, чем их однопоточные эквиваленты.
Рассмотрим следующую функцию, производительность которой зависит от скорости процессора. Она выполняет операцию декремента переменной заданное количество раз:
def countdown(n): while n > 0: n -= 1Мы, не мудрствуя лукаво, попробуем распараллелить выполнение соответствующего 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 все операции ввода/вывода реализованы с использованием следующего паттерна:
- Освобождение GIL.
- Выполнение операции, например, write(), recv(), accept().
- Захват GIL.
Получается, что поток может добровольно освободить GIL, ещё до того, как другой поток установит флаги eval_breaker и gil_drop_request . Обычно потоку нужно удерживать GIL только тогда, когда он работает с Python-объектами. В результате в CPython паттерн «освобождение-выполнение-захват» реализован не только для операций ввода-вывода, но и для других блокирующих вызовов ОС, вроде select() и pthread_mutex_lock(), а так же для кода, выполняющего «тяжёлые» вычисления на чистом C. Например, хэш-функции в стандартном модуле hashlib освобождают GIL. Это позволяет нам реально ускорить Python-код, который вызывает подобные функции с использованием многопоточности.
Предположим, что нам нужно вычислить хэши SHA-256 для восьми 128-мегабайтных сообщений. Мы можем вызвать hashlib.sha256(message) для каждого сообщения, обойдясь одним потоком, но можно и распределить нагрузку по нескольким потокам. Вот результаты исследования этой задачи, полученные на моём компьютере:
Количество потоков
Общий размер сообщений на поток
Время в секундах (лучшее из 3 попыток)
Переход от одного потока к двум даёт ускорение почти в 2 раза из-за того, что эти два потока работают параллельно. Правда, дальнейшее увеличение числа потоков не особенно сильно улучшает ситуацию, так как на моём компьютере всего два физических процессорных ядра. Тут можно сделать вывод о том, что, прибегнув к многопоточности, можно ускорить Python-код, выполняющий «тяжёлые» вычисления, в том случае, если в этом коде осуществляется вызов C-функций, которые освобождают GIL. Обратите внимание на то, что подобные функции можно обнаружить не только в стандартной библиотеке, но и в модулях сторонних разработчиков, рассчитанных на серьёзные вычисления, вроде NumPy. Можно даже самостоятельно писать C-расширения, освобождающие GIL.
Мы упоминали о потоках, скорость работы которых привязана к производительности CPU, то есть — о потоках, которые, большую часть времени, заняты некими вычислениями. Мы говорили и о потоках, производительность которых ограничена подсистемой ввода/вывода — о тех, которые большую часть времени заняты ожиданием операций ввода/вывода. Самые интересные последствия существования GIL появляются при смешанном использовании и тех и других потоков. Рассмотрим простой эхо-сервер TCP, который ожидает входящих подключений. Когда к нему подключается клиент — он запускает новый поток для работы с этим клиентом:
from threading import Thread import socket def run_server(host='127.0.0.1', port=33333): sock = socket.socket() sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind((host, port)) sock.listen() while True: client_sock, addr = sock.accept() print('Connection from', addr) Thread(target=handle_client, args=(client_sock,)).start() def handle_client(sock): while True: received_data = sock.recv(4096) if not received_data: break sock.sendall(received_data) print('Client disconnected:', sock.getpeername()) sock.close() if name == 'main': run_server()Сколько запросов в секунду «потянет» этот сервер? Я написал простую программу-клиент, которая, настолько быстро, насколько это возможно, отправляет серверу 1-байтовые сообщения и принимает их от него. У меня получилось что-то около 30 тысяч запросов в секунду (RPS, Requests Per Second). Это, скорее всего, не особенно надёжный результат, так как и сервер, и клиент работали на одном и том же компьютере. Но тут к надёжности этого результата я и не стремился. А интересовало меня то, как упадёт RPS в том случае, если сервер будет, во время обработки запросов клиентов, выполнять в отдельном потоке какую-нибудь серьёзную вычислительную задачу.
Рассмотрим тот же самый серверный код, к которому теперь добавлен код, запускающий дополнительный поток, устроенный довольно примитивно. Код, выполняемый в этом потоке, инкрементирует и декрементирует переменную в бесконечном цикле (при выполнении любого кода, интенсивно использующего ресурсы процессора, в сущности, происходит то же самое):
# . тот же самый код сервера def compute(): n = 0 while True: n += 1 n -= 1 if name == 'main': Thread(target=compute).start() run_server()Как думаете — насколько сильно изменится RPS? Упадёт лишь немного? Или, может, снизится в 2 раза? А может — в 10? Нет. Показатель RPS упал до 100, что в 300 раз меньше первоначального показателя. И это крайне удивительно для того, кто привык к тому, как операционная система планирует выполнение потоков. Для того чтобы проиллюстрировать то, что я имею в виду, давайте запустим код сервера и код потока, выполняющего вычисления, в виде отдельных процессов, что приведёт к тому, что на них не будет действовать GIL. Можно разделить код на два отдельных файла, или просто воспользоваться стандартным модулем multiprocessing для создания новых процессов. Например, это может выглядеть так:
from multiprocessing import Process #. тот же самый код сервера if name == 'main': Process(target=compute).start() run_server()Этот код выдаёт около 20 тысяч RPS. Более того, если запустить два, три или четыре процесса, интенсивно использующих процессор, RPS почти не меняется. Планировщик ОС отдаёт приоритет процессам, производительность которых привязана к подсистеме ввода/вывода. И это правильно.
В нашем примере серверного кода поток, привязанный к подсистеме ввода/вывода, ожидает, когда сокет будет готов к чтению и записи, но производительность любого другого подобного потока будет ухудшаться по тому же сценарию. Представим себе поток, отвечающий за работу пользовательского интерфейса, который ожидает пользовательского ввода. Он, если рядом с ним запустить поток, интенсивно использующий процессор, будет регулярно «подвисать». Ясно, что обычные потоки операционной системы работают не так, и что причиной этого является GIL. Глобальная блокировка интерпретатора мешает планировщику ОС.
Разработчики CPython, на самом деле, хорошо осведомлены об этой проблеме. Они называют её «эффектом сопровождения» (convoy effect). Дэвид Бизли сделал об этом доклад в 2010 году и открыл обращение о проблеме на bugs.python.org. Через 11 лет, в 2021 году, это обращение было закрыто. Но проблема так и не была исправлена. Далее мы попытаемся разобраться с тем, почему это так.
Эффект сопровождения
Эффект сопровождения возникает из-за того, что каждый раз, когда поток, ограниченный подсистемой ввода/вывода, выполняет операцию ввода/вывода, он освобождает GIL, а когда он, после выполнения операции, пытается снова захватить GIL, то блокировка, вероятно, уже окажется захвачена потоком, ограниченным возможностями процессора. В результате потоку, занятому вводом/выводом данных, необходимо подождать как минимум 5 мс до того, как он сможет установить флаги eval_breaker и gil_drop_request , принудив тем самым поток, занятый вычислениями, освободить GIL.
Операционная система может запланировать выполнение потока, привязанного к возможностям 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 раз. Как видите, эти цифры совпадают.
Тут можно задаться вопросом о том, приводит ли наличие эффекта сопровождения к проблемам в реальных приложениях. Ответа на этот вопрос я не знаю. Я никогда с подобными проблемами не сталкивался и не встречал свидетельств того, что с ними сталкивался кто-то ещё. Никто на это не жалуется, и это — одна из причин, по которой данная проблема до сих пор не исправлена.
Но что если эффект сопровождения вызывает проблемы с производительностью вашего приложения? Есть два способа исправления этих проблем.
Устранение последствий эффекта сопровождения
Так как рассматриваемая проблема заключается в том, что поток, привязанный к подсистеме ввода/вывода, вынужден ждать истечения интервала переключения, и лишь после этого может запросить GIL, мы можем попытаться сделать интервал переключения меньше. В Python, специально для этой цели, имеется функция sys.setswitchinterval(interval). Аргумент interval — это значение с плавающей точкой, представляющее собой время в секундах. Интервал переключения измеряется в микросекундах, в результате наименьшее значение, которое ему можно задать — это 0.000001 . Вот показатели RPS, которые мне удалось получить, меняя интервал переключения и количество потоков, производительность которых привязана к возможностям процессора (в таблице они называются «CPU-потоки»):
Интервал переключения в секундах
RPS без CPU-потоков
RPS с одним CPU-потоком
RPS с двумя CPU-потоками
RPS с четырьмя CPU-потоками
0.005
30,000
100
50
30
Полученные результаты позволяют сделать следующие выводы:
- Интервал переключения не влияет на RPS в том случае, если поток, ограниченный возможностями подсистемы ввода/вывода — это единственный поток приложения.
- Когда в состав сервера включается один поток, ограниченный возможностями процессора, RPS сильно падает.
- Удвоение количества CPU-потоков приводит к снижению RPS вдвое.
- Уменьшение интервала переключения приводит к почти пропорциональному увеличению RPS до тех пор, пока интервал переключения не оказывается слишком маленьким. Происходит это из-за того, что в таких условиях значимой становится дополнительная нагрузка на систему, вызываемая переключением контекста.
Более короткие интервалы переключения делают потоки, привязанные к подсистеме ввода/вывода, более отзывчивыми. Но слишком маленькие интервалы переключения означают сильное увеличение дополнительной нагрузки на систему, вызванное большим количеством операций переключения контекста. Вспомните рассмотренную выше функцию countdown() . Мы видели, что ускорить её, воспользовавшись несколькими потоками, не удалось. Если же сделать интервал переключения слишком маленьким — мы и в случае с этой функцией увидим замедление работы:
Интервал переключения в секундах
Время в секундах (1 поток)
Время в секундах (2 потока)
Время в секундах (4 потока)
Время в секундах (8 потоков)
0.005
6.53
6.58
7.20
7.19
Тут, опять же, длительность интервала переключения не играет роли в том случае, если в программе имеется лишь один поток. Кроме того, количество потоков неважно в том случае, если интервал переключения достаточно велик. С низкой производительностью мы сталкиваемся в ситуациях, когда интервал переключения мал и когда в программе имеется несколько потоков.
В итоге, можно сказать, что изменение интервала переключения — это один из способов исправления последствий эффекта сопровождения. Но, прибегая к этому способу, нужно внимательно оценивать то, как изменение интервала переключения влияет на производительность приложения.
Второй способ борьбы с эффектом сопровождения выглядит ещё более «хакерским», чем первый. Так как на одноядерных процессорах этот эффект проявляется гораздо слабее, чем на многоядерных, можно попытаться ограничить все Python-потоки использованием одного ядра. Это заставит операционную систему принимать решение о том, выполнение какого именно потока нужно запланировать, и потоки, производительность которых привязана к подсистеме ввода/вывода, получат приоритет.
Не каждая ОС даёт возможность привязать группу потоков к определённым ядрам. Насколько я понимаю, macOS предоставляет пользователям лишь механизм, позволяющий давать планировщику ОС подсказки. Механизм, который нам нужен, имеется в Linux. Это — функция pthread_setaffinity_np(). Она принимает поток и маску, описывающую ядра CPU, после чего сообщает ОС о том, что ей нужно планировать выполнение этого потока только на ядрах, заданных маской.
Pthread_setaffinity_np() — это C-функция. Для того чтобы вызвать её из Python — можно использовать что-то вроде ctypes. Я не хотел связываться с ctypes , поэтому просто модифицировал исходный код CPython. Затем я скомпилировал исполняемый файл, запустил эхо-сервер на Ubuntu-машине с двумя ядрами и получил следующие результаты:
Количество CPU-потоков
0
1
2
4
8
Сервер вполне нормально переносит наличие одного потока, производительность которого привязана к процессору. Но, так как поток, зависящий от подсистемы ввода/вывода, вынужден конкурировать со всеми CPU-потоками за GIL, то, по мере того, как мы добавляем в программу такие потоки, производительность неуклонно и серьёзно падает. Этот способ борьбы с последствиями эффекта сопровождения — скорее не «способ», а самый настоящий «хак». Почему бы разработчикам CPython просто не реализовать нормальную глобальную блокировку интерпретатора?
Дополнение от 7 октября 2021 года. Сейчас я знаю о том, что ограничение потоков одним ядром помогает в борьбе с эффектом сопровождения лишь в том случае, если клиент привязан к тому же ядру, и именно так я и поступил, настраивая бенчмарк. Дело в том, что ограничение потоков одним ядром, на самом деле, не исправляет последствий эффекта сопровождения. Конечно, этот шаг принуждает ОС принимать решение о том, выполнение какого именно потока нужно запланировать, что даёт потоку, зависящему от подсистемы ввода/вывода, высокие шансы повторно захватить GIL при выполнении операции ввода/вывода. Но если операция ввода/вывода является блокирующей, пользы от этого нет. В таком случае поток, привязанный к подсистеме ввода/вывода, не готов к планированию его выполнения, в результате ОС планирует выполнение потока, производительность которого зависит от процессора.
В примере с эхо-сервером практически каждый вызов recv() является блокирующим — сервер ожидает того, чтобы клиент прочёл ответ и отправил бы следующее сообщение. Ограничение потоков одним ядром не должно улучшить ситуацию. Но мы видели улучшение RPS. Почему? Дело в том, что в бенчмарке был недочёт. Я запускал клиент на том же компьютере, и на том же ядре, на котором работали потоки сервера. В этой ситуации ОС, когда серверный поток, привязанный к подсистеме ввода/вывода, был заблокирован операцией recv() , была вынуждена выбирать между серверным потоком, привязанным к производительности CPU, и клиентским потоком. В этой ситуации шансы клиентского потока на то, что ОС запланирует его выполнение, были выше, чем шансы серверного потока. Клиентский поток отправляет следующее сообщение и тоже блокируется операцией recv() . Но теперь готов к работе серверный поток, привязанный к подсистеме ввода/вывода, и с потоком, привязанным к производительности процессора, конкурирует уже он. Получается, что запуск клиента на том же ядре приводит к тому, что ОС приходится выбирать между потоком, привязанным к подсистеме ввода/вывода, и потоком, привязанным к процессору, даже в случае с использованием блокирующей операции recv() .
Кроме того, для того чтобы ограничить Python-потоки определёнными ядрами, не нужно модифицировать исходный код CPython или связываться с ctypes . В Linux функция pthread_setaffinity_np() реализована поверх системного вызова sched_setaffinity(), а стандартный модуль os даёт Python доступ к этому системному вызову. Благодарю Карла Бордума Хансена за то, что обратил на это моё внимание.
Существует ещё команда taskset, которая позволяет задавать привязку процессов к процессору, совершенно не вмешиваясь в исходный код. Для этого достаточно, при запуске Python-программы, воспользоваться такой конструкцией:
$ taskset -c python program.pyКакой должна быть глобальная блокировка интерпретатора?
Фундаментальная проблема GIL заключается в том, что глобальная блокировка интерпретатора мешает работе планировщика ОС. В идеале нам хотелось бы запускать потоки, привязанные к подсистеме ввода/вывода, сразу же после того, как завершаются операции ввода/вывода, завершения которых они ожидают. Именно так обычно и работает планировщик ОС. В CPython, правда, поток в такой ситуации немедленно оказывается в состоянии ожидания GIL, в результате решения планировщика ОС, на самом деле, ничего не значат. Можно попытаться избавиться от интервала переключения, что позволит потоку, нуждающемуся в GIL, захватить блокировку без задержки, но тогда появится проблема с потоками, привязанными к производительности процессора, так как они постоянно нуждаются в GIL.
Достойным решением этой проблемы будет проведение различия между потоками разных видов. Потоки, производительность которых зависит от подсистемы ввода/вывода, должны иметь возможность без ожидания забирать GIL у потоков, зависящих от процессора. Но при этом потоки, обладающие одинаковым приоритетом, должны ждать друг друга. Планировщик ОС уже дифференцирует потоки, но мы полагаться на него не можем, так как он ничего не знает о GIL. Возникает такое ощущение, что единственный выход тут — реализация логики планирования выполнения потоков в самом интерпретаторе.
После того как Дэвид Бизли открыл обращение о проблеме, разработчики CPython сделали несколько попыток решить эту проблему. Сам Бизли предложил простой патч. Если в двух словах, то этот патч даёт потокам, привязанным к подсистеме ввода/вывода, преимущество перед потоками, привязанными к процессору. По умолчанию все потоки считаются потоками, привязанными к подсистеме ввода/вывода. После того как поток вынуждают освободить GIL, у него устанавливается флаг, указывающий на то, что это поток, привязанный к производительности процессора. А если поток освобождает GIL добровольно, этот флаг сбрасывается и поток снова считается потоком, зависящим от подсистемы ввода/вывода.
Патч Бизли решил все проблемы GIL, о которых мы сегодня говорили. Почему же его не включили в код CPython? Похоже, что все сошлись к мнению, что любая простая реализация GIL может дать сбой в некоторых патологических случаях. По крайней мере — может понадобиться приложить больше усилий к тому, чтобы эти случаи выявить. Нормальное решение проблемы GIL будет представлять собой систему планирования потоков, напоминающую ту, что есть в ОС, или, как выразился Нир Эйдс:
… Python, на самом деле, нужен планировщик, а не блокировка.
В результате Эйдс реализовал в своём патче полномасштабный планировщик. Патч оказался работоспособным, но планировщик — это достаточно сложная система. Включение этого патча в код CPython требовало серьёзных усилий. В итоге этот патч забросили, так как в то время не было достаточного количества доказательств того, что рассматриваемая проблема приводит к каким-то неприятностям в продакшн-коде. Подробности об этом можно посмотреть здесь.
У GIL никогда не было множества фанатов. А то, о чём мы сегодня говорили, только ухудшает ситуацию. И тут мы возвращаемся к «вопросу вопросов»: а нельзя ли избавиться от GIL?
Нельзя ли избавиться от GIL?
Первый шаг избавления от GIL заключается в понимании того, почему в Python существует глобальная блокировка интерпретатора. Для того чтобы это понять — достаточно поразмыслить о том, почему обычно используют блокировки в многопоточных программах. Делается это для предотвращения состояния гонок и для того, чтобы действия, производимые в одном из потоков, сделать, с точки зрения других потоков, атомарными. Предположим, имеется последовательность инструкций, которые модифицируют некую структуру данных. Если не защитить эти инструкции блокировкой, это значит, что, пока один поток модифицирует данные, другой поток может обратиться к изменяемой структуре данных в момент, когда её модификация ещё не завершена. В результате этот поток «увидит» такую структуру данных в неполном, «испорченном» состоянии.
Или, например, рассмотрим инкрементирование одной и той же переменной из нескольких потоков. Если операция инкрементирования не является атомарной и не защищена блокировкой, это значит, что итоговое значение переменной может быть меньше, чем количество операций её инкрементирования. Вот — типичный пример гонки данных:
- Поток №1 читает значение переменной x .
- Поток №2 читает значение переменной x .
- Поток №1 записывает в переменную значение, равное x + 1 .
- Поток №2 записывает в переменную значение, равное x + 1 , затирая те изменения, которые выполнены потоком №1.
В Python операция += не является атомарной, так как она состоит из нескольких инструкций байт-кода. Для того чтобы увидеть то, как это может привести к гонке данных, установим интервал переключения в 0.000001 и запустим следующую функцию в нескольких потоках:
sum = 0 def f(): global sum for _ in range(1000): sum += 1И, аналогично, в C не является атомарной операция инкрементирования целого числа с использованием конструкции вроде x++ или ++x . Дело в том, что компилятор транслирует подобные операции в последовательности машинных инструкций. В многопоточном режиме последовательности этих инструкций, выполняемые в одних потоках, могут смешиваться с последовательностями инструкций, выполняемых в других потоках.
Глобальная блокировка интерпретатора в Python весьма ценна тем, что позволяет надёжно выполнять подобные операции. В частности, когда CPython, в ходе работы, инкрементирует и декрементирует целые числа, доступные разным потокам. Подобное используется в механизме сборки мусора, реализованном в CPython. Так, у каждого Python-объекта есть поле, используемое для подсчёта ссылок на этот объект. В этом поле хранится число, соответствующее количеству мест, где есть ссылки на данный объект. Это могут быть Python-объекты, локальные и глобальные C-переменные. Где-то появилась новая ссылка на объект? Поле инкрементируется. Какая-то ссылка на объект исчезла? Поле декрементируется. Когда счётчик ссылок достигает нуля — память, занятая объектом, освобождается. Если бы не GIL — некоторые операции декрементирования счётчика могли бы устроить гонку данных и переписать то, что было записано другими операциями. Это могло бы привести к тому, что объект, никому уже не нужный, навсегда остался бы в памяти. Но это — ещё не самое худшее. Гонка операций инкрементирования счётчика может привести к уничтожению объекта, на который имеются активные ссылки.
GIL, кроме того, упрощает реализацию встроенных мутабельных структур данных. Списки, словари и множества, благодаря GIL, не используют собственные внутренние механизмы блокировок. Их можно безопасно использовать в многопоточных программах. И, аналогично, GIL позволяет потокам безопасно работать с глобальными данными и данными, имеющими отношение к интерпретатору — с загруженными модулями, с предварительно созданными объектами, с интернированными строками и так далее.
И, наконец, GIL упрощает написание C-расширений. Разработчики могут рассчитывать на то, что в некий момент времени их расширение работает лишь в одном потоке. В результате им не нужно использовать дополнительные механизмы блокировок для того, чтобы сделать свой код потокобезопасным. Если же они сознательно стремятся к параллельному выполнению кода — они могут освободить GIL.
В итоге, можно сказать, что действия GIL направлены на то, чтобы сделать потокобезопасными следующие механизмы и сущности:
- Подсчёт ссылок.
- Мутабельные структуры данных.
- Глобальные данные и данные, имеющие отношение к интерпретатору.
- C-расширения.
Для того чтобы убрать GIL и при этом не нарушить работу интерпретатора, нужно найти альтернативный механизм для обеспечения потокобезопасности. Попытки сделать это уже предпринимались. Наиболее заметная такая попытка представлена проектом Gilectomy Ларри Хастингса, работа над которым началась в 2016 году. Хастингс сделал форк CPython, убрал GIL, модифицировал механизм подсчёта ссылок с использованием атомарных операций инкрементирования и декрементирования переменных и разместил в коде множество тонко настроенных блокировок для защиты мутабельных структур данных и данных интерпретатора.
В рамках проекта Gilectomy можно было запускать Python-код, код мог работать и в параллельном режиме. Но при этом пострадала производительность однопоточных программ. Одни только атомарные операции инкрементирования и декрементирования переменных стали причиной 30%-го увеличения дополнительной нагрузки на систему. Хастингс попытался решить эту проблему, реализовав буферизованный подсчёт ссылок. Если в двух словах, то при таком подходе все операции по изменению переменных, соответствующих количеству ссылок на объекты, передаются одному специализированному потоку. Другие потоки лишь записывают сведения об инкрементировании или декрементировании подобных переменных в журнал, а особый поток читает данные из этого журнала. Этот механизм оказался рабочим, но и после его внедрения дополнительная нагрузка на систему всё ещё была очень высокой.
В итоге стало очевидным то, что код проекта Gilectomy не попадёт в CPython. Хастингс прекратил работу над этим проектом. Но Gilectomy нельзя назвать совершенно бесполезным делом. Этот проект дал ответ на вопрос о том, почему так трудно убрать GIL из CPython. А именно, речь идёт о двух основных причинах такой ситуации:
- Сборка мусора, основанная на подсчёте ссылок, не предназначена для многопоточных сред. Единственное решение этой задачи заключается в реализации системы сборки мусора, основанной на определении достижимости объекта. Подобные механизмы уже реализованы в JVM, в CLR, в Go и в других средах выполнения кода, в которых не используется GIL.
- Избавление от GIL приведёт к нарушению работы существующих C-расширений. И исправить это нельзя.
В наши дни никто серьёзно не размышляет о том, чтобы убрать GIL из CPython. Значит ли это, что GIL останется с нами навсегда?
Будущее GIL и конкурентности в Python
Весьма вероятно то, что, страшно сказать, в CPython мы скорее увидим появление множества GIL, чем устранение той глобальной блокировки интерпретатора, которая имеется сейчас. И это — не фигура речи — есть предложение по оснащению CPython несколькими GIL. Речь идёт о так называемых суб-интерпретаторах. Идея заключается в том, чтобы в рамках одного процесса работало бы несколько интерпретаторов. Потоки в одном интерпретаторе, как и прежде, будут совместно пользоваться одним экземпляром GIL, но при этом несколько интерпретаторов могут работать в параллельном режиме. Для синхронизации этих интерпретаторов нет нужды в GIL, так как у них нет общего глобального состояния и так как они не работают с одними и теми же Python-объектами. Глобальное состояние существует лишь в пределах отдельного интерпретатора, а взаимодействуют интерпретаторы лишь посредством обмена сообщениями. Конечная цель этой идеи заключается в том, чтобы ввести в Python модель конкурентности, основанную на последовательных процессах, обменивающихся данными, которая применяется в языках вроде Go и Clojure.
Интерпретаторы были частью CPython с версии 1.5, но они представляют собой всего лишь механизм изоляции. Они хранят данные, имеющие отношение к группе потоков: загруженные модули, встроенные объекты, настройки импорта и прочее подобное. Они не видны из Python, но C-расширения могут пользоваться ими через Python/C API. Лишь немногие расширения пользуются этими возможностями, в частности, заметный пример такого расширения — это mod_wsgi.
Сегодняшние интерпретаторы ограничены тем фактом, что им нужно совместно использовать GIL. Это может измениться только тогда, когда всё, имеющее отношение к глобальному состоянию, будет ограничено пределами отдельного интерпретатора. В этом направлении ведётся работа, но кое-что ещё остаётся глобальным: некоторые встроенные типы, синглтоны вроде None , True и False , части системы выделения памяти. C-расширениям, прежде чем они смогут работать с суб-интерпретаторами, тоже надо избавиться от глобального состояния.
Эрик Сноу подготовил предложение PEP 554, описывающее добавление в стандартную библиотеку модуля interpreters . Идея тут заключается в том, чтобы предоставить Python доступ к существующему C API для работы с интерпретаторами и дать механизм для организации обмена данными между интерпретаторами. Предложение нацелено на Python 3.9, но его внедрение отложено до того момента, когда у каждого интерпретатора будет собственная GIL. И даже тогда нет гарантии того, что PEP 554 будет внедрено. Действительно ли Python нуждается в ещё одной модели конкурентного выполнения кода — это спорный вопрос.
Ещё один восхитительный современный проект называется Faster CPython. В октябре 2020 года Марк Шеннон предложил план пятикратного ускорения CPython в течение нескольких лет. И этот план в реальности выглядит гораздо более реалистичным, чем может показаться на первый взгляд, так как очень многое в CPython можно подвергнуть оптимизации. Одно только добавление в него JIT может привести к огромному приросту производительности.
Похожие проекты появлялись и раньше, но они терпели неудачи — либо из-за отсутствия средств на их развитие, либо из-за нехватки опыта у тех, кто ими занимался. В этот раз поддерживать проект Faster CPython вызвалась компания Microsoft, что позволит Марку Шеннону, Гвидо ван Россуму и Эрику Сноу работать над проектом. Некоторые наработки, сделанные в рамках проекта, уже попали в код CPython, они не залёживаются в форке.
Проект Faster CPython направлен на улучшение однопоточной производительности. У его команды нет планов, касающихся изменения или устранения GIL. Но, несмотря на это, если проект окажется успешным, будет исправлена одна из главных проблем Python, а значит — вопрос о GIL станет острым, как никогда.
P.S.
Бенчмарки, использованные в этом материале, можно найти на GitHub. Хочу выразить особую благодарность Дэвиду Бизли за его замечательные доклады. Доклады Ларри Хастингса о GIL и о проекте Gilectomy (первый, второй и третий) тоже весьма интересны. Для того чтобы разобраться с тем, как работают планировщики в современных ОС, я прочитал книгу Роберта Лава «Ядро Linux: описание процесса разработки». Горячо рекомендую её всем, кому это интересно.
Если вы хотите углубиться в изучение устройства GIL, это значит, что вам стоит почитать исходный код. Идеальным местом для начала этого приключения является файл Python/ceval_gil.h. Я, для того чтобы помочь тем, кто на это решится, подготовил следующий дополнительный раздел.
Детали реализации GIL
GIL, с технической точки зрения — это флаг, указывающий на то, захвачена ли блокировка, набор мьютексов и условных переменных, которые контролируют установку этого флага, а так же некоторые другие вспомогательные переменные, вроде той, которая хранит значение интервала переключения. Всё это хранится в структуре _gil_runtime_state :
struct _gil_runtime_state < /* Микросекунды (Python API, правда, использует секунды) */ unsigned long interval; /* Последняя сущность PyThreadState удерживающая / удерживавшая GIL. Это помогает узнать о том, было ли что-то запланировано, после того, как мы освободили GIL. */ _Py_atomic_address last_holder; /* Захвачена ли блокировка (-1 - если не инициализировано). Это - атомарная переменная, так как читать её можно без какой-либо блокировки, захваченной в ceval.c. */ _Py_atomic_int locked; /* Количество переключений GIL с начала работы. */ unsigned long switch_number; /* Эта условная переменная позволяет одному или нескольким потокам ожидать освобождения GIL. Мьютекс, кроме того, защищает вышеобъявленные переменные. */ PyCOND_T cond; PyMUTEX_T mutex; #ifdef FORCE_SWITCHING /* Эта условная переменная помогает потоку, освобождающему GIL, дождаться планирования потока, ожидающего GIL, и захвата GIL этим потоком. */ PyCOND_T switch_cond; PyMUTEX_T switch_mutex; #endif >;Структура _gil_runtime_state является частью глобального состояния. Она хранится в структуре _ceval_runtime_state , которая, в свою очередь, является частью состояния _ceval_runtime_state , к которому есть доступ у всех Python-потоков:
struct _ceval_runtime_state < _Py_atomic_int signals_pending; struct _gil_runtime_state gil; >;typedef struct pyruntimestate < // . struct _ceval_runtime_state ceval; struct _gilstate_runtime_state gilstate; // . >_PyRuntimeState;Обратите внимание на то, что структура _gilstate_runtime_state — это не то же самое, что _gil_runtime_state . Она хранит информацию о потоке, удерживающем GIL:
struct _gilstate_runtime_state < /* bpo-26558: Флаг для отключения PyGILState_Check(). Если установлен в ненулевое значение, PyGILState_Check() всегда возвращает 1. */ int check_enabled; /* Если предположить, что GIL удерживает текущий поток, это будет PyThreadState для текущего потока. */ _Py_atomic_address tstate_current; /* Единственное PyInterpreterState, используемое реализацией GILState этого процесса */ /* TODO: Принимая во внимание interp_main может быть возможным уничтожение этой ссылки */ PyInterpreterState *autoInterpreterState; Py_tss_t autoTSSkey; >;И, наконец, существует структура _ceval_state , являющаяся частью PyInterpreterState . Она хранит флаги eval_breaker и gil_drop_request :
struct _ceval_state < int recursion_limit; int tracing_possible; /* Эта переменная собирает все запросы на выход из вычислительного цикла. */ _Py_atomic_int eval_breaker; /* Запрос на освобождение GIL. */ _Py_atomic_int gil_drop_request; struct _pending_calls pending; >;Python/C API дают нам функции PyEval_RestoreThread() и PyEval_SaveThread(), предназначенные для захвата и освобождения GIL. Эти функции, кроме того, занимаются установкой gilstate->tstate_current . Фактически же все эти задачи решают функции take_gil() и drop_gil(). Они вызываются потоком, удерживающим GIL, когда он приостанавливает выполнение байт-кода:
/* Обрабатывает сигналы, ожидающие вызовы, запрос на освобождение GIL и асинхронное исключение */ static int eval_frame_handle_pending(PyThreadState *tstate) < _PyRuntimeState * const runtime = &_PyRuntime; struct _ceval_runtime_state *ceval = &runtime->ceval; /* Ожидающие сигналы */ // . /* Ожидающие вызовы */ struct _ceval_state *ceval2 = &tstate->interp->ceval; // . /* Запрос на освобождение GIL */ if (_Py_atomic_load_relaxed(&ceval2->gil_drop_request)) < /* Дать шанс другому потоку */ if (_PyThreadState_Swap(&runtime->gilstate, NULL) != tstate) < Py_FatalError("tstate mix-up"); >drop_gil(ceval, ceval2, tstate); /* Теперь могут работать другие потоки */ take_gil(tstate); if (_PyThreadState_Swap(&runtime->gilstate, tstate) != NULL) < Py_FatalError("orphan tstate"); >> /* Проверка на асинхронное исключение. */ // . >В Unix-подобных системах реализация GIL основана на примитивах, предоставляемых библиотекой pthreads. В их состав входят мьютексы и условные переменные. В двух словах расскажу о том, как всё это работает. Поток вызывает pthread_mutex_lock(mutex) для того чтобы заблокировать мьютекс. Когда другой поток делает то же самое — он блокируется. Операционная система помещает этот поток в очередь потоков, ожидающих освобождения мьютекса и будит этот поток когда первый поток вызывает pthread_mutex_unlock(mutex). В некий момент времени лишь один поток может выполнять защищённый код.
Условные переменные позволяют одному потоку ждать до тех пор, пока другой поток не сделает некое условие истинным. Для того чтобы организовать ожидание изменения условной переменной, поток блокирует мьютекс и вызывает pthread_cond_wait(cond, mutex) или pthread_cond_timedwait(cond, mutex, time). Эти вызовы атомарно разблокируют мьютекс и блокируют поток. Операционная система помещает поток в очередь ожидания и будит его тогда, когда другой поток вызывает pthread_cond_signal(). Разбуженный поток снова блокирует мьютекс и продолжает работу. Вот как обычно используются условные переменные:
# ожидающий поток mutex.lock() while not condition: cond_wait(cond_variable, mutex) # . условная переменная равняется True, сделать что-то mutex.unlock()# сигнализирующий поток mutex.lock() # . сделать что-то для того, чтобы установить условную переменную в True cond_signal(cond_variable) mutex.unlock()Обратите внимание на то, что ожидающий поток должен проверять условие в цикле, так как не гарантируется, что оно, после уведомления, будет иметь значение True . Мьютекс позволяет обеспечить то, что ожидающий поток не пропустит момент изменения значения условной переменной с False на True .
Функции take_gil() и drop_gil() используют условную переменную gil->cond для того, чтобы уведомлять потоки, ожидающие освобождения GIL, об освобождении GIL. А переменная gil->switch_cond используется для того, чтобы уведомлять поток, удерживающий GIL о том, что другой поток захватил GIL. Эти условные переменные защищены двумя мьютексами: gil->mutex и gil->switch_mutex .
Вот пошаговый разбор работы take_gil():
- Блокировка мьютекса GIL: pthread_mutex_lock(&gil->mutex) .
- Проверка того, осуществлён ли захват GIL ( gil->locked ). Если ничто не захватило GIL — переход к шагу №4.
- Ожидание освобождения GIL. Пока истинно gil->locked :
- Запомнить gil->switch_number .
- Подождать, пока поток, удерживающий GIL, освободит GIL: pthread_cond_timedwait(&gil->cond, &gil->mutex, switch_interval) .
- Если вышло время тайм-аута, а значения gil->locked и gil->switch_number не изменились, попросить поток, удерживающий GIL, освободить блокировку: установить флаги ceval->gil_drop_request и ceval->eval_breaker .
- Заблокировать мьютекс switch_mutex : pthread_mutex_lock(&gil->switch_mutex) .
- Установить gil->locked .
- Если наш поток — это не поток, записанный в gil->last_holder , обновить значение gil->last_holder и инкрементировать gil->switch_number .
- Уведомить поток, освобождающий GIL, о том, что мы захватили GIL: pthread_cond_signal(&gil->switch_cond) .
- Разблокировать мьютекс switch_mutex : pthread_mutex_unlock(&gil->switch_mutex) .
Обратите внимание на то, что пока поток ожидает GIL, блокировку может захватить другой поток, поэтому для того чтобы убедиться в том, что потоку, который только что захватил GIL, не придётся принудительно освобождать блокировку, необходимо проверять значение переменной gil->switch_number .
И, наконец, разберём работу drop_gil():
- Заблокировать мьютекс GIL: pthread_mutex_lock(&gil->mutex) .
- Сбросить gil->locked .
- Уведомить поток, ожидающий GIL о том, что мы освободили GIL: pthread_cond_signal(&gil->cond) .
- Разблокировать мьютекс GIL: pthread_mutex_unlock(&gil->mutex) .
- Если установлен флаг ceval->gil_drop_request , подождать, пока другой поток захватит GIL:
- Заблокировать мьютекс switch_mutex : pthread_mutex_lock(&gil->switch_mutex) .
- Если мы всё ещё записаны в gil->last_holder , подождать: pthread_cond_wait(&gil->switch_cond, &gil->switch_mutex) .
- Разблокировать мьютекс switch_mutex : pthread_mutex_unlock(&gil->switch_mutex) .
Обратите внимание на то, что потоку, освобождающему GIL, не нужно ждать изменения условной переменной в цикле. Он вызывает pthread_cond_wait(&gil->switch_cond, &gil->switch_mutex ) только для того чтобы не начать немедленно повторно захватывать GIL. Если произошло изменение значения переменной — это означает, что другой поток захватил GIL и пришло время снова бороться с другими потоками за GIL.
Дополнение от 16 октября 2021 года. Сэм Гросс недавно представил широкой общественности свой форк CPython, который убирает GIL. Этот проект можно воспринимать как нечто вроде Gilectomy 2.0. Тут глобальная блокировка интерпретатора заменена на альтернативные механизмы обеспечения потокобезопасности, но, в отличие от Gilectomy, избавление от GIL не привело к значительному замедлению однопоточного кода. На самом деле, Гросс оптимизировал интерпретатор, в результате чего однопоточная производительность форка без GIL оказывается даже выше, чем у обычного CPython 3.9.
Этот проект выглядит как самая перспективная попытка освобождения CPython от GIL. Уверен, некоторые идеи Гросса доберутся до официального CPython. Для того чтобы узнать подробности об этом проекте и об идеях, лежащих в его основе, посмотрите его проектную документацию и репозиторий. А вот — хороший материал о нём.
О, а приходите к нам работать?
Мы в wunderfund.io занимаемся высокочастотной алготорговлей с 2014 года. Высокочастотная торговля — это непрерывное соревнование лучших программистов и математиков всего мира. Присоединившись к нам, вы станете частью этой увлекательной схватки.
Мы предлагаем интересные и сложные задачи по анализу данных и low latency разработке для увлеченных исследователей и программистов. Гибкий график и никакой бюрократии, решения быстро принимаются и воплощаются в жизнь.
Сейчас мы ищем плюсовиков, питонистов, дата-инженеров и мл-рисерчеров.
Присоединяйтесь к нашей команде.
