Состояние гонки и критическая секция

Почему два потока, увеличивающих счётчик, могут «потерять» часть прибавлений.

Состояние гонки (race condition) — это ситуация, когда результат работы программы зависит от порядка, в котором выполняются операции разных потоков над общими данными.

Откуда берётся гонка

Безобидная на вид операция balance = balance + 50 на самом деле состоит из трёх шагов:

  1. прочитать текущее значение balance в регистр;
  2. прибавить 50 к регистру;
  3. записать результат обратно в 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 неатомарна: чтение, изменение, запись могут чередоваться.
  • Критическая секция — код, работающий с общими данными, требует взаимного исключения.
  • Корректное решение даёт взаимное исключение, прогресс и ограниченное ожидание.
  • Гонки непредсказуемы — общие данные нужно защищать всегда.
Проверьте себя
1. Почему операция balance = balance + 50 может потерять обновление при гонке?
AОна состоит из трёх шагов (чтение, изменение, запись), которые могут неудачно чередоваться
BПотому что 50 — слишком большое число
CПотому что balance хранится на диске
DПотому что сложение всегда атомарно
2. Что такое критическая секция?
AСамый медленный участок программы
BУчасток кода, обращающийся к общим данным, который не должны выполнять два потока одновременно
CРаздел памяти только для чтения
DКод, выполняемый в режиме ядра
3. Какое из требований к решению критической секции означает отсутствие голодания?
AВзаимное исключение
BПрогресс
CОграниченное ожидание
DАтомарность