Как стать автором
Обновить

GIL и его влияние на многопоточность Python

Время на прочтение24 мин
Количество просмотров14K
Автор оригинала: Victor Skvortsov

GIL расшифровывается как Global Interpreter Lock (Глобальная блокировка интерпретатора), и его задача состоит в том, чтобы сделать интерпретатор CPython потокобезопасным.

GIL позволяет только одному потоку ОС выполнять байт-код Python в любой момент времени. Следствием этого является невозможность ускорить выполнение кода Python с интенсивным использованием процессора путем распределения работы между несколькими потоками. 

Это, однако, не единственный негативный эффект. GIL вводит накладные расходы, которые замедляют работу многопоточных программ, и это может повлиять даже на потоки, связанные с вводом-выводом.

В этом посте я хотел бы рассказать вам больше о неочевидных эффектах GIL. По пути мы обсудим, что такое GIL на самом деле, почему он существует, как он работает и как он повлияет на параллелизм в будущих реализациях Python.

Примечание: В этом посте рассматривается CPython версии 3.9.

Потоки ОС, потоки Python и GIL

Когда вы запускаете python-исполняемый файл, ОС запускает новый процесс с одним потоком выполнения, называемым основным потоком. Как и в случае любой другой программы на языке Си, основной поток начинает выполнение с main() функции. Все, что делает основной поток дальше, можно свести к трем шагам:

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

  2. Компилирует код Python в байт-код;

  3. Запускает цикл выполнения байт-кода.

Основной поток-это обычный поток операционной системы, который выполняет скомпилированный код на языке Си. Его состояние включает значения регистров процессора и стек вызовов функций языка C. Однако поток Python должен захватывать стек вызовов функций Python, состояние исключений и другие вещи, связанные с Python. Итак, что делает CPython, так это помещает эти вещи в структуру состояния потока и связывает состояние потока с потоком ОС. Другими словами, Python thread = OS thread + Python thread state.

Цикл выполнения байт-кода - это бесконечный цикл, который содержит гигантский switch всех возможных инструкций байт-кода. Чтобы запустить цикл, поток должен удерживать GIL. Основной поток получает GIL во время инициализации. Когда он входит в цикл, то просто начинает выполнять инструкции байт-кода одну за другой.

Время от времени поток должен приостанавливать исполнение байт-кода. Он проверяет, есть ли какие-либо причины для этого в начале каждой итерации цикла. Нас интересует одна из таких причин: другой поток запросил GIL. Вот как эта логика реализована в коде:

PyObject*
_PyEval_EvalFrameDefault(PyThreadState *tstate, PyFrameObject *f, int throwflag)
{
    // ... declaration of local variables and other boring stuff

    // the evaluation loop
    for (;;) {

        // `eval_breaker` tells whether we should suspend bytecode execution
        // e.g. other thread requested the GIL
        if (_Py_atomic_load_relaxed(eval_breaker)) {

            // `eval_frame_handle_pending()` suspends bytecode execution
            // e.g. when another thread requests the GIL,
            // this function drops the GIL and waits for the GIL again
            if (eval_frame_handle_pending(tstate) != 0) {
                goto error;
            }
        }

        // get next bytecode instruction
        NEXTOPARG();

        switch (opcode) {
            case TARGET(NOP) {
                FAST_DISPATCH(); // next iteration
            }

            case TARGET(LOAD_FAST) {
                // ... code for loading local variable
                FAST_DISPATCH(); // next iteration
            }

            // ... 117 more cases for every possible opcode
        }

        // ... error handling
    }

    // ... termination
}

В однопоточной программе на Python основной поток является единственным потоком, и он никогда не выпускает GIL. Давайте теперь посмотрим, что происходит в многопоточной программе. Мы используем threading - стандартный модуль для запуска нового потока Python:

import threading

def f(a, b, c):
    # do something
    pass

t = threading.Thread(target=f, args=(1, 2), kwargs={'c': 3})
t.start()

