Два потока инкрементируют один счётчик, и результат каждый раз разный — это race condition?
Готовлюсь к собесу, читаю про многопоточность. Сделал учебный пример: два потока по миллиону раз увеличивают общую переменную. Ожидаю 2 000 000, а получаю то 1 700 000, то 1 850 000 — каждый запуск разное.
import threading
counter = 0
def inc():
global counter
for _ in range(1_000_000):
counter += 1
t1 = threading.Thread(target=inc)
t2 = threading.Thread(target=inc)
t1.start(); t2.start()
t1.join(); t2.join()
print(counter) # почти никогда не 2000000
Я думал, GIL защищает от такого. Это и есть race condition? Как чинить?
3 ответа
Да, это классический race condition (гонка данных). GIL гарантирует атомарность только на уровне отдельных байт-кодов, а counter += 1 — это НЕ одна операция. Под капотом это «прочитать counter → прибавить 1 → записать обратно». Между чтением и записью GIL может переключить поток, и второй поток успеет прочитать старое значение. Часть инкрементов теряется.
Лечится через threading.Lock — замок, который пускает в критическую секцию только один поток за раз:
import threading
counter = 0
lock = threading.Lock()
def inc():
global counter
for _ in range(1_000_000):
with lock: # захватили замок
counter += 1 # критическая секция
# на выходе из with замок автоматически отпускается
t1 = threading.Thread(target=inc)
t2 = threading.Thread(target=inc)
t1.start(); t2.start()
t1.join(); t2.join()
print(counter) # стабильно 2000000
with lock: — это lock.acquire() на входе и lock.release() на выходе, даже если внутри случится исключение. Писать руками acquire/release можно, но через with безопаснее — не забудешь отпустить.
Цена вопроса: с замком этот код станет ОЧЕНЬ медленным, потому что миллион раз захватывать/отпускать замок дорого, и потоки фактически встают в очередь. Для чисто счётного цикла потоки тут вообще бесполезны (см. GIL). Но как иллюстрация race condition и lock — то что надо.
Маленький нюанс про deadlock: не захватывай два разных замка в разном порядке в разных потоках — это классический способ намертво повесить программу. Если нужен «рекурсивный» захват одного и того же замка в одной цепочке вызовов — бери threading.RLock, обычный Lock повторно из того же потока не захватишь, словишь зависание.
Если задача именно «безопасно считать из нескольких потоков» и не хочется городить замки, иногда проще не делить переменную вовсе: пусть каждый поток считает свою сумму, а в конце сложишь результаты. Нет общего состояния — нет и гонки. Lock хорош, но самый надёжный способ убрать race condition — убрать разделяемые изменяемые данные.