Программирование PythonКонкурентность и asyncioPython-разработчик серверных приложений

В чём принципиальное отличие threading.Lock от threading.RLock в вопросе владения блокировкой?

В чём принципиальное отличие threading.Lock от threading.RLock в вопросе владения блокировкой?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

threading.Lock не связывает блокировку с потоком-владельцем: поток, который её не захватывал, может вызвать release, если блокировка занята. threading.RLock принадлежит конкретному потоку: только поток-владелец может повторно захватывать и освобождать её.

У RLock повторный захват тем же потоком увеличивает внутренний счётчик, поэтому освобождений должно быть столько же, сколько захватов. У обычного Lock такого счётчика рекурсивного владения нет.

Исторический контекст

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

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

Постановка проблемы

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

У RLock обратная ошибка опаснее: забытое освобождение или лишний захват оставляет положительный счётчик рекурсии. Другие потоки будут ждать, пока поток-владелец не выполнит соответствующее количество release.

Подробное решение

threading.Lock имеет два основных состояния: свободен и занят. Метод acquire переводит его в занятое состояние, а release — обратно. При попытке освободить уже свободный Lock возникает RuntimeError, но идентификатор потока, выполнившего захват, не проверяется.

threading.RLock хранит поток-владелец и уровень рекурсии. Если владелец вызывает acquire повторно, операция проходит сразу, а счётчик увеличивается. Фактическое освобождение для ожидающих потоков происходит только после парного числа вызовов release.

Минимальная демонстрация допустимой передачи обычной блокировки между потоками:

import threading lock = threading.Lock() lock.acquire() def handoff(): lock.release() t = threading.Thread(target=handoff) t.start() t.join() print(lock.acquire(False))

Выводом будет True: второй поток освободил Lock, а затем основной поток снова его захватил. Для RLock такой вызов release из другого потока завершился бы ошибкой, поскольку поток не является владельцем.

В прикладном коде возможность освобождения Lock другим потоком следует использовать только как явно спроектированную передачу разрешения. Обычно безопаснее применять queue.Queue, threading.Event или Condition, потому что они выражают намерение обмена данными или сигналом яснее и уменьшают риск преждевременного освобождения.

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

Ситуация из практики

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

Вариант с прямой передачей Lock имеет плюс — он прост и не требует дополнительного объекта синхронизации. Минусы — слабая очевидность протокола, риск освобождения не того ресурса и сложность сопровождения при появлении исключений.

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

Что кандидаты часто упускают

  1. Можно ли любому потоку освободить занятый threading.Lock?

    Да, если блокировка действительно занята. Lock не хранит владельца и проверяет лишь корректность состояния: освобождение свободной блокировки вызывает RuntimeError. Это отличается от многих mutex-реализаций в других языках и не означает, что произвольное освобождение безопасно с точки зрения протокола приложения.

  2. Что произойдёт, если поток дважды захватит threading.RLock, но освободит его один раз?

    Блокировка останется занятой: внутренний счётчик уменьшится, но не станет нулём. Другие потоки продолжат блокироваться, пока поток-владелец не выполнит второй release или пока не завершится ошибочный протокол синхронизации.

  3. Можно ли заменить RLock обычным Lock, если все вызовы происходят в одном потоке?

    Нельзя делать такую замену без проверки структуры вызовов. Если синхронизированная функция прямо или косвенно вызывает другую функцию, которая пытается захватить тот же Lock, поток заблокирует сам себя. RLock устраняет именно эту проблему, но одновременно требует строгого соответствия числа захватов и освобождений.