Метод start() экземпляра Thread создает новый поток ОС. В Unix-подобных системах, включая Linux и macOS, для этой цели он вызывает функцию pthread_create (). Вновь созданный поток начинает выполнение функции t_bootstrap() с аргументом boot. Аргумент boot представляет собой структуру, содержащую целевую функцию, переданные аргументы и состояние потока для нового потока ОС. Функция t_bootstrap() выполняет ряд действий, но самое главное - она получает GIL, после чего запускает цикл выполнения байт-кода целевой функции.

Чтобы получить GIL, поток сначала проверяет, захвачен ли GIL каким-либо другим потоком. Если это не так, поток немедленно получает GIL. В противном случае он ждет, пока GIL не будет освобожден. Он ожидает фиксированного интервала времени, называемого интервалом переключения (по умолчанию 5 мс), и если GIL не будет выпущен в течение этого времени, он устанавливает флаги eval_breaker и gil_drop_request. Флаг eval_breaker указывает потоку, удерживающему GIL, приостановить выполнение байт-кода, а gil_drop_request объясняет, почему. Поток, удерживающий GIL, видит флаги, когда при запуске следующей итерации цикла, и освобождает GIL. Он уведомляет об этом потоки, ожидающие GIL, и один из них захватывает GIL. Какой именно поток получит GIL - зависит от операционной системы, так что это может быть поток, который установил флаги, а может быть и другой поток, также ожидающий GIL.

Это самый минимум того, что нам нужно знать о GIL. Позвольте мне теперь продемонстрировать его эффекты, о которых я говорил ранее.

Эффекты GIL

Первый эффект GIL хорошо известен: несколько потоков Python не могут работать параллельно. Таким образом, многопоточная программа не будет быстрее, чем ее однопоточный эквивалент, даже на многоядерной машине. В качестве наивной попытки распараллелить код Python рассмотрим следующую CPU-bound функцию, которая выполняет операцию уменьшения заданное количество раз:

def countdown(n):
    while n > 0:
        n -= 1

Теперь предположим, что мы хотим выполнить 100 000 000 декрементов. Мы можем запустить countdown(100_000_000) в одном потоке или countdown(50_000_000) в двух потоках, или countdown(25_000_000) в четырех потоках, и так далее. В языке без GIL, таком как C, мы бы увидели ускорение по мере увеличения количества потоков. Запустив Python на своем MacBook Pro с двумя ядрами и гиперпоточностью, я вижу следующее:

Количество потоков

Декрементов на поток (n)

Время в секундах (лучшее из 3)

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 применяет шаблон release-perform-acquire не только к операциям ввода-вывода, но и к другим блокирующим вызовам в ОС, таким как 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-bound - потоки, которые большую часть времени что–то вычисляют. И потоки I/O – потоки, которые большую часть времени ожидают ввода-вывода. Самый интересный эффект GIL имеет место, когда мы смешиваем их. Рассмотрим простой TCP эхо-сервер, который прослушивает входящие соединения и, когда клиент подключается, создает новый поток для обработки клиента:

from threading import Thread
import socket


def run_server(host='127.0.0.1', port=33333):
    sock = socket.socket()
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind((host, port))
    sock.listen()
    while True:
        client_sock, addr = sock.accept()
        print('Connection from', addr)
        Thread(target=handle_client, args=(client_sock,)).start()


def handle_client(sock):
    while True:
        received_data = sock.recv(4096)
        if not received_data:
            break
        sock.sendall(received_data)

    print('Client disconnected:', sock.getpeername())
    sock.close()


if __name__ == '__main__':
    run_server()

Сколько запросов в секунду может обрабатывать такой сервер? Я написал простую клиентскую программу, которая просто отправляет и получает 1-байтовые сообщения на сервер так быстро, как только может, и получил что-то около 30 тысяч RPS. Это, скорее всего, неточное число, поскольку клиент и сервер работают на одной машине, но суть не в этом. Смысл в том, чтобы увидеть, как RPS падает, когда сервер выполняет какую-либо CPU-bound задачу в отдельном потоке.

Рассмотрим точно такой же сервер, но с дополнительным потоком, который увеличивает и уменьшает переменную в бесконечном цикле (фактически, любая задача, связанная с процессором, будет делать что-то похожее):

