Как вы, наверное, знаете, глобальная блокировка интерпретатора (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(). Следующие действия главного потока могут быть сведены к трём шагам:

  1. Инициализация интерпретатора.

  2. Компиляция Python-кода в байт-код.

  3. Вход в вычислительный цикл для выполнения байт-кода.

Главный поток — это обычный поток операционной системы, который выполняет скомпилированный 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 все операции ввода/вывода реализованы с использованием следующего паттерна:

  1. Освобождение GIL.

  2. Выполнение операции, например, write()recv()accept().

  3. Захват 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. Поток №1 читает значение переменной x.

  2. Поток №2 читает значение переменной x.

  3. Поток №1 записывает в переменную значение, равное x + 1.

  4. Поток №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 направлены на то, чтобы сделать потокобезопасными следующие механизмы и сущности:

  1. Подсчёт ссылок.

  2. Мутабельные структуры данных.

  3. Глобальные данные и данные, имеющие отношение к интерпретатору.

  4. C-расширения.

Для того чтобы убрать GIL и при этом не нарушить работу интерпретатора, нужно найти альтернативный механизм для обеспечения потокобезопасности. Попытки сделать это уже предпринимались. Наиболее заметная такая попытка представлена проектом Gilectomy Ларри Хастингса, работа над которым началась в 2016 году. Хастингс сделал форк CPython, убрал GIL, модифицировал механизм подсчёта ссылок с использованием атомарных операций инкрементирования и декрементирования переменных и разместил в коде множество тонко настроенных блокировок для защиты мутабельных структур данных и данных интерпретатора.

В рамках проекта Gilectomy можно было запускать Python-код, код мог работать и в параллельном режиме. Но при этом пострадала производительность однопоточных программ. Одни только атомарные операции инкрементирования и декрементирования переменных стали причиной 30%-го увеличения дополнительной нагрузки на систему. Хастингс попытался решить эту проблему, реализовав буферизованный подсчёт ссылок. Если в двух словах, то при таком подходе все операции по изменению переменных, соответствующих количеству ссылок на объекты, передаются одному специализированному потоку. Другие потоки лишь записывают сведения об инкрементировании или декрементировании подобных переменных в журнал, а особый поток читает данные из этого журнала. Этот механизм оказался рабочим, но и после его внедрения дополнительная нагрузка на систему всё ещё была очень высокой.

В итоге стало очевидным то, что код проекта Gilectomy не попадёт в CPython. Хастингс прекратил работу над этим проектом. Но Gilectomy нельзя назвать совершенно бесполезным делом. Этот проект дал ответ на вопрос о том, почему так трудно убрать GIL из CPython. А именно, речь идёт о двух основных причинах такой ситуации:

  1. Сборка мусора, основанная на подсчёте ссылок, не предназначена для многопоточных сред. Единственное решение этой задачи заключается в реализации системы сборки мусора, основанной на определении достижимости объекта. Подобные механизмы уже реализованы в JVM, в CLR, в Go и в других средах выполнения кода, в которых не используется GIL.

  2. Избавление от 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. Это может измениться только тогда, когда всё, имеющее отношение к глобальному состоянию, будет ограничено пределами отдельного интерпретатора. В этом направлении ведётся работа, но кое-что ещё остаётся глобальным: некоторые встроенные типы, синглтоны вроде NoneTrue и 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():

  1. Блокировка мьютекса GIL: pthread_mutex_lock(&gil->mutex).

  2. Проверка того, осуществлён ли захват GIL (gil->locked). Если ничто не захватило GIL — переход к шагу №4.

  3. Ожидание освобождения GIL. Пока истинно gil->locked:

    1. Запомнить gil->switch_number.

    2. Подождать, пока поток, удерживающий GIL, освободит GIL: pthread_cond_timedwait(&gil->cond, &gil->mutex, switch_interval).

    3. Если вышло время тайм-аута, а значения gil->locked и gil->switch_number не изменились, попросить поток, удерживающий GIL, освободить блокировку: установить флаги ceval->gil_drop_request и ceval->eval_breaker.

  4. Захватить GIL и уведомить поток, удерживающий GIL, о том, что мы захватили блокировку:

    1. Заблокировать мьютекс switch_mutexpthread_mutex_lock(&gil->switch_mutex).

    2. Установить gil->locked.

    3. Если наш поток — это не поток, записанный в gil->last_holder, обновить значение gil->last_holder и инкрементировать gil->switch_number.

    4. Уведомить поток, освобождающий GIL, о том, что мы захватили GIL: pthread_cond_signal(&gil->switch_cond).

    5. Разблокировать мьютекс switch_mutexpthread_mutex_unlock(&gil->switch_mutex).

  5. Сбросить ceval->gil_drop_request.

  6. Пересчитать ceval->eval_breaker.

  7. Разблокировать мьютекс GIL: pthread_mutex_unlock(&gil->mutex).

Обратите внимание на то, что пока поток ожидает GIL, блокировку может захватить другой поток, поэтому для того чтобы убедиться в том, что потоку, который только что захватил GIL, не придётся принудительно освобождать блокировку, необходимо проверять значение переменной gil->switch_number.

И, наконец, разберём работу drop_gil():

  1. Заблокировать мьютекс GIL: pthread_mutex_lock(&gil->mutex).

  2. Сбросить gil->locked.

  3. Уведомить поток, ожидающий GIL о том, что мы освободили GIL: pthread_cond_signal(&gil->cond).

  4. Разблокировать мьютекс GIL: pthread_mutex_unlock(&gil->mutex).

  5. Если установлен флаг ceval->gil_drop_request, подождать, пока другой поток захватит GIL:

    1. Заблокировать мьютекс switch_mutexpthread_mutex_lock(&gil->switch_mutex).

    2. Если мы всё ещё записаны в gil->last_holder, подождать: pthread_cond_wait(&gil->switch_cond, &gil->switch_mutex).

    3. Разблокировать мьютекс switch_mutexpthread_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 разработке для увлеченных исследователей и программистов. Гибкий график и никакой бюрократии, решения быстро принимаются и воплощаются в жизнь.

Сейчас мы ищем плюсовиков, питонистов, дата-инженеров и мл-рисерчеров.
Присоединяйтесь к нашей команде.

Комментарии (12)


  1. sebres
    01.11.2021 21:02

    Как-то пытался объяснять последствия изоляции всего и вся GIL-ом в python, и накидал маленький пример для наглядности:

    https://gist.github.com/sebres/230c4bfafc36c99074202dc59b194a95

    Если что, в приведенном примере ("тупой" инкремент в цикле 100M раз) для 4-х потоков, python медленнее вашего любимого языка без GIL tcl в 15 раз (35 сек. vs. 2.3 сек.), а однопоточно более чем в 3 раза.
    И это при том, что собственно код исполнения не "пересекается" нигде - не использует общих (shared) объектов.


    1. Murtagy
      09.11.2021 14:11
      -1

      CPU-bound вычисления в питоне выполняются однопроцессно.
      Странный пример.
      Примерно как дергать API для уже известного значения (только в данном случае переключать поток)


      1. sebres
        09.11.2021 15:36

        Что, простите?!

        CPU-bound вычисления в питоне выполняются однопроцессно

        Нет! Это издержки GIL. Если лень смотреть в исходники, просто см. результат для многопоточного исполнения (натяжение "ручника" зависит от количества потоков, что при N threads < M CPU core однозначно указывает на overhead от "чрезмерной" блокировки).

        Странный пример...

        Пример как пример... Можно попробовать что-нибудь другое (не "CPU-bound"), результат не изменится. А можно попробовать что-нибудь без GIL (Iron, PyPy STM, хоть тот же PoC Сэма) и узреть разницу.

        только в данном случае переключать поток

        Никакой поток тут нигде не переключается (напрямую)... каждый поток исполняет собственный изолированный код (с собственным циклом и своими переменными - с полностью независымыми объектами PyObjectи PyVarObject) и context-switch если и происходит, то исключительно на lock-ах в GIL (совершенно не нужном здесь, т.к. пересечений и shared references нет совсем).
        Пример собственно это и показывает.


        1. Murtagy
          09.11.2021 15:51
          -1

          Ну попробуйте не CPU bound сделать - жажду увидеть 4 потока медленнее одного, продемонстрируйте...
          Издержки издержками, но в текущей архитектере CPU bound вычисления выполняются однопроцессно или мультипроцессно, использовать треды для этого - это просто потеря производительности, тк на ядра они не раскидываются.
          Зная это писать такой код и говорить какой питон плохой - странно. Ведь это просто неправильно использовать инструмент, о чем вам явно говорит документация:

          https://docs.python.org/3/library/threading.html
          If you want your application to make better use of the computational resources of multi-core machines, you are advised to use multiprocessing or concurrent.futures.ProcessPoolExecutor




          1. sebres
            09.11.2021 17:03

            Ну попробуйте не CPU bound сделать - жажду увидеть 4 потока медленнее одного, продемонстрируйте...

            Любой код где исполнение касается GIL-защищенных объектов будет сваливаться в бесконечный lock. Если вы говорите про "абстрактный" C-модуль, где всё вычисление происходит исключительно в С (без дерганья GIL-related primitives), и только результат помещается в питоний объект, то вы ошиблись статьей - здесь про архитектуру python, а не про вызов "сторонних" библиотек из питона. И overhead будет тем больше, чем больше обвязанных GIL объектов ваш код затронет (т.е. чем короче длинна той стрелки в связке python --> C --> python, и чем короче исполнение собственно в C-модуле).

            в текущей архитектере CPU bound вычисления выполняются однопроцессно или мультипроцессно...

            Зная это писать такой код и говорить какой питон плохой - странно.

            У вас простите причинно-следственная связь нарушена. От слова совсем.

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

            Ну и простейший пример (на подумать) - если добавить флаг состояния объекта типа IsThreadShared, и исключить блокировку GIL-ом на объектах, у которых оно false(а еще лучше у всего bytecode и части evaluation stack, которая затронута тем кодом), то тот пример будет выполнен в 3 раза быстрее однопоточно и 15 раз быстрее в 4 потока. Тоже самое будет если просто тупо "выключить" GIL (просто в качестве теста), т.е. на стадии компиляции python переопределив stable ABI PyGILState_Ensure, PyGILState_Release, PyGILState_Checkи иже с ними.

            Тема же multiprocessing vs. multithreaded совершенна ортогональна тут и у меня простите здесь обсуждать это с вами нет ни малейшего желания.


            1. Murtagy
              10.11.2021 09:42

              Прощаю.


  1. loltrol
    02.11.2021 00:34
    +3

    Интересно причинно-следственную связь в появлении GIL проследить. Решили рулить память с помощью счетчика ссылок, и обошли проблемы синхронизации GILом? Или что то другое послужило такому решению?


    1. funca
      03.11.2021 22:12

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


  1. a-tk
    02.11.2021 22:48
    +3

    Всё равно не понял, каким образом GIL решает проблему гонки для неатомарных операций вроде += 1

    Если блокировка захватывается на время выполнения опкода и может быть освобождена между опкодами, то где решение?


    1. Zada
      03.11.2021 16:16

      Это атомарный опкод с точки зрения Python. UNARY_POSITIVE. Но это не атомарная операция с точки зрения машинных инструкций.

      В том большом switch, не может произойти взятия/освобождения GIL пока опкод не выполнится полностью.

      Если бы GIL небыло, то выполняя UNARY_POSITIVE в одном треде, ОС могла бы прервать его выполнение и тем самым нарушив атомарность.

      При GIL, даже если ОС переключит поток во время выполнения опкода, GIL остаётся заблокированным и никакой другой поток не сможет его взять и нарушить наши данные. После возврата ОС на данный поток, мы продолжим выполнять нашу атомарную операцию. И на следующей итерации бесконечного цикла будем проверять надо ли ещё кому-то GIL или нет.


      1. Zada
        16.11.2021 20:13

        Так, спустя много времени, мне указали на ошибочность моего рассуждения. UNARY_POSITIVE это не то что я думал. += это INPLACE_ADD и для выполнения a += 1 будет использовано минимум 3 опкода.

        Так что мой ответ выше - бред и не правда.


  1. ayevdoshenko
    08.11.2021 08:59
    +1

    Спасибо за статью - познавательно. Однако, что касается сути вопроса - а разве не общепринят подход: multithreading - для кода ввода/вывода, multiprocessing - для CPU-bound?