В стандартной библиотеке языка 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 — что позволит другим потокам далее выполнять свою работу, пока наш новый поток сам себя заблокировал.
А что будут делать остальные потоки — есть два варианта:
-
Если поток не является «хозяином», то он будет проходить все проверки выше и блокироваться на попытке захватить реальный
lock
. -
Если поток является «хозяином» — то, он либо будет и далее успешно захватывать наш объект (т.к. пройдёт по условию
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. А есть ли у этого всего смысл — давайте посмотрим.
Сравнение производительности
Автор предлагает следующие тестовые сценарии:
-
lock_unlock
— последовательность из пяти захватов‑освобождений блокировки -
reentrant_lock_unlock
— пять последовательных захватов блокировки и затем такое же количество разблокировок. -
mixed_lock_unlock
— смешанный порядок захватов и освобождений, в том числе с повторными захватами. -
lock_unlock_nonblocking
— аналогично вариантуlock_unlock
, только в режиме без ожидания (в описанном варианте с использованием Python C‑API я реализовал только с ожиданием, для упрощения реализации). -
context_manager
— аналогичный вариантmixed_lock_unlock
, только реализованный с использованием контекстного менеджера.
И две группы тестов:
-
sequential
— запускается 1 поток. -
threaded 10T
— запускается 10 потоков.
Во время тестирования под Windows 11 x64 (11th Gen Intel(R) Core(TM) i5-11600K @ 3.90GHz), на версии Python 3.8.+ я получил следующие результаты:
Я протестировал на Windows 11 x64 и нескольких дистрибутивах Linux, запущенных в WSL2 — Fedora 30, Ubuntu 20.04, Ubuntu 24.10. Процессор 11th Gen Intel(R) Core(TM) i5-11600K @ 3.90GHz, WSL2 доступны 12 ядер, память и диск в тестах не используются, поэтому их не указываю.
Тестовый сценарий |
RLock, msec |
FastRLock, msec |
FastRLock, msec |
---|---|---|---|
Windows 11 x64 |
v3.8.13 |
v0.8.2 |
v2.10.0, h5py |
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 |
|
|
|
|
Fedora 39, x64, WSL2 |
v3.12.7 |
v0.8.2 |
v3.12.1, h5py |
sequential (x100000) |
|
|
|
lock_unlock |
48.62 |
29.06 |
24.89 |
reentrant_lock_unlock |
33.62 |
26.93 |
23.11 |
mixed_lock_unlock |
41.99 |
25.79 |
22.85 |
lock_unlock_nonblocking |
66.09 |
26.77 |
32.45 |
context_manager |
442.95 |
323.86 |
325.62 |
|
|
|
|
threaded 10T (x1000) |
|
|
|
lock_unlock |
1109.63 |
1106.15 |
1096.96 |
reentrant_lock_unlock |
1108.54 |
1092.48 |
1112.06 |
mixed_lock_unlock |
1109.89 |
1107.4 |
1110.49 |
lock_unlock_nonblocking |
1125.07 |
1086.37 |
1115.5 |
context_manager |
1151.92 |
1125.6 |
1139.98 |
|
|
|
|
Ubuntu 20.04, x64, WSL2 |
v3.8.10 |
v0.8.2 |
v3.11.0, h5py |
sequential (x100000) |
|
|
|
lock_unlock |
31.63 |
17.5 |
18.42 |
reentrant_lock_unlock |
28.12 |
18.6 |
19.26 |
mixed_lock_unlock |
31.46 |
18.35 |
19.32 |
lock_unlock_nonblocking |
39.92 |
18.17 |
18.54 |
context_manager |
133.4 |
91.42 |
91.09 |
|
|
|
|
threaded 10T (x1000) |
|
|
|
lock_unlock |
694.96 |
714.54 |
705.53 |
reentrant_lock_unlock |
688.83 |
710.33 |
693.75 |
mixed_lock_unlock |
687.25 |
693.02 |
691.03 |
lock_unlock_nonblocking |
692.59 |
690.37 |
689.43 |
context_manager |
716.34 |
713.11 |
732.81 |
|
|
|
|
Ubuntu 24.10, x64, WSL2 |
v3.12.7 |
v0.8.2 |
v3.12.1, h5py |
sequential (x100000) |
|
|
|
lock_unlock |
32.12 |
16.47 |
17.22 |
reentrant_lock_unlock |
22.7 |
17.88 |
18.96 |
mixed_lock_unlock |
26.16 |
23.31 |
18.87 |
lock_unlock_nonblocking |
44.51 |
30.49 |
18.27 |
context_manager |
217.48 |
127.25 |
129.32 |
|
|
|
|
threaded 10T (x1000) |
|
|
|
lock_unlock |
1027.15 |
1050.73 |
1031.66 |
reentrant_lock_unlock |
1029.67 |
1031.18 |
1022.53 |
mixed_lock_unlock |
1026.82 |
1020.96 |
1017.4 |
lock_unlock_nonblocking |
1031.81 |
1046.83 |
1044.83 |
context_manager |
1060.33 |
1070.13 |
1099.05 |
Как можно видеть, в тестовых однопоточных сценариях fastrlock
обгоняет стандартный RLock
(в худшем случае около 15 процентом, в лучшем на 50 процентов), причем для разных ОС и разных версий Python.
По словам автора, результаты могут зависеть от версии и оптимизации Python. Стабильно на версиях до 3.2 данная реализация была на порядок быстрее, чем стандартный вариант (но я думаю, что мало кого эти версии интересуют в 2024 году).
Когда это может быть нужно? Современная тенденция в разработке ПО нацелена на создание многопоточных приложений, для повышения скорости обработки информации или снижения задержек в пользовательском интерфейсе. Но есть сценарии, когда мы должны защитить ресурс, который не умеет работать в многопоточном окружении. И примером может послужить h5py — Python интерфейс для файлов данных в формате HDF5. Для защиты файлов от повреждений и упрощения самой библиотеки h5py использует fastrlock
при реализации своего программного интерфейса.
Рекомендации
-
Если вы хотите использовать
fastrlock
, то обязательно протестируйте для своей версии Python и своего окружения. -
Выбирайте
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.
Но для некоторых проектов это может быть реальностью ещё на ближайшие лет пять…
Спасибо за внимание.
Список изменений
-
2024-11-03
-
Добавлена ссылка на скрипт с тестовыми сценариями
-
-
2024-11-05
-
Добавлены результаты сравнения для Fedora 39, Ubuntu 20.04, Ubuntu 24.10
-
Переформулирован первый абзац
-
ссылка на оригинал статьи https://habr.com/ru/articles/855728/
Добавить комментарий