# ... the same server code

def compute():
    n = 0
    while True:
        n += 1
        n -= 1

if __name__ == '__main__':
    Thread(target=compute).start()
    run_server()

Насколько, как вы думаете, изменится RPS? Слегка? Станет в 2 раза меньше? В 10 раз меньше? Нет. RPS падает до 100, что в 300 раз меньше! И это станет сюрпризом, если вы привыкли к тому, как операционные системы управляют потоками. Чтобы понять, что я имею в виду, давайте запустим сервер и CPU-bound поток, как отдельные процессы, чтобы на них не влиял GIL. Мы можем разделить код на два разных файла или просто использовать стандартный модуль multiprocessing для создания нового процесса. Например:

from multiprocessing import Process

# ... the same server code

if __name__ == '__main__':
    Process(target=compute).start()
    run_server()

И это дает около 20 тысяч RPS. Более того, если мы запустим два, три или четыре CPU-bound процесса, RPS останется примерно таким же. Планировщик ОС определяет приоритет I/O потока, что является правильным в данном случае.

В примере с сервером, I/O поток ожидает, пока сокет будет готов для чтения и записи, и производительность любого другого I/O потока будет снажаться соответственно. Рассмотрим поток пользовательского интерфейса, который ожидает ввода данных пользователем. Он будет регулярно зависать, если вы запустите его вместе с потоком, связанным с процессором. Очевидно, что это не похоже на то, как работают обычные потоки ОС, и причина в GIL. Это мешает работе планировщика операционной системы.

Эта проблема на самом деле хорошо известна среди разработчиков CPython. Они называют это эффектом конвоя. Дэвид Бизли выступил с докладом об этом в 2010 году, а также открыл соответствующий выпуск на bugs.python.org. В 2021 году, спустя 11 лет, этот вопрос был закрыт. Однако это не было исправлено. В остальной части этого поста мы попытаемся выяснить, почему.

Эффект конвоя

Эффект конвоя происходит потому, что каждый раз, когда поток, связанный с вводом-выводом, выполняет операцию ввода-вывода, он освобождает GIL, и когда он пытается повторно получить GIL, то GIL, скорее всего, уже будет занят потоком, связанным с процессором. Таким образом, поток, связанный с вводом-выводом, должен подождать не менее 5 мс, прежде чем он сможет установить eval_breaker и gil_drop_request и заставить поток, связанный с процессором, освободить GIL.

ОС может запланировать запуск CPU-bound потока, как только I/O поток выпустит GIL. Поток I/O может быть запланирован только после завершения операции ввода-вывода, поэтому у него меньше шансов захватить GIL первым. Если операция действительно быстрая , например, неблокирующий send(), шансы на самом деле довольно высоки, но только на одноядерной машине, где ОС должна решить, какой поток запустить.

На многоядерной машине ОС не нужно решать, какой из двух потоков запланировать. Он может планировать запуск и одного, и второго на разных ядрах. В результате поток, связанный с процессором, почти гарантированно получит GIL первым, и каждая операция ввода-вывода в I/O потоке стоит дополнительных 5 мс.

Обратите внимание, что поток, который вынужден освободить GIL, ждет, пока его не примет другой поток, поэтому I/O поток получает GIL после одного интервала переключения. Без этой логики эффект конвоя был бы еще более серьезным.

Итак, сколько стоит 5 мс? Это зависит от того, сколько времени занимают операции ввода-вывода. Если поток ожидает несколько секунд, пока данные в сокете не станут доступны для чтения, дополнительные 5 мс не имеют большого значения. Но некоторые операции ввода-вывода выполняются очень быстро. Например, send() блокируется только тогда, когда буфер отправки заполнен, и возвращается немедленно в противном случае. Поэтому, если операции ввода-вывода занимают микросекунды, то миллисекунды ожидания GIL могут оказать огромное влияние.

