Метод Python завис внутри другого метода: когда нужен RLock
У объекта есть метод пополнения счётчика и метод переноса нескольких событий сразу. Каждый защищает состояние блокировкой. После рефакторинга пакетный метод вызывает одиночный и останавливается навсегда: конкурентов в этот момент может вообще не быть.
Одна нить тоже может ждать сама себя
Обычный Lock отвечает на простой вопрос: занят ли участок работы? Он не разрешает повторное получение занятой блокировки даже тому потоку, который уже вошёл внутрь. Внешний метод удерживает замок, внутренний пытается получить его ещё раз, а до освобождения внешнего выполнение уже не дойдёт. Низкая загрузка процессора здесь закономерна: программа ожидает разрешения продолжить.
Посмотрите стек зависшего потока. Если второй with расположен в методе того же объекта, который вызван из уже защищённого участка, сначала нарисуйте цепочку вызовов. Добавление ещё одного рабочего потока ситуацию не исправит. Уменьшение размера пакета тоже лишь меняет частоту попадания в этот путь. Причина находится в устройстве методов и границе защищённого состояния.
Две осмысленные конструкции
Первый вариант — выделить внутренний метод без блокировки. Публичные методы захватывают Lock ровно один раз, затем вызывают внутреннюю операцию. Такое устройство удобно, если контролировать все пути вызова просто. Название с подчёркиванием помогает обозначить служебную функцию, но запрета на внешний вызов в Python не создаёт: правило нужно поддерживать структурой класса и тестами.
Второй вариант — RLock. Он учитывает повторные захваты одним потоком. Каждый вход должен иметь парный выход; вложенные with обеспечивают этот баланс при нормальном возврате и исключении. Другой поток получит доступ после освобождения самого внешнего уровня. Это подходит объекту, у которого публичные операции естественно составляются друг из друга и все защищают один инвариант.
В примере пакет должен попадать в счётчик целиком с точки зрения других методов того же объекта. Поэтому batch удерживает RLock на протяжении обхода, а add тоже остаётся безопасной самостоятельной операцией. Пул запускает несколько пакетов, а итоговое значение вычисляется из входных диапазонов. Проверка не зависит от порядка, в котором рабочие потоки получат блокировку.
from concurrent.futures import ThreadPoolExecutor
from threading import RLock
class Counter:
def __init__(self):
self._lock = RLock()
self._value = 0
def add(self, value):
with self._lock:
self._value += value
def batch(self, values):
with self._lock:
for value in values:
self.add(value)
def snapshot(self):
with self._lock:
return self._value
counter = Counter()
batches = [range(1, 101), range(101, 201), range(201, 301)]
with ThreadPoolExecutor(max_workers=3) as pool:
list(pool.map(counter.batch, batches))
expected = sum(sum(batch) for batch in batches)
assert counter.snapshot() == expected
print(counter.snapshot())Что остаётся под контролем класса
Счётчик скрыт за методами: читатель также берёт блокировку. Если вернуть наружу изменяемый словарь внутреннего состояния, вызывающий код сможет обойти защиту. Для сложного объекта возвращайте отдельный снимок. Иначе формально правильные with не зададут единую дисциплину доступа, а ошибка будет выглядеть как редкое повреждение данных в постороннем месте программы.
RLock решает повторный вход одного потока, но не меняет порядок захвата разных замков. Если два объекта вызывают друг друга, заранее установите общий порядок блокировок либо перенесите координацию в один уровень. Долгий сетевой запрос внутри критической секции увеличит время ожидания всех соседей: сначала получите необходимые данные, затем коротко обновите состояние.
Для регрессии полезны отдельные сценарии add и batch, исключение посередине операции и одновременный snapshot. Если пакет обязан быть ещё и откатываемым, считайте новое значение во временной переменной и присваивайте после успешного расчёта. Повторный вход определяет доступ к состоянию; правило о сохранности частично выполненного пакета нужно сформулировать отдельно.
При разборе большого класса полезно выписать все места чтения и изменения защищаемого поля. Если один метод использует другой замок или вовсе не берёт его, гарантии остальных методов не распространяются на этот путь. Затем отметьте вызовы внешних функций внутри секции: callback способен снова обратиться к тому же объекту. Это ещё один источник неожиданного повторного входа, который трудно заметить по одному методу. Для сложной операции сначала вычисляйте план изменения без блокировки, а внутри проверяйте, что исходная версия состояния ещё подходит. Если версия изменилась, план нужно пересчитать. Такая оптимизация требует отдельного контракта; начинать разбор зависания лучше с короткого, полностью защищённого варианта, поведение которого можно объяснить по цепочке вызовов.
Следующий шаг в практике
Откройте бесплатный урок о первых тестах pytest и оформите проверки одиночного вызова, пакета и ошибочного входа. В программе «Python для профессионалов» есть работа интерпретатора и GIL: это продолжение поможет связать устройство языка с выполнением конкурентного кода.
Продолжить на практике
- Python для продвинутых: ООП, типизация и тестирование ↗
Практический курс-мост от базового Python к разработке поддерживаемых приложений. Вы разберётесь со структурой пакетов, объектной моделью, современной типизацией и автоматическими тестами, а затем соедините эти инструменты в небольшом приложении с хранением данных и логированием.
- Python для профессионалов ↗
Python для профессионалов — курс для людей, стремящихся углубить свои знания языка. Вы изучите итераторы, генераторы, декораторы, современные возможности Python (3.8+), освоите метапрограммирование, асинхронность, внутреннее устройство интерпретатора, инструменты оптимизации и профилирования, а также получите уверенность в использовании продвинутых возможностей стандартной библиотеки.
