1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9

# теория · шаг 1 из 9

Гонка: почему c = c + 1 теряет прибавления и что такое критическая секция

Коротко

c = c + 1 в простейшей машинной модели - три операции: прочитать c в регистр, прибавить, записать обратно. Если два потока делают это одновременно, их операции перемешиваются, и одно прибавление может пропасть. Это гонка: результат зависит от того, в каком порядке планировщик перемешал операции. Порядков астрономически много, и плохие попадаются не всегда - поэтому гонка проходит тесты и падает в бою. Защита - критическая секция: кусок кода, в котором одновременно находится не больше одного потока. Хорошая защита обязана дать три свойства: взаимоисключение, отсутствие взаимоблокировки и отсутствие голодания.

Если ты знаешь, почему инкремент не атомарен, что такое чередование и чем взаимоблокировка отличается от голодания, листай до «Теперь сам».

Как думаешь, что напечатает

Два потока, у каждого цикл из двух шагов: c = c + 1. В начале c = 0. Какие значения c возможны в конце? И насколько часто будет правильный ответ 4?

Ответь себе до чтения. «4, иногда 3» - частый ответ. Перебор всех порядков в разборе показывает, что возможны 2, 3 и 4, и из 924 возможных порядков ответ 4 дают только шесть.

На что это похоже

Два кассира ведут общую тетрадь с остатком наличных. Каждый, приняв деньги, подходит к тетради, читает остаток, считает в уме новый и записывает. Если оба подошли одновременно, прочитали «100», приняли по 10 и записали «110», - десятка пропала из учёта, хотя в кассе она есть. Никто не ошибся в сложении: ошибка в том, что между «прочитал» и «записал» тетрадь поменялась.

Решение очевидное: тетрадь в каждый момент у одного кассира. Ровно это и называют взаимным исключением.

Механизм: чередование операций

Поток выполняет свои операции по порядку, но между двумя его операциями может вклиниться сколько угодно операций другого потока. Итоговая последовательность всех операций - чередование. Для двух потоков по a и b операций чередований C(a + b, a) - число способов выбрать, какие места займёт первый поток. Три операции у каждого - 20 чередований, шесть - уже 924.

Гонка - ситуация, когда результат зависит от чередования. Критическая секция - участок кода, который должен исполняться целиком, без вклинившихся операций других потоков над теми же данными. Протокол входа и выхода (замок) обязан обеспечить:

  • взаимоисключение - в критической секции не больше одного потока; это свойство безопасности: «плохое никогда не случается»;
  • отсутствие взаимоблокировки - если кто-то хочет войти, кто-то обязательно войдёт; потоки не могут все застрять, ожидая друг друга;
  • отсутствие голодания - каждый, кто хочет войти, в конце концов войдёт (если планировщик не забывает поток навсегда - иначе этого не обещает ни один замок). Это сильнее: без взаимоблокировки система в целом движется, а без голодания движется каждый.

Последние два - свойства живости: «хорошее когда-нибудь случается».

поток 0 поток 1 c читает 0 0 + 1 пишет 1 читает 0 0 + 1 пишет 1 0 1 1, а не 2
Оба потока прочитали 0 до того, как кто-то записал. Вторая запись затирает первую: сделано два прибавления, а счётчик вырос на одно.

Правило

Операция, которая читает и потом пишет общие данные, не атомарна: между чтением и записью может вклиниться другой поток. Результат, зависящий от чередования, - гонка. Код над общими данными помещают в критическую секцию, и её протокол обязан дать взаимоисключение (безопасность), отсутствие взаимоблокировки и отсутствие голодания (живость). Проваленный тест гонку показывает, а зелёный ничего не доказывает: среди всех чередований плохих большинство, но настоящий планировщик выдаёт их редко.

Разбор: все чередования двух потоков

Перебор моделирует каждый поток как цепочку операций «прочитать - прибавить - записать» и обходит все чередования стеком: в каждом состоянии можно сделать следующий шаг либо первого, либо второго потока.

def itogi(k):
    # два потока, у каждого k раз: прочитать c, прибавить 1, записать;
    # перебираем все чередования этих 3k + 3k операций
    vstrecheno = {}
    # состояние: c, шаги потоков 0 и 1, регистры потоков 0 и 1
    stek = [[0, 0, 0, 0, 0]]
    vsego = 0
    while len(stek) > 0:
        c, s0, s1, r0, r1 = stek.pop()
        if s0 == 3 * k and s1 == 3 * k:
            vsego = vsego + 1
            vstrecheno[c] = vstrecheno.get(c, 0) + 1
            continue
        if s0 < 3 * k:
            faza = s0 % 3
            if faza == 0:
                stek.append([c, s0 + 1, s1, c, r1])
            elif faza == 1:
                stek.append([c, s0 + 1, s1, r0 + 1, r1])
            else:
                stek.append([r0, s0 + 1, s1, r0, r1])
        if s1 < 3 * k:
            faza = s1 % 3
            if faza == 0:
                stek.append([c, s0, s1 + 1, r0, c])
            elif faza == 1:
                stek.append([c, s0, s1 + 1, r0, r1 + 1])
            else:
                stek.append([r1, s0, s1 + 1, r0, r1])
    return [vsego, vstrecheno]

for k in [1, 2]:
    r = itogi(k)
    otvety = []
    for c in sorted(r[1]):
        otvety.append([c, r[1][c]])
    print(k, "чередований", r[0], "итог: сколько раз", otvety)
