Гонки и критические секции
Состояние гонки
Гонка (race condition) — ситуация, когда результат зависит от того, в каком порядке выполнятся операции параллельных процессов или потоков.
Классический пример — счётчик в общей памяти:
/* общая память */
mem->counter++;
Одна строка C превращается в три машинных шага:
mov eax, [counter] ; 1. прочитать
add eax, 1 ; 2. прибавить
mov [counter], eax ; 3. записать
На многопроцессорной машине процессы и вовсе выполняются одновременно, а на одном процессоре переключение может произойти между любыми двумя инструкциями.
Критическая секция
Критическая секция — участок кода, работающий с общими данными, который нельзя выполнять параллельно. Решение должно обеспечивать:
- Взаимное исключение — в секции не больше одного.
- Прогресс — если секция свободна, кто-то из желающих войдёт.
- Ограниченное ожидание — никто не ждёт бесконечно.
- Никаких предположений о скоростях и числе процессоров.
вход_в_секцию();
/* критическая секция: работа с общими данными */
выход_из_секции();
Гонки в задаче 8
Все процессы работают с одной структурой Mem в разделяемой памяти:
| Место | Проблема |
|---|---|
mem->counter++ |
неатомарно (в этой программе увеличения идут по цепочке друг за другом, но в общем случае — гонка) |
mem->pid = ...; mem->ppid = ...; |
разные процессы перезаписывают одну пару полей; пара может «склеиться» из двух процессов |
for (i < mem->counter) printf(mem->ppid, mem->pid) |
родитель читает значения, пока их меняют другие; counter может расти во время цикла |
wait(NULL) |
ждёт только своего потомка, а не всю цепочку |
Наивные решения не работают
Флаг-замок
while (mem->state != 0) ; /* ждать */
mem->state = 1; /* занять — но между проверкой и записью может вклиниться другой! */
volatile
volatile запрещает компилятору держать значение в регистре — каждый раз будет чтение из памяти. Но неделимости он не даёт и порядок операций на других процессорах не гарантирует.
Запрет прерываний
На однопроцессорной машине ядро может запретить прерывания на время секции. Процессам это недоступно (привилегированная инструкция), и на многопроцессорной машине не помогает.
Алгоритм Петерсона
Программное решение для двух процессов только на чтениях и записях:
volatile int flag[2] = {0, 0};
volatile int turn;
void lock(int i) /* i = 0 или 1 */
{
int j = 1 - i;
flag[i] = 1; /* хочу войти */
turn = j; /* уступаю очередь */
while (flag[j] && turn == j)
; /* ждать */
}
void unlock(int i) { flag[i] = 0; }
Красивое и важное для теории решение, но на современных процессорах без барьеров памяти оно ломается: процессор может переупорядочить запись и чтение. Поэтому на практике используют аппаратные атомарные инструкции.
Атомарные инструкции
Процессор предоставляет неделимые операции «прочитать-изменить-записать»:
| Инструкция | Действие |
|---|---|
| test-and-set | записать 1 и вернуть старое значение |
compare-and-swap (CAS, cmpxchg) |
если значение равно ожидаемому — заменить, вернуть успех |
fetch-and-add (lock xadd) |
прибавить и вернуть старое значение |
/* атомарный счётчик */
__atomic_fetch_add(&mem->counter, 1, __ATOMIC_SEQ_CST);
/* или C11 */
#include <stdatomic.h>
atomic_uint counter;
atomic_fetch_add(&counter, 1);
/* спин-блокировка на test-and-set */
while (__atomic_test_and_set(&mem->lock, __ATOMIC_ACQUIRE))
; /* крутимся */
/* критическая секция */
__atomic_clear(&mem->lock, __ATOMIC_RELEASE);
Спин-блокировка тратит процессорное время, пока ждёт. Если ждать долго, лучше усыпить процесс — этим занимаются семафоры и мьютексы ядра (следующий урок).
Итог
- Гонка — результат зависит от порядка выполнения;
counter++— три шага. - Критическая секция требует взаимного исключения, прогресса и ограниченного ожидания.
- Флаг-замок и
volatileне дают неделимости «проверить и занять». - Решения: алгоритм Петерсона (теория), атомарные инструкции (test-and-set, CAS, fetch-and-add), а для долгого ожидания — примитивы с засыпанием.
Практика решено 0 из 5
Расставьте шаги, из-за которых два процесса, выполняя counter++ одновременно, получают 1 вместо 2 (начальное значение 0):
- Планировщик переключает на процесс B; B читает counter: 0
- A продолжает: прибавляет 1 к своему регистру (0 + 1) и записывает: counter = 1
- B прибавляет 1 и записывает в память: counter = 1
- Процесс A читает counter из памяти в регистр: 0
Какие требования предъявляются к решению задачи критической секции?
В задаче 8 несколько процессов выполняют mem->pid = getpid(); mem->ppid = getppid();. Какая проблема возможна?
Почему такой «замок» не работает?
while (mem->state != 0) ; /* ждать */
mem->state = 1; /* занять */
/* критическая секция */
mem->state = 0;
Сделайте увеличение счётчика в разделяемой памяти атомарным с помощью встроенной функции GCC:
__atomic_fetch_add(&mem->counter, 1, __ATOMIC_SEQ_CST); вместо mem->counter++;.
__atomic_fetch_add(&mem->counter, 1, __ATOMIC_SEQ_CST);
Войдите, чтобы отмечать пройденные уроки.