Эхо-сервер без CPU-bound потока обрабатывает 30 кб/с, что означает, что один запрос занимает около 1/30 кб ≈ 30 мкс. С CPU-bound потоком recv(), и send() добавляют дополнительные 5 мс = 5000 мкс к каждому запросу, и теперь один запрос занимает 10 030 мкс. Это примерно в 300 раз больше. Таким образом, пропускная способность в 300 раз меньше. Цифры совпадают.

Вы можете спросить: является ли эффект конвоя проблемой в реальных приложениях? Я не знаю. Я никогда не сталкивался с этим и не мог найти доказательств того, что это сделал кто-то другой. Люди не жалуются, и это одна из причин, по которой проблема не была устранена.

Но что, если эффект конвоя действительно вызывает проблемы с производительностью в вашем приложении? Вот два способа исправить это.

Исправление эффекта конвоя

Поскольку проблема заключается в том, что поток, связанный с вводом-выводом, ожидает интервала переключения, пока не запросит GIL, мы можем попытаться установить интервал переключения на меньшее значение. Python предоставляет функцию sys.setswitchinterval(interval) для этой цели. Аргумент interval представляет собой значение с плавающей точкой, представляющее секунды. Интервал переключения измеряется в микросекундах, поэтому наименьшее значение равно 0.000001. Вот RPS, который я получаю, если я изменяю интервал переключения и количество CPU-bound потоков:

Интервал переключения в секундах

RPS без потоков процессора

RPS с одним потоком 

RPS с двумя CPU-bound потоками 

RPS с четырьмя CPU-bound потоками 

0.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 увеличивается почти пропорционально, пока интервал переключения не станет слишком маленьким. Это связано с тем, что стоимость переключения контекста становится значительной.

Меньшие интервалы переключения делают потоки, связанные с вводом-выводом, более отзывчивыми. Но слишком малые интервалы переключения приводят к большим накладным расходам, вызванным большим количеством переключений контекста. Вспомните функцию 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(). Она принимает поток и маску ядер процессора, и сообщает ОС запланировать поток только на ядрах, указанных в маске.

pthread_setaffinity_np() является функцией C. Чтобы вызвать её из Python вы можете использовать что-то вроде ctypes. Я не хотел связываться ctypes, поэтому я просто изменил исходный код CPython. Затем я скомпилировал исполняемый файл, запустил эхо-сервер на двухъядерной машине Ubuntu и получил следующие результаты:

Количество CPU-bound потоков

0

1

2

4

8

RPS

24к

12к

30

10

Сервер может довольно хорошо переносить один поток, связанный с процессором. Но поскольку поток, связанный с вводом-выводом, должен конкурировать за GIL со всеми потоками, связанными с процессором, по мере добавления новых потоков производительность значительно падает. Это исправление больше похоже на взлом. Почему разработчики CPython просто не реализуют правильный GIL?

Обновление от 7 октября 2021 года: теперь я узнал, что ограничение потоков одним ядром помогает с эффектом конвоя только в том случае, если клиент ограничен одним и тем же ядром - именно так, как я настроил тест.

Правильный GIL

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

Правильное решение состоит в том, чтобы различать потоки. Поток I/O должен иметь возможность отобрать GIL у CPU-bound потока без ожидания, но потоки с одинаковым приоритетом должны ждать друг друга. Планировщик ОС умеет различать потоки, но вы не можете полагаться на него, потому что он ничего не знает о GIL. Похоже, что единственный вариант - реализовать логику планирования в интерпретаторе.

После того, как Дэвид Бизли открыл проблему, разработчики CPython предприняли несколько попыток ее решить. Сам Бизли предложил простой патч. Короче говоря, этот патч позволяет потоку, связанному с вводом-выводом, вытеснять поток, связанный с процессором. По умолчанию все потоки считаются связанными с вводом-выводом. Как только поток вынужден освободить GIL, он помечается как связанный с процессором. Когда поток добровольно освобождает GIL, флаг сбрасывается, и поток снова считается связанным вводом-выводом.

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

... Python действительно нуждается в планировщике, а не в блокировке.

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

У GIL никогда не было большой фанатской базы. То, о чём мы говорили сегодня, только усугубляет ситуацию. Мы возвращаемся к вечным вопросам.

