[Перевод] Глобальная блокировка интерпретатора (GIL) и её воздействие на многопоточность в Python
Как вы, наверное, знаете, глобальная блокировка интерпретатора (GIL, Global Interpreter Lock) — это механизм, обеспечивающий, при использовании интерпретатора CPython, безопасную работу с потоками. Но из-за GIL в конкретный момент времени выполнять байт-код Python может лишь один поток операционной системы. В результате нельзя ускорить Python-код, интенсивно использующий ресурсы процессора, распределив вычислительную нагрузку по нескольким потокам. Негативное влияние GIL на производительность Python-программ, правда, на этом не заканчивается. Так, GIL создаёт дополнительную нагрузку на систему. Это замедляет многопоточные программы и, что выглядит достаточно неожиданно, может даже оказать влияние на потоки, производительность которых ограничена подсистемой ввода/вывода.
Прим. Wunder Fund: в статье рассказано, зачем появился и существует GIL, как он работает, и как он влияет на скорость работы Питона, а также о том, куда в будущем вероятно будет двигаться Питон. У нас в фонде почти все, что не написано на плюсах — написано на Питоне, мы пристально следим за тем, куда движется язык, и если вы тоже — вы знаете, что делать:)
Здесь я опираюсь на особенности 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={'c': 3})
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 попыток) |
1 | 100,000,000 | 6.52 |
2 | 50,000,000 | 6.57 |
4 | 25,000,000 | 6.59 |
8 | 12,500,000 | 6.58 |
Сколько потоков мы не использовали бы, время выполнения вычислений, в сущности, остаётся одним и тем же. На самом деле, многопоточные варианты программы могут оказаться даже медленнее однопоточного из-за дополнительной нагрузки на систему, вызванной операциями переключения контекста. Стандартный интервал переключения составляет 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 попыток) |
1 | 1 Гб | 3.30 |
2 | 512 Мб | 1.68 |
4 | 256 Мб | 1.50 |
8 | 128 Мб | 1.60 |
Переход от одного потока к двум даёт ускорение почти в 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.1 | 30,000 | 5 | 2 | 0 |
0.01 | 30,000 | 50 | 30 | 15 |
0.005 | 30,000 | 100 | 50 | 30 |
0.001 | 30,000 | 500 | 280 | 200 |
0.0001 | 30,000 | 3,200 | 1,700 | 1000 |
0.00001 | 30,000 | 11,000 | 5,500 | 2,800 |
0.000001 | 30,000 | 10,000 | 4,500 | 2,500 |
Полученные результаты позволяют сделать следующие выводы:
Интервал переключения не влияет на RPS в том случае, если поток, ограниченный возможностями подсистемы ввода/вывода — это единственный поток приложения.
Когда в состав сервера включается один поток, ограниченный возможностями процессора, RPS сильно падает.
Удвоение количества CPU-потоков приводит к снижению RPS вдвое.
Уменьшение интервала переключения приводит к почти пропорциональному увеличению RPS до тех пор, пока интервал переключения не оказывается слишком маленьким. Происходит это из-за того, что в таких условиях значимой становится дополнительная нагрузка на систему, вызываемая переключением контекста.
Более короткие интервалы переключения делают потоки, привязанные к подсистеме ввода/вывода, более отзывчивыми. Но слишком маленькие интервалы переключения означают сильное увеличение дополнительной нагрузки на систему, вызванное большим количеством операций переключения контекста. Вспомните рассмотренную выше функцию countdown()
. Мы видели, что ускорить её, воспользовавшись несколькими потоками, не удалось. Если же сделать интервал переключения слишком маленьким — мы и в случае с этой функцией увидим замедление работы:
Интервал переключения в секундах | Время в секундах (1 поток) | Время в секундах (2 потока) | Время в секундах (4 потока) | Время в секундах (8 потоков) |
0.1 | 7.29 | 6.80 | 6.50 | 6.61 |
0.01 | 6.62 | 6.61 | 7.15 | 6.71 |
0.005 | 6.53 | 6.58 | 7.20 | 7.19 |
0.001 | 7.02 | 7.36 | 7.56 | 7.12 |
0.0001 | 6.77 | 9.20 | 9.36 | 9.84 |
0.00001 | 6.68 | 12.29 | 19.15 | 30.53 |
0.000001 | 6.89 | 17.16 | 31.68 | 86.44 |
Тут, опять же, длительность интервала переключения не играет роли в том случае, если в программе имеется лишь один поток. Кроме того, количество потоков неважно в том случае, если интервал переключения достаточно велик. С низкой производительностью мы сталкиваемся в ситуациях, когда интервал переключения мал и когда в программе имеется несколько потоков.
В итоге, можно сказать, что изменение интервала переключения — это один из способов исправления последствий эффекта сопровождения. Но, прибегая к этому способу, нужно внимательно оценивать то, как изменение интервала переключения влияет на производительность приложения.
Второй способ борьбы с эффектом сопровождения выглядит ещё более «хакерским», чем первый. Так как на одноядерных процессорах этот эффект проявляется гораздо слабее, чем на многоядерных, можно попытаться ограничить все Python-потоки использованием одного ядра. Это заставит операционную систему принимать решение о том, выполнение какого именно потока нужно запланировать, и потоки, производительность которых привязана к подсистеме ввода/вывода, получат приоритет.
Не каждая ОС даёт возможность привязать группу потоков к определённым ядрам. Насколько я понимаю, macOS предоставляет пользователям лишь механизм, позволяющий давать планировщику ОС подсказки. Механизм, который нам нужен, имеется в Linux. Это — функция pthread_setaffinity_np (). Она принимает поток и маску, описывающую ядра CPU, после чего сообщает ОС о том, что ей нужно планировать выполнение этого потока только на ядрах, заданных маской.
Pthread_setaffinity_np()
— это C-функция. Для того чтобы вызвать её из Python — можно использовать что-то вроде ctypes. Я не хотел связываться с ctypes
, поэтому просто модифицировал исходный код CPython. Затем я скомпилировал исполняемый файл, запустил эхо-сервер на Ubuntu-машине с двумя ядрами и получил следующие результаты:
Количество CPU-потоков | 0 | 1 | 2 | 4 | 8 |
RPS | 24,000 | 12,000 | 3,000 | 30 | 10 |
Сервер вполне нормально переносит наличие одного потока, производительность которого привязана к процессору. Но, так как поток, зависящий от подсистемы ввода/вывода, вынужден конкурировать со всеми 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 {cpu_list} 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 операция +=
не является атомарной, так как она состоит из нескольких инструкций байт-кода. Для того чтобы увидеть то, как это может привести к гонке данных, установим интервал переключен