В стандартной библиотеке языка Python имеется базовый примитив синхронизации — реентерабельная блокировка. Она позволяет одному и тому же потоку, несколько раз захватить блокировку. Стандартная реализация может использовать для блокировки мьютекс или семафор, и их захват всегда приводит к вызову функции из ядра ОС.

Программист и его воображаемые друзья закрывают замок дважды...
Программист и его воображаемые друзья закрывают замок дважды...

Используя GIL (Global Interpreter Lock — Глобальная блокировка интерпретатора) и особенности реализации Threading.Lock.release можно создать более быстрый вариант, который захватывает реальный объект блокировки только, если есть конкурирующие потоки. Если потоки работаю последовательно, то описанный в статье вариант будет иметь преимущество в производительности.

Оригинальная идея описана на сайте рецептов для Python, позднее автор разместил её реализацию в репозитории на гитхабе. В h5py, с которым я плотно работал, используется свой вариант. И fastrlock и h5py реализованы с помощью Cython (Cython — оптимизирующий компилятор, который транслирует код на языках Python и Cython в C/C++ и выполняет его компиляцию), но можно сделать аналогичный объект только на Python C‑API, я покажу ниже как.

Если вас это заинтересовало, давайте попробуем разобраться.

Скрытый текст

Если посмотреть назад, то всё выглядит довольно просто, но когда я в первый раз с этим разбирался, то получил удовольствие от элегантного решения.

Оглавление

GIL как критическая секция

Когда я выше сказал, что мы работаем на уровне Python C‑API, я подразумевал, что тем или иным способом мы имеем дело с модулем расширения (extension module) — будет он написан на C или с использованием Cython — не суть важно. Любые вызовы в модуль расширения в Python защищены GIL, пока мы не завершим вызов или явно не освободим его. И этим можно воспользоваться для наших целей.

Для реализации быстрой блокировки, нам нужен объект со следующим состоянием:

typedef struct {
  PyObject_HEAD
  PyThread_type_lock lock; // наш объект для реальной блокировки
  PyThread_ident_t owner_tid; // ID потока, который "захватил" быструю блокировку
  unsigned long count; // количество повторных захватов быстрой блокировки
  unsigned long pending_count; // количество "ожиданий" захвата быстрой блокировки (я напишу ниже подробнее)
  uint8_t is_locked; // захвачена ли реальная блокировка
} fastrlockobject;

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

Захват блокировки в отсутствии конкурирующих потоков

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

if (0 == self->count && 0 == self->pending_count)
{
	self->owner_tid = PyThread_get_thread_ident();
	self->count = 1;
	Py_RETURN_TRUE;
}
if (count > 0)
{
	PyThread_ident_t id = PyThread_get_thread_ident();
	if (id == self->owner_tid)
	{
		self->count += 1;
		Py_RETURN_TRUE;
	}
}

Если у нас изначально счётчик равен 0, то нам необходимо проверить, нету ли у нас ожидающих потоков, которые хотят нашу объект захватить. Если таких потоков нет, то мы можем спокойно выполнить захват. Если же есть, то это значит, что у нас сценарий с несколькими потоками и кто‑то ждёт на блокировке.

Захват блокировки, когда появляется несколько потоков

Так как у нас появился новый поток, то он попробует захватить реальный объект блокировки. Что это значит? Если счётчик count не равен нулю, т. е. у нашего объекта есть «хозяин», но он не выполнил захват реальной блокировки, то новый поток попробует её захватить. В случае успеха реальный «хозяин» продолжит владеть блокировкой (а в случае неуспеха мы выбросим исключение, о чём говорит return nullptr), но позже новый поток сможет перехватить блокировку, когда «хозяин» её освободит.

if (0 == self->is_locked && 0 == self->pending_count)
{
	if (0 == PyThread_acquire_lock(self->lock, WAIT_LOCK))
	{
		return NULL;
	}
	self->is_locked = 1;
}

Запомним, что мы выставили флаг, означающий, что реальный объект блокировки захвачен.

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

self->pending_count += 1;
int32_t acquired = 1;

Py_BEGIN_ALLOW_THREADS;
acquired = PyThread_acquire_lock(self->lock, WAIT_LOCK);
Py_END_ALLOW_THREADS;

self->pending_count -= 0;
if (0 == acquired)
{
	return NULL;
}

self->is_locked = 1;
self->count = 1;
self->owner_tid = PyThread_get_thread_ident();

Py_RETURN_TRUE;

