Короткий ответ
GIL гарантирует, что в любой момент выполняется только один поток байткода, но составная операция self.counter += 1 на уровне байткода разбивается на несколько шагов (прочитать текущее значение, вычислить новое, записать обратно), и между этими шагами GIL может быть передан другому потоку, который успеет выполнить такую же составную операцию над тем же значением — в результате возможна потеря обновлений (race condition) даже при наличии GIL; надёжное решение — явная синхронизация (threading.Lock) вокруг составной операции или использование атомарных примитивов, спроектированных для конкурентного доступа.
Как это работает подробнее
GIL защищает внутреннюю целостность самого интерпретатора (например, от повреждения внутренних структур CPython при одновременном выполнении байткода двумя потоками), но не превращает составные операции над объектами Python в атомарные с точки зрения бизнес-логики. counter += 1 компилируется в несколько байткод-инструкций (загрузка текущего значения атрибута, сложение, сохранение нового значения), и GIL может переключиться на другой поток между любыми двумя байткод-инструкциями (например, при превышении лимита переключения потоков или при любой операции, явно/неявно отпускающей GIL) — если второй поток в этот момент тоже читает старое значение counter и увеличивает его, одно из двух увеличений будет потеряно при записи.
Пример кода
import threading
class RequestCounterUnsafe:
def __init__(self):
self.counter = 0
def increment(self):
self.counter += 1 # read-modify-write — не атомарно, возможна потеря обновлений
class RequestCounterSafe:
def __init__(self):
self.counter = 0
self._lock = threading.Lock()
def increment(self):
with self._lock: # гарантирует атомарность составной операции
self.counter += 1
def run_many_threads(counter_obj, n_threads=20, n_increments=5000):
def worker():
for _ in range(n_increments):
counter_obj.increment()
threads = [threading.Thread(target=worker) for _ in range(n_threads)]
for t in threads: t.start()
for t in threads: t.join()
return counter_obj.counter # у Unsafe-версии итог часто < n_threads * n_incrementsЧто использовать на практике
- Практика. Надёжный способ защитить составную операцию — обернуть её в
threading.Lock(with self._lock: self.counter += 1), что гарантирует, что чтение-изменение-запись выполняется как единый, не прерываемый другими потоками блок относительно этого конкретного лока. Альтернативы: использовать потокобезопасные примитивы из модуляqueue/collectionsтам, где они покрывают нужный сценарий, либо вообще избегать общего mutable-состояния между потоками, агрегируя локальные счётчики каждого потока и суммируя их в конце (если бизнес-логика это допускает), что убирает необходимость в синхронизации вовсе.
Подводные камни
- Риски. Полагаются на "GIL делает код потокобезопасным" как на общее правило, не проверяя, что конкретная операция действительно атомарна на уровне байткода (простые перестановки/присваивания отдельных неизменяемых значений часто действительно атомарны, но составные read-modify-write операции — нет). Также добавляют блокировку на каждую отдельную простую операцию без анализа, не приводит ли это к избыточной конкуренции за лок и падению производительности в горячем пути, вместо того чтобы спроектировать более крупную единицу синхронизации или вовсе другую модель состояния.
- Компромисс. Блокировки гарантируют корректность, но вносят конкуренцию за лок, что может стать узким местом при высокой частоте обращений из многих потоков, и требует дисциплины, чтобы не создать deadlock при использовании нескольких локов одновременно. Более радикальная альтернатива — вынести такие счётчики в асинхронную модель с одним потоком (устранить многопоточность для этого состояния целиком) или в отдельное хранилище, спроектированное для конкурентных атомарных операций (например, атомарный счётчик в Redis) — выбор зависит от того, насколько критична точность счётчика, частота операций и общая архитектура сервиса.