1 чередований 20 итог: сколько раз [[1, 18], [2, 2]]
2 чередований 924 итог: сколько раз [[2, 702], [3, 216], [4, 6]]

Один инкремент на поток - 20 чередований, и только 2 из них дают правильную двойку: те, где один поток закончил все три операции до того, как другой начал. Стоит им перекрыться хоть на одну операцию - результат 1. Два инкремента на поток - 924 чередования, и итог бывает 2, 3 или 4, причём правильная четвёрка - в шести случаях. Двойка встречается чаще всех, и это не случайность перебора: известно, что при двух потоках по k инкрементов итог может опуститься до 2 при любом k ≥ 2.

Настоящий планировщик не выбирает чередования равновероятно - обычно поток успевает много операций подряд. Поэтому в жизни гонка выглядит иначе, и это следующий разбор.

Разбор: гонка, которая проходит тест

Теперь планировщик переключает потоки случайно: после каждой операции с вероятностью q. Чем меньше q, тем дольше поток работает без помех - так похоже ведёт себя одно ядро при редких прерываниях. Числа модели - иллюстрация, а не замер: на нескольких ядрах потоки идут ещё и одновременно.

import random

def progon(n, q):
    # каждый поток n раз делает c = c + 1 в три операции;
    # после каждой операции планировщик с вероятностью q переключается
    c = 0
    shag = [0, 0]
    reg = [0, 0]
    t = 0
    while shag[0] < 3 * n or shag[1] < 3 * n:
        if shag[t] == 3 * n or random.random() < q:
            t = 1 - t
            if shag[t] == 3 * n:
                t = 1 - t
        faza = shag[t] % 3
        if faza == 0:
            reg[t] = c
        elif faza == 1:
            reg[t] = reg[t] + 1
        else:
            c = reg[t]
        shag[t] = shag[t] + 1
    return c

random.seed(4)
for q in [0.001, 0.01, 0.1, 0.5]:
    poteryano = []
    for opyt in range(4):
        poteryano.append(400 - progon(200, q))
    print("переключение", q, "потеряно из 400:", poteryano)
переключение 0.001 потеряно из 400: [51, 117, 0, 0]
переключение 0.01 потеряно из 400: [152, 161, 30, 116]
переключение 0.1 потеряно из 400: [124, 138, 135, 140]
переключение 0.5 потеряно из 400: [163, 178, 168, 175]

При частых переключениях теряется почти половина прибавлений, и ошибка видна сразу. При редких - два прогона из четырёх не потеряли НИЧЕГО: тест прошёл бы зелёным. А в двух других пропало 51 и 117 прибавлений: одно неудачное переключение посреди «прочитал - записал» стирает всю работу, которую другой поток успел сделать за это время. Именно так гонки и живут в коде: месяцами проходят тесты, а потом в нагрузку, на другой машине или после обновления компилятора теряют деньги.

Что ломается без этого

«У меня работает». Гонку на машине разработчика не видно: мало ядер, мало нагрузки, планировщик переключает редко. На сервере с 64 ядрами те же строки теряют обновления каждую секунду.

Проверка «если ещё не занято - займи». if not zanyato: zanyato = True - тоже чтение и запись с зазором между ними: оба потока видят «не занято», и оба входят. Замок, написанный так, не исключает ничего; почему - следующий урок.

Взаимоблокировка вместо гонки. Два потока захватывают два замка в разном порядке: первый держит A и ждёт B, второй держит B и ждёт A. Защиты слишком много, и программа стоит.

Зачем это в работе

Счётчик просмотров в базе (UPDATE ... SET views = views + 1 против «прочитали в приложении, прибавили, записали»), остаток на складе при одновременных заказах, баланс кошелька - всё это тот же c = c + 1. В Python с глобальной блокировкой интерпретатора c += 1 тоже не атомарен: поток могут прервать между чтением и записью. А с выходом Python 3.14 (октябрь 2025) сборка без этой блокировки стала официально поддерживаемой - код, который «случайно работал», начнёт терять обновления чаще.

Три свойства - взаимоисключение, отсутствие взаимоблокировки, отсутствие голодания - и есть техзадание на любой замок: от мьютекса в ядре до распределённой блокировки на Redis.

Теперь сам

  1. Сколько чередований у двух потоков по две операции? Перечисли их.
  2. Почему при двух инкрементах на поток итог не может быть 1?
  3. Протокол позволяет двум потокам одновременно ждать друг друга вечно. Какое из трёх свойств нарушено? А если один поток входит снова и снова, а второй ждёт вечно?

Ответ. 1. C(4, 2) = 6: AABB, ABAB, ABBA, BAAB, BABA, BBAA.

  1. Любая запись - прочитанное плюс один, то есть не меньше 1, поэтому после первой записи c ≥ 1 навсегда. Последняя операция - чья-то вторая запись, а второе чтение этого потока было после его первой записи: прочитано не меньше 1, записано не меньше 2.

  2. Первое - взаимоблокировка: никто не входит, хотя оба хотят. Второе - голодание: система движется, но один поток не входит никогда. Взаимоисключение в обоих случаях соблюдено.

Главное

c = c + 1 - это прочитать, прибавить и записать, и чужая запись между ними стирает работу. Зелёный тест гонку не опровергает - планировщик выдаёт плохие чередования редко; её исключают протоколом, который даёт взаимоисключение, отсутствие взаимоблокировки и отсутствие голодания.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.