Мы увеличиваем счётчик ожиданий и выходим из‑под защиты GIL и снова пытаемся захватить реальный объект блокировки. Так как lock у нас не реентерабельный, то это приведёт к взаимной блокировке самого себя. Выйти из этой ситуации помогает освобождение GIL — что позволит другим потокам далее выполнять свою работу, пока наш новый поток сам себя заблокировал.

А что будут делать остальные потоки — есть два варианта:

  1. Если поток не является «хозяином», то он будет проходить все проверки выше и блокироваться на попытке захватить реальный lock.

  2. Если поток является «хозяином» — то, он либо будет и далее успешно захватывать наш объект (т.к. пройдёт по условию count < 0 и thread_id == owner_tid), либо будет освобождать блокировку, что рано или поздно приведёт к освобождению lock.

Освобождение блокировки

Выше я говорил, что помимо GIL этот алгоритм опирается на особенность Threading.Lock.release, заключается она в следующем:

Release a lock. This can be called from any thread, not only the thread which has acquired the lock.

When the lock is locked, reset it to unlocked, and return. If any other threads are blocked waiting for the lock to become unlocked, allow exactly one of them to proceed.

Как вы уже могли догадаться — захваченный новым потоком‑кандидатом реальный объект блокировки может освободить только «хозяин» блокировки, когда его счётчик дойдёт до нуля.

if (--self->count == 0)
{
	self->owner_tid = 0;
	if (1 == self->is_locked)
	{
		PyThread_release_lock(self->lock);
		self->is_locked = 0;
	}
}

Py_RETURN_NONE;

В этом случае один из новых потоков‑кандидатов, который ожидает на PyThread_acquire_lock, разблокируется, захватит её и станет новым хозяином.

Под катом полный текст этих двух функций
static PyObject *
fastrlock_acquire(fastrlockobject *self, PyObject *args, PyObject *kwds)
{
	if (0 == self->count && 0 == self->pending_count)
	{
		self->owner_tid = PyThread_get_thread_ident();
		self->count = 1;
		Py_RETURN_TRUE;
	}
	if (count > 0)
	{
		PyThread_ident_t id = PyThread_get_thread_ident();
		if (id == self->owner_tid)
		{
			self->count += 1;
			Py_RETURN_TRUE;
		}
	}
	if (0 == self->is_locked && 0 == self->pending_count)
	{
		if (0 == PyThread_acquire_lock(self->lock, WAIT_LOCK))
		{
			return NULL;
		}
		self->is_locked = 1;
	}
	self->pending_count += 1;
	int32_t acquired = 1;
	
	Py_BEGIN_ALLOW_THREADS;
	acquired = PyThread_acquire_lock(self->lock, WAIT_LOCK);
	Py_END_ALLOW_THREADS;
	
	self->pending_count -= 0;
	if (0 == acquired)
	{
		return NULL;
	}
	
	self->is_locked = 1;
	self->count = 1;
	self->owner_tid = PyThread_get_thread_ident();
	
	Py_RETURN_TRUE;
}

static PyObject *
fastrlock_release(fastrlockobject *self, PyObject *Py_UNUSED(ignored))
{
	if (--self->count == 0)
	{
		self->owner_tid = 0;
		if (1 == self->is_locked)
		{
			PyThread_release_lock(self->lock);
			self->is_locked = 0;
		}
	}
	
	Py_RETURN_NONE;
}

Вот в общем то и всё — довольно простое и элегантное решение, которое пользуется особенностями Python. А есть ли у этого всего смысл — давайте посмотрим.

Сравнение производительности

Автор предлагает следующие тестовые сценарии:

  1. lock_unlock — последовательность из пяти захватов‑освобождений блокировки

  2. reentrant_lock_unlock — пять последовательных захватов блокировки и затем такое же количество разблокировок.

  3. mixed_lock_unlock — смешанный порядок захватов и освобождений, в том числе с повторными захватами.

  4. lock_unlock_nonblocking — аналогично варианту lock_unlock, только в режиме без ожидания (в описанном варианте с использованием Python C‑API я реализовал только с ожиданием, для упрощения реализации).

  5. context_manager — аналогичный вариант mixed_lock_unlock, только реализованный с использованием контекстного менеджера.

И две группы тестов:

  1. sequential — запускается 1 поток.

  2. threaded 10T — запускается 10 потоков.

Во время тестирования под Windows 11 x64 (11th Gen Intel® Core™ i5–11 600K @ 3.90GHz), на версии Python 3.8.+ я получил следующие результаты:

Тестовый сценарий

RLock (3.8.13), msec

FastRLock (0.8.2), msec