Разве мы не можем убрать GIL?

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

Или, скажем, вы увеличиваете одну и ту же переменную из нескольких потоков. Если операция приращения не является атомарной и не защищена блокировкой, то конечное значение переменной может быть меньше общего числа приращений. Это типичная гонка данных:

  1. Поток 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 не является атомарным, поскольку компилятор преобразует такие операции в последовательность машинных инструкций. Потоки могут чередоваться между ними.

GIL полезен, потому что CPython увеличивает и уменьшает целые числа, которые могут быть разделены между потоками повсюду. Это способ CPython для сборки мусора. Каждый объект Python имеет счётчик ссылок, в котором подсчитывается количество других объектов, ссылающихся на этот объект. Когда количество ссылок достигает нуля, объект удаляется. Если бы не GIL, некоторые декременты могли бы перезаписать друг друга, и объект остался бы в памяти навсегда. Что еще хуже, несогласованные приращения счётчика ссылок могут привести к удалению ещё использующегося объекта.

GIL также упрощает реализацию встроенных изменяемых структур данных. Списки, словари и множества не используют блокировку, но благодаря GIL их можно безопасно использовать в многопоточных программах. Аналогично, GIL позволяет потокам безопасно получать доступ к глобальным данным и данным в масштабах интерпретатора: загруженным модулям, предварительно распределенным объектам и так далее.

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

Подводя итог, можно сказать, что GIL делает следующее потокобезопасным:

  1. подсчет ссылок;

  2. изменяемые структуры данных;

  3. глобальные и общесистемные данные;

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

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

Gilectomy может запустить код на Python и запустить его параллельно. Однако однопоточная производительность CPython была поставлена под угрозу. Только атомарные приращения и уменьшения добавляли около 30% накладных расходов. Гастингс попытался решить эту проблему, реализовав буферизованный подсчет ссылок. Короче говоря, этот метод ограничивает все обновления количества ссылок одним специальным потоком. Другие потоки фиксируют только приращения и уменьшения в журнале, а специальный поток считывает журнал. Это сработало, но накладные расходы все равно были значительными.

В конце концов, стало очевидно, что Gilectomy не будет объединена с CPython. Гастингс прекратил работу над проектом. Однако это не было полным провалом. Это научило нас, почему трудно удалить GIL из CPython. Есть две основные причины:

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

  2. Удаление GIL затрагивает существующие расширения на C. Нет никакого способа обойти это.

В наши дни никто всерьез не думает о том, чтобы убрать ГИЛ. Означает ли это, что мы должны жить с GIL вечно?

Будущее GIL и параллелизма Python

Это звучит пугающе, но гораздо более вероятно, что у CPython будет несколько GIL, чем вообще никакого GIL. Существует инициатива по внедрению нескольких GIL в CPython. Это называется “под-интерпретаторами”. Идея состоит в том, чтобы иметь несколько интерпретаторов в рамках одного и того же процесса. Потоки в одном интерпретаторе по-прежнему совместно используют GIL, но несколько интерпретаторов могут работать параллельно. Для синхронизации интерпретаторов не требуется GIL, поскольку у них нет общего глобального состояния и они не используют общие объекты Python. Все глобальное состояние создается для каждого интерпретатора, а интерпретаторы общаются только посредством передачи сообщений. Конечная цель состоит в том, чтобы представить в Python модель параллелизма, основанную на связывании последовательных процессов, найденных в таких языках, как Go и Clojure.

Интерпретаторы являются частью CPython начиная с версии 1.5, но только в качестве механизма изоляции. Они хранят данные, относящиеся к группе потоков: загруженные модули, встроенные модули, параметры импорта и так далее. Они не представлены в Python, но расширения на C могут использовать их через Python/C API. Некоторые действительно делают это. mod_wsgi является ярким примером.

