Состояние гонки и критическая секция
Почему два потока, увеличивающих счётчик, могут «потерять» часть прибавлений.
Состояние гонки (race condition) — это ситуация, когда результат работы программы зависит от порядка, в котором выполняются операции разных потоков над общими данными.
Откуда берётся гонка
Безобидная на вид операция balance = balance + 50 на самом деле состоит из трёх шагов:
- прочитать текущее значение
balanceв регистр; - прибавить 50 к регистру;
- записать результат обратно в
balance.
Если два потока выполняют это одновременно и их шаги чередуются неудачно, прибавление может потеряться. Оба прочитают одно и то же старое значение, оба прибавят, оба запишут — и одно из двух пополнений исчезнет.
Наглядная симуляция гонки
Смоделируем чередование инструкций двух потоков над общим балансом. Сначала корректный последовательный порядок, потом — порядок с гонкой.
def run(interleaving):
balance = 100
regs = {"A": None, "B": None}
for thread, op in interleaving:
if op == "read":
regs[thread] = balance # прочитать общий balance
elif op == "add":
regs[thread] += 50 # прибавить в своём регистре
elif op == "write":
balance = regs[thread] # записать обратно
return balance
# Корректно: поток A целиком, потом поток B
good = [("A","read"),("A","add"),("A","write"),
("B","read"),("B","add"),("B","write")]
print(f"Последовательно: итог = {run(good)} (ожидаем 200)")
# Гонка: оба прочитали ДО того, как кто-то записал
bad = [("A","read"),("B","read"),
("A","add"),("B","add"),
("A","write"),("B","write")]
print(f"С гонкой: итог = {run(bad)} (одно пополнение потеряно!)")
Вывод:
Последовательно: итог = 200 (ожидаем 200) С гонкой: итог = 150 (одно пополнение потеряно!)
Оба варианта используют один и тот же код, но разный порядок выполнения даёт разный результат. Именно это и есть гонка: значение 150 вместо 200 — потерянное обновление.
Критическая секция
Критическая секция — это участок кода, который обращается к общим данным и не должен выполняться двумя потоками одновременно. В нашем примере критическая секция — три шага «прочитать, прибавить, записать».
Решение проблемы гонок — обеспечить взаимное исключение (mutual exclusion): в критической секции в каждый момент должен находиться не более чем один поток. Пока поток A внутри, поток B ждёт у входа.
Три требования к решению
Хорошее решение задачи критической секции обязано удовлетворять трём условиям:
- Взаимное исключение. Не более одного потока в критической секции одновременно.
- Прогресс. Если секция свободна, один из желающих войти должен войти (никто не блокируется зря).
- Ограниченное ожидание. Поток не может ждать входа бесконечно — нет голодания.
Почему нельзя «просто быть аккуратным»
Новички думают, что гонки — редкость и «у меня не воспроизводится». Проблема в том, что планировщик переключает потоки в непредсказуемые моменты. Баг может проявляться раз в миллион запусков — и именно в проде под нагрузкой. Поэтому общие данные защищают всегда примитивами синхронизации, а не надеждой на удачу. Эти примитивы — мьютексы и семафоры — тема следующего урока.
Итог
- Состояние гонки — зависимость результата от порядка выполнения потоков над общими данными.
- Даже
x = x + 1неатомарна: чтение, изменение, запись могут чередоваться. - Критическая секция — код, работающий с общими данными, требует взаимного исключения.
- Корректное решение даёт взаимное исключение, прогресс и ограниченное ожидание.
- Гонки непредсказуемы — общие данные нужно защищать всегда.