FastRLock (2.10.0, h5py), msec

sequential (x100000)

lock_unlock

56.2

30.93

35.22

reentrant_lock_unlock

47.09

30.43

36.11

mixed_lock_unlock

52.48

32.85

38.61

lock_unlock_nonblocking

67.67

30.86

42.59

context_manager

215.37

137.26

179.33

threaded 10T (x1000)

lock_unlock

1008.61

1014.97

1019.77

reentrant_lock_unlock

1003.81

1032.95

1007.53

mixed_lock_unlock

1002.43

1000.82

1002

lock_unlock_nonblocking

1007.93

1015.65

1000.99

context_manager

1030.02

1074.24

1026.42

Как можно видеть, в тестовых однопоточных сценариях fastrlock обгоняет стандартный RLock (в худшем случае около 15 процентом, в лучшем на 40 процентов).

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

Когда это может быть нужно? Современная тенденция в разработке ПО нацелена на создание многопоточных приложений, для повышения скорости обработки информации или снижения задержек в пользовательском интерфейсе. Но есть сценарии, когда мы должны защитить ресурс, который не умеет работать в многопоточном окружении. И примером может послужить h5py — Python интерфейс для файлов данных в формате HDF5. Для защиты файлов от повреждений и упрощения самой библиотеки h5py использует fastrlock при реализации своего программного интерфейса.

Рекомендации

  1. Если вы хотите использовать fastrlock, то обязательно протестируйте для своей версии Python и своего окружения.

  2. Выбирайте fastrlock, если в ваших рабочих сценариях чаще используется однопоточный доступ (при этом возможен доступ из нескольких потоков).

Сравнение со стандартной реализацией

Рассмотрим версию 3.8.13 (но можно взять любую версию на ветке 3 — от 3.2 до 3.13).
Можем видеть, что стандартный RLock использует примерно такое же состояние как у нас, не хватает только количества ожидающих потоков:

typedef struct {
    PyObject_HEAD
    PyThread_type_lock rlock_lock;
    unsigned long rlock_owner;
    unsigned long rlock_count;
    PyObject *in_weakreflist;
} rlockobject;

В случае когда блокировка уже захвачена нами, реализация тоже совпадает с нашей:

tid = PyThread_get_thread_ident();
if (self->rlock_count > 0 && tid == self->rlock_owner) {
	unsigned long count = self->rlock_count + 1;
	if (count <= self->rlock_count) {
		PyErr_SetString(PyExc_OverflowError, "Internal lock count overflowed");
		return NULL;
	}
	self->rlock_count = count;
	Py_RETURN_TRUE;
}

А вот если блокировка не захвачена или захвачена не нами, то происходит захват (или попытка захвата) реального объекта блокировки, что и может объяснить разницу в производительности:

r = acquire_timed(self->rlock_lock, timeout);
if (r == PY_LOCK_ACQUIRED) {
	assert(self->rlock_count == 0);
	self->rlock_owner = tid;
	self->rlock_count = 1;
}
else if (r == PY_LOCK_INTR) {
	return NULL;
}

return PyBool_FromLong(r == PY_LOCK_ACQUIRED);

acquire_timed пытается захватить реальную блокировку, используя такой же приём с освобождением GIL, как описан выше.

Python 3.14+

Какое же будущее нас ждёт? Новые версии Python планируют отказаться от использования GIL, PEP 703 посвящён рационализации решения и там же описаны примеры сценариев, когда GIL очень мешает. С этой целью в прошлом году был сделан коммит — gh-108 724: Add PyMutex and _PyParkingLot APIs (gh-109 344) · python/cpython@0c89 056, который лёг в основу более легковесной реализации блокировок.