Сегодняшние интерпретаторы ограничены тем фактом, что им приходится делить между собой GIL. Это может измениться только тогда, когда все глобальное состояние будет выполнено для каждого интерпретатора. Работа ведется в этом направлении, но несколько вещей остаются глобальными: некоторые встроенные типы, None, True и False, части распределителя памяти. Расширения на C также должны избавиться от глобального состояния, прежде чем они смогут работать с под-интерпретаторами.

Эрик Сноу написал PEP 554, который добавляет модуль interpreters в стандартную библиотеку. Идея состоит в том, чтобы предоставить существующим интерпретаторам C API к Python и предоставить механизмы связи между интерпретаторами. Предложение было нацелено на Python 3.9, но было отложено до тех пор, пока GIL не будет соответствующе модифицирован. Даже в этом случае успех не гарантирован. Вопрос заключается в том, действительно ли Python нуждается в другой модели параллелизма.

Еще один захватывающий проект, который реализуется в настоящее время, - это более быстрый CPython. В октябре 2020 года Марк Шеннон предложил план по ускорению CPython в 5 раз за несколько лет. И на самом деле это гораздо более реалистично, чем может показаться, потому что CPython обладает большим потенциалом для оптимизации. Добавление JIT само по себе может привести к огромному повышению производительности.

Подобные проекты были и раньше, но они провалились из-за отсутствия надлежащего финансирования или опыта. На этот раз Microsoft вызвалась спонсировать более быстрый CPython и позволила Марку Шеннону, Гвидо ван Россуму и Эрику Сноу работать над проектом и соответствующие изменения уже реализуются в CPython.

Более быстрый CPython фокусируется на однопоточной производительности. Команда не планирует менять или удалять GIL. Тем не менее, если проект увенчается успехом, одна из главных болевых точек Python будет устранена, и вопрос GIL может стать более актуальным, чем когда-либо.

P.S.

Контрольные показатели, используемые в этом посте, доступны на GitHub. Особая благодарность Дэвиду Бизли за его удивительные выступления. Беседы Ларри Хастингса о GIL и Gilectomy (раз, два, три) также были очень интересными. Чтобы понять, как работают современные планировщики ОС, я прочитал книгу Роберта Лава "Разработка ядра Linux". Очень рекомендую!

Если вы хотите изучить GIL более подробно, вам следует прочитать исходный код. Файл Python/ceval_gil.h - идеальное место для начала. Чтобы помочь вам в этом предприятии, я написал следующий раздел.

Детали реализации GIL *

Технически GIL - это флаг, указывающий, заблокирован ли GIL или нет, а также набор мьютексов и условных переменных, которые управляют установкой этого флага, и некоторые другие служебные переменные, такие как интервал переключения. Все эти вещи хранятся в структуре _gil_runtime_state:

struct _gil_runtime_state {
    /* microseconds (the Python API uses seconds, though) */
    unsigned long interval;
    /* Last PyThreadState holding / having held the GIL. This helps us
       know whether anyone else was scheduled after we dropped the GIL. */
    _Py_atomic_address last_holder;
    /* Whether the GIL is already taken (-1 if uninitialized). This is
       atomic because it can be read without any lock taken in ceval.c. */
    _Py_atomic_int locked;
    /* Number of GIL switches since the beginning. */
    unsigned long switch_number;
    /* This condition variable allows one or several threads to wait
       until the GIL is released. In addition, the mutex also protects
       the above variables. */
    PyCOND_T cond;
    PyMUTEX_T mutex;
#ifdef FORCE_SWITCHING
    /* This condition variable helps the GIL-releasing thread wait for
       a GIL-awaiting thread to be scheduled and take the GIL. */
    PyCOND_T switch_cond;
    PyMUTEX_T switch_mutex;
#endif
};

Структура _gil_runtime_state является частью глобального состояния. Он хранится в структуре _ceval_runtime_state, которая, в свою очередь, является частью _PyRuntimeState, к которому имеют доступ все потоки Python:

struct _ceval_runtime_state {
    _Py_atomic_int signals_pending;
    struct _gil_runtime_state gil;
};

typedef struct pyruntimestate {
    // ...
    struct _ceval_runtime_state ceval;
    struct _gilstate_runtime_state gilstate;

    // ...
} _PyRuntimeState;

