← Все вопросы

Два потока инкрементируют один счётчик, и результат каждый раз разный — это race condition?

Задан 14 месяцев назад728 просмотров3 ответа
7

Готовлюсь к собесу, читаю про многопоточность. Сделал учебный пример: два потока по миллиону раз увеличивают общую переменную. Ожидаю 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 ответа

12
✓ Принятый ответ — помог автору

Да, это классический 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 — то что надо.

3

Маленький нюанс про deadlock: не захватывай два разных замка в разном порядке в разных потоках — это классический способ намертво повесить программу. Если нужен «рекурсивный» захват одного и того же замка в одной цепочке вызовов — бери threading.RLock, обычный Lock повторно из того же потока не захватишь, словишь зависание.

2

Если задача именно «безопасно считать из нескольких потоков» и не хочется городить замки, иногда проще не делить переменную вовсе: пусть каждый поток считает свою сумму, а в конце сложишь результаты. Нет общего состояния — нет и гонки. Lock хорош, но самый надёжный способ убрать race condition — убрать разделяемые изменяемые данные.

Ваш ответ

Войдите, чтобы ответить на вопрос.
Поддержать проект