Совсем недавно (14 октября 2024 года) был сделан коммит gh-125139: use _PyRecursiveMutex in _thread.RLock (#125144) · python/cpython@67f6e08, который добавил реализацию RLock с использованием легковесного lock‑free мьютекса, что теоретически должно быть быстрее, чем варианты на основе объекта ядра (мьютекса или семафора) и тогда применение fastrlock будет иметь смысл, только на версиях Python до 3.14.

Но для некоторых проектов это может быть реальностью ещё на ближайшие лет пять...

Спасибо за внимание.

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


  1. ednersky
    03.11.2024 06:47

    Слушайте, ну вот зачем тащить в скриптовый язык треды? Ведь это изврат!

    Ладно бы в приложениях это делали, но постоянно на это натыкаешься в библиотеках. Вроде бы на чистом asyncio что-то написал, потом однажды заглянешь в ps а там... и начинаешь раскопки: кто?, когда?, где?, зачем?

    и конечно же эти треды оказываются в самом ненужном месте - каком-нибудь парсере.

    Блин.


    1. zzzzzzerg Автор
      03.11.2024 06:47

      Тут наверное можно долго спорить для чего подходит Python.

      На нем можно решать CPU-intensive задачи, да не самим питоном, но внешними библиотеками (например, numba, numpy, scipy, pandas, py-arrow и т.д.) либо разрабатывать свои собственные расширения (на C, C++, Rust) и такого рода задачи вполне можно пускать в отдельных потоках, управляя ими из приложения, написанного на Python.

      К тому же, asyncio появился только в 3.4, а есть проекты, которые ведут свою историю раньше, и в которых такого рода задачи надо решать.


      1. ednersky
        03.11.2024 06:47

        слушайте, вот есть физика

        две функции и переменная

        x = 10
        
        def inc():
            x = x + 1
        
        def dec():
            x = x - 1

        этот же вопрос любому понятен: если функции вызываются из разных процессов (=потоков), то без мютексов такой код будет работать только если переменная x может меняться атомарно. То есть на C с атомарными переменными подобный код писать можно, да.

        Но в ЛЮБОМ скриптовом языке переменная - это структура. Её атомарно не заменить. Соответственно присвоение нового значения = присвоение нескольких полей структуры = изначально неатомарное действие.

        А потому язык, допускающий подобный синтаксис будет или иметь GIL на борту или приводить к UB.

        В общем случае гарантировать отсутствие крешей при UB очень сложно, а потому GIL из питона не уйдёт. НИКОГДА.

        то есть записываем: GIL в питоне ВСЕГДА будет мешать тредам.

        Теперь вопрос: а как же тогда масштабироваться по процессам?

        Правильный ответ: POSIX::fork и POSIX::IPC

        Возможный правильный ответ: запрет на уровне языка шарить переменные между тредами - но сюда разработчики Python не пошли

        Соответственно, применение тредов в Python - полумера, признак слабой компетентности.

        Как-то так


        1. zzzzzzerg Автор
          03.11.2024 06:47

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

          Вам же я процитирую PEP703, который вы не читали, как и код, на который даны ссылки:

          The Steering Council accepts PEP 703, but with clear proviso: that the rollout be gradual and break as little as possible, and that we can roll back any changes that turn out to be too disruptive – which includes potentially rolling back all of PEP 703 entirely if necessary (however unlikely or undesirable we expect that to be).


          1. ednersky
            03.11.2024 06:47

            Вам же я процитирую PEP703, который вы не читали, как и код, на который даны ссылки

            эм, очень часто разработчики что-то делают, ПОНИМАЯ, что так делать нельзя. Ну вот вы и процитировали пример.

            как можно отменить GIL? Только отменив треды как таковые.

            это будет очень правильное решение, но на это, увы, никто не пойдёт.


            1. zzzzzzerg Автор
              03.11.2024 06:47

              Я вам ваше, в своем первом комментарии, написал, какие есть варианты написания многопоточного кода на Python, который мог бы заниматься осмысленной работой и при этом GIL ему бы не мешал. У меня складывается мнение, что вы не понимаете как работает GIL в Python и зачем он нужен.

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

              Если вы готовы это конструктивно обсуждать (в чем я не уверен), мы можем даже посмотреть какие-то примеры кода.

              Возвращаясь к вашему первому примеру, вы там сделали правильную ремарку То есть на C с атомарными переменными - т.е. вы вполне понимаете, что даже на С++ код `x = x + 1` выполнится атомарно, только если x имеет тип, например, std::atomic<int32_t>. Тогда почему вы отказываете питону возможность реализовать ту же операцию с использованием таких же "атомиков" под капотом?

              В том же питоне, GIL вам не запрещает работать с одним и тем же объектом из разных потоков (например, пополнять список). GIL дает вам гарантию, что внутреннее состояния списка не будет "разрушено" в ходе работы двух потоков. В том же PEP703 помимо всего прочего предлагается реализовать объектные блокировки.

              В целом там много интересного, заставлять его прочитать вас я не могу, да и не хочу.


              1. ednersky
                03.11.2024 06:47

                В том же питоне, GIL вам не запрещает работать с одним и тем же объектом из разных потоков (например, пополнять список).

                список на атомиках не делается: двое перезаписывают хвост и кто-нибудь да выиграет

                и работа с переменными питон тоже на атомиках не получится: переменная — это структура данных


                1. zzzzzzerg Автор
                  03.11.2024 06:47

                  Во-первых, я не говорил о списках на атомиках, я написал про объектные блокировки, это несколько иное. Во-вторых, lock-free структуру данных известны давно (даже здесь на хабре есть статье про них Lock-free структуры данных. Iterable list / Хабр), это к разговору о том, что есть структуры данных, безопасные для использования в многопоточном окружении, но которые не используют блокировки.

                  Относительно переменных, вы правы, что просто так атомиками там не получится сделать. Но проблема там не в том, что переменная это структура данных, а в том, что Python поддерживает длинную арифметику - вот пример того как сделано сложение - cpython/Objects/longobject.c at eac41c5ddfadf52fbd84ee898ad56aedd5d90a41 · python/cpython. И есть перевод разбора, как эта арифметика работает - Как в Python реализованы очень длинные числа типа integer? / Хабр.

                  Думаю, что в рамках отказа от GIL тут тоже придется, что то придумывать.


  1. KaminskyIlya
    03.11.2024 06:47

    Прошу прощения, не знаком с питоном глубоко, но разве эта вот проверка

    if (0 == self->count && 0 == self->pending_count){

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

    и еще : попробуйте протестировать на 100000 потоках, 10 что то слабовато.


    1. KaminskyIlya
      03.11.2024 06:47

      И код тестирования не проведён или я невнимателен


      1. zzzzzzerg Автор
        03.11.2024 06:47

        Код для тестирования в репозитории у автора fastrlock - fastrlock/lockbench.py at master · scoder/fastrlock, я туда добавил только h5py (что не заслуживает какого-то отдельного выкладывания).

        Ссылку добавлю в статью, спасибо!


    1. zzzzzzerg Автор
      03.11.2024 06:47

      Этот код защищен GIL, поэтому исполняется только одним потоком в единицу времени. Пока мы не освободим GIL (через вызов Py_BEGIN_ALLOW_THREADS) другой поток в нашу функцию fastrlock_acquire попасть не может.

      На 100_000 потоках тестировать это смысла нет (как впрочем и запускать такое количество потоков, только если они не занимаются ожиданием на IO).


      1. blind_oracle
        03.11.2024 06:47

        Да даже если они занимаются IO - 100к обычных тредов скорее всего поставят на колени шедулер ОС.


        1. zzzzzzerg Автор
          03.11.2024 06:47

          Полностью согласен.


        1. KaminskyIlya
          03.11.2024 06:47

          нагрузочное тестирование проводить обязательно!!! чтобы самому увидеть как поведет себя твой код . иначе такой программист не вырастет выше уровня hello world


        1. KaminskyIlya
          03.11.2024 06:47

          Скрытый текст

          да не нужно жалеть свой шедуллер


      1. KaminskyIlya
        03.11.2024 06:47

        получается вся магия закрыта в волшебной директиве Py_BEGIN_ALLOW_THREADS

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

        но последнее- предположение.


        1. zzzzzzerg Автор
          03.11.2024 06:47

          Отвечу на оба два ваших комментария здесь.

          Есть различные задачи, под которые пишется код. Есть задачи IO-intensive, есть CPU-intensive. В первом случае, у вас потоки много ждут на операциях ввода-вывода и большую часть времени не занимают ресурсы процессора (но при этом все равно продолжают потреблять оперативную память). Как правильно ядро ОС будет реже на них переключаться, но это все равно будет занимать процессорное время. Во втором случае, потоки занимаются "число дробительными" задачами. Но т.к. у нас вытесняющая многозадачность, то это значит что ядро ОС все равно эти потоки будет прерывать и отдавать квант времени другим потокам (с учетом приоритетов и всего прочего). В этом случае нам не нужно использовать больше потоков, чем у нас есть ядер в процессоре. Тут можно так же упомянуть Закон Амдала — Википедия (простите за ссылку на википедию).

          В случае 100к потоков, у нас шедулер ОС будет заниматься только тем что будет между потоками переключаться - т.е. весь ресурс процессора будет отдан только на переключение между потоками.

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

          И возвращаясь к 100к потокам и RLock - никто в здравом уме не будет запускать 100к (даже если бы имел смысл) и ходить ими в защищаемый ресурс. На таких числах работают уже другие подходы, например, data parallelism.


  1. sleirsgoevy
    03.11.2024 06:47

    Заголовок кликбейт. Реализация у вас немного не на Питоне.


    1. zzzzzzerg Автор
      03.11.2024 06:47

      Наверное надо было написать ‘для Python’, но тут уже моя проф. деформация сработала.