Обратите внимание, что _gilstate_runtime_state это структура, отличная от _gil_runtime_state. В ней хранится информация о потоке, удерживающем GIL:

struct _gilstate_runtime_state {
    /* bpo-26558: Flag to disable PyGILState_Check().
       If set to non-zero, PyGILState_Check() always return 1. */
    int check_enabled;
    /* Assuming the current thread holds the GIL, this is the
       PyThreadState for the current thread. */
    _Py_atomic_address tstate_current;
    /* The single PyInterpreterState used by this process'
       GILState implementation
    */
    /* TODO: Given interp_main, it may be possible to kill this ref */
    PyInterpreterState *autoInterpreterState;
    Py_tss_t autoTSSkey;
};

Наконец, есть структура _ceval_state, которая является частью PyInterpreterState. В ней хранятся флаги eval_breaker и gil_drop_request:

struct _ceval_state {
    int recursion_limit;
    int tracing_possible;
    /* This single variable consolidates all requests to break out of
       the fast path in the eval loop. */
    _Py_atomic_int eval_breaker;
    /* Request for dropping the GIL */
    _Py_atomic_int gil_drop_request;
    struct _pending_calls pending;
};

Python/C API предоставляет функции PyEval_RestoreThread() и PyEval_SaveThread() для захвата и освобождения GIL. Эти функции также заботятся об установке gilstate->tstate_current. Под капотом вся работа выполняется функциями take_gil() и drop_gil(). Они вызываются потоком, удерживающим GIL, когда он приостанавливает выполнение байт-кода:

/* Handle signals, pending calls, GIL drop request
   and asynchronous exception */
static int
eval_frame_handle_pending(PyThreadState *tstate)
{
    _PyRuntimeState * const runtime = &_PyRuntime;
    struct _ceval_runtime_state *ceval = &runtime->ceval;

    /* Pending signals */
    // ...

    /* Pending calls */
    struct _ceval_state *ceval2 = &tstate->interp->ceval;
    // ...

    /* GIL drop request */
    if (_Py_atomic_load_relaxed(&ceval2->gil_drop_request)) {
        /* Give another thread a chance */
        if (_PyThreadState_Swap(&runtime->gilstate, NULL) != tstate) {
            Py_FatalError("tstate mix-up");
        }
        drop_gil(ceval, ceval2, tstate);

        /* Other threads may run now */

        take_gil(tstate);

        if (_PyThreadState_Swap(&runtime->gilstate, tstate) != NULL) {
            Py_FatalError("orphan tstate");
        }
    }

    /* Check for asynchronous exception. */
    // ...
}

В Unix-подобных системах реализация GIL опирается на примитивы, предоставляемые библиотекой pthreads. К ним относятся мьютексы и условные переменные. Короче говоря, они работают следующим образом. Поток вызывает pthread_mutex_lock(mutex) чтобы заблокировать мьютекс. Когда другой поток делает то же самое, он блокируется. ОС помещает его в очередь потоков, которые ожидают мьютекс, и запускает его, когда первый поток вызывает pthread_mutex_unlock(mutex). В каждый момент времени только один поток может запускать защищенный код.

Условные переменные позволяют одному потоку ждать, пока другой поток не выполнит какое-либо условие. Чтобы дождаться условной переменной, поток блокирует мьютекс и вызывает pthread_cond_wait(cond, mutex) или pthread_cond_timedwait(cond, mutex, time). Эти вызовы атомарно разблокируют мьютекс и блокируют поток. ОС помещает поток в очередь ожидания и пробуждает его, когда другой поток вызывает pthread_cond_signal(). Пробужденный поток снова блокирует мьютекс и продолжает работу. Вот как обычно используются условные переменные:

# awaiting thread

mutex.lock()
while not condition:
    cond_wait(cond_variable, mutex)
# ... condition is True, do something
mutex.unlock()

# signaling thread

mutex.lock()
# ... do something and make condition True
cond_signal(cond_variable)
mutex.unlock()

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

Функции take_gil()и drop_gil() используют условную переменную gil->cond для уведомления потоков, ожидающих GIL, о том, что GIL был освобожден, и gil->switch_cond для уведомления потока, удерживающего GIL, о том, что другой поток принял GIL. Эти условные переменные защищены двумя мьютексами: gil->mutex и gil->switch_mutex.

Вот шаги по take_gil() :

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

  2. Проверить gil->locked. Если не установлен, перейти к шагу 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, чтобы он освободил GIL: установить ceval->gil_drop_request и ceval->eval_breaker.

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

    1. Заблокировать мьютекс переключателя: pthread_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. Разблокировать мьютекс: pthread_mutex_unlock(&gil->switch_mutex).

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

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

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

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

Наконец, вот шаги по 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. Заблокировать мьютекс: pthread_mutex_lock(&gil->switch_mutex).

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

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

Обратите внимание, что потоку, освобождающему GIL, не нужно ждать условия в цикле. Он вызывает pthread_cond_wait(&gil->switch_cond, &gil->switch_mutex) только для того, чтобы убедиться, что он не получит GIL немедленно обратно. Если произошел переход, это означает, что другой поток взял GIL, и можно снова побороться за GIL.

Если у вас есть какие-либо вопросы, замечания или предложения, не стесняйтесь обращаться ко мне по адресу victor@tenthousandmeters.com

Обновление от 7 октября 2021 года: [1] Ограничение потоков одним ядром на самом деле не устраняет эффект конвоя. Да, это заставляет ОС выбирать, какой из двух потоков запланировать, что дает потоку, связанному с вводом-выводом, хороший шанс повторно получить GIL при операции ввода-вывода, но если операция ввода-вывода блокируется, это не помогает. В этом случае поток, связанный с вводом-выводом, не готов к планированию, поэтому ОС планирует поток, связанный с процессором.

В примере с эхо–сервером фактически каждый recv() блокирующий - сервер ждет, пока клиент прочитает ответ и отправит следующее сообщение. Ограничение потоков одним ядром не сможет помочь. Но мы увидели, что RPS улучшился. Почему? Это потому, что эталон ошибочен. Я запустил клиент на той же машине и на том же ядре, что и потоки сервера. Такая настройка вынуждает ОС выбирать между потоком, связанным с процессором сервера, и потоком клиента, когда поток, связанный с вводом-выводом сервера, блокируется на recv(). Поток клиента, скорее всего, будет запланирован. Он отправляет следующее сообщение и блокируется на recv() тоже. Но теперь поток ввода-вывода сервера готов и конкурирует с потоком, связанным с процессором. 

Кроме того, вам не нужно изменять исходный код CPython или возиться с ctypes, чтобы ограничить потоки Python определенными ядрами. В Linux функция pthread_setaffinity_np() реализована поверх системного вызова sched_setaffinity(), и стандартный модуль os предоставляет этот системный вызов Python. Спасибо Карлу Бордуму Хансену за то, что указал мне на это.

Существует также команда taskset, которая позволяет установить соответствие процессора процессу, вообще не касаясь исходного кода. Просто запустите программу вот так:

$ taskset -c {cpu_list} python program.py

Обновление от 16 октября 2021 года: Сэм Гросс недавно объявил о своём форке CPython, который удаляет GIL. Вы можете думать об этом проекте как о Gilectomy 2.0: он заменяет GIL альтернативными механизмами безопасности потоков, но, в отличие от Gilectomy, не делает однопоточный код намного медленнее. Фактически, Гросс оптимизировал интерпретатор таким образом, чтобы однопоточная производительность его форка без GIL стала даже быстрее, чем у основного CPython 3.9.

Теги:
Хабы:
Всего голосов 1: ↑1 и ↓0+1
Комментарии3

Публикации

Истории

Работа

Data Scientist
56 вакансий
Python разработчик
125 вакансий

Ближайшие события

Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн
Антиконференция X5 Future Night
Дата30 мая
Время11:00 – 23:00
Место
Онлайн
Конференция «IT IS CONF 2024»
Дата20 июня
Время09:00 – 19:00
Место
Екатеринбург