Синхронизация
Предпосылки
планировщик (вытеснение между любыми двумя инструкциями), потоки (общее адресное пространство, CLONE_VM), когерентность кешей (MESI, invalidate, cache line ping-pong).
← Права доступа и capabilities | Модель памяти →
Права доступа и capabilities определяют, кто может обращаться к ресурсу: ядро проверяет UID, GID и capability-флаги при каждом системном вызове. Но когда несколько потоков одного процесса — с одинаковыми UID и capabilities — одновременно обращаются к одним и тем же данным в памяти, разрешение доступа не спасает от повреждения. Нужен другой механизм: не «кому можно», а «в каком порядке и когда».
Потоки разделяют адресное пространство — любой из них может прочитать и записать любой адрес в heap и в секции данных процесса. Планировщик при этом может прервать поток между любыми двумя машинными инструкциями, передав ядро другому потоку. Сочетание этих двух фактов — общая память и вытеснение — порождает гонки (race conditions): результат программы начинает зависеть от порядка, в котором планировщик чередует потоки.
Потерянный инкремент
Два потока инкрементируют общий счётчик по миллиону раз каждый. Ожидание — 2 000 000. Реальность:
#include <pthread.h>
#include <stdio.h>
static int counter = 0;
void *worker(void *arg) {
for (int i = 0; i < 1000000; i++)
counter++;
return NULL;
}
int main(void) {
pthread_t t1, t2;
pthread_create(&t1, NULL, worker, NULL);
pthread_create(&t2, NULL, worker, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("counter = %d\n", counter); /* ~1 200 000 */
return 0;
}Результат — примерно 1 200 000 вместо 2 000 000. Потеряно 800 тысяч инкрементов. Причина в том, что counter++ — не одна инструкция.
Компилятор превращает counter++ в три операции: load значения из памяти в регистр, add единицы к регистру, store результата обратно в память. Планировщик может вытеснить поток между любыми из этих шагов:
поток A поток B
------- -------
load counter -> rax (= 0)
load counter -> rax (= 0)
add rax, 1 (rax = 1)
add rax, 1 (rax = 1)
store rax -> counter (= 1)
store rax -> counter (= 1)
Оба потока прочитали 0, оба записали 1. Два инкремента — а счётчик вырос на единицу. Это lost update (потерянное обновление): запись одного потока затирает запись другого, потому что между чтением и записью вклинился чужой поток.
Гонка и гонка данных
Термин гонка (race condition) описывает ситуацию, когда результат программы зависит от временного порядка операций нескольких потоков. Потерянный инкремент — пример гонки: если планировщик чередует потоки иначе, результат меняется.
Стандарты C и C++ вводят более узкое понятие — гонку данных (data race): одновременный доступ двух потоков к одной ячейке памяти, где хотя бы один доступ — запись, и ни один из них не использует атомарную операцию или блокировку. По стандарту гонка данных — это неопределённое поведение (undefined behavior): компилятор может оптимизировать код в предположении, что гонок нет, и программа может делать что угодно.
Разница между двумя понятиями: гонка данных — свойство конкретных обращений к памяти, её можно обнаружить инструментами вроде ThreadSanitizer. Гонка — ошибка на уровне логики, где корректность зависит от тайминга. Классический пример — TOCTOU (time-of-check-to-time-of-use, разрыв между проверкой и использованием): программа проверяет, существует ли файл (access()), затем открывает его (open()). Между проверкой и открытием другой процесс может удалить файл или заменить его символической ссылкой. Каждая из двух операций по отдельности потокобезопасна — гонки данных нет, но логическая гонка есть.
Rust устраняет гонки данных на уровне системы типов: &mut T — единственная мутабельная ссылка, &T — произвольное количество иммутабельных. Компилятор отклоняет код, где два потока могут одновременно писать в одну ячейку без синхронизации. Но логические гонки (TOCTOU, lost update на уровне бизнес-логики) остаются возможными и в safe Rust.
Критическая секция
Участок кода, в котором поток работает с разделяемыми данными, называется критической секцией (critical section). В примере со счётчиком критическая секция — три инструкции load-add-store. Корректность требует, чтобы в каждый момент времени в критической секции находился только один поток.
Напрашивается решение — завести флаг locked:
/* наивная попытка */
volatile int locked = 0;
void naive_lock(void) {
while (locked) /* ждём, пока занято */
;
locked = 1; /* захватываем */
}Попытка не работает: проверка locked == 0 и присваивание locked = 1 — это ровно та же пара load + store. Два потока могут оба прочитать locked == 0 и оба записать 1, считая, что блокировка захвачена. Та же гонка, рекурсия проблемы. Для решения нужна аппаратная поддержка — инструкция, выполняющая чтение и запись как одну неделимую операцию.
Три инструмента синхронизации
Задача синхронизации решается на трёх уровнях. Атомарная инструкция CAS (Compare-And-Swap) обеспечивает неделимость одной операции чтения-модификации-записи. Мьютекс (mutex) защищает произвольный блок кода, внутри которого может быть десятки операций, а не одна. Futex — это механизм реализации мьютекса в Linux, который избегает системного вызова при отсутствии конкуренции. Три уровня — не альтернативы, а слои: мьютекс построен на основе CAS, а futex — способ сделать мьютекс быстрым.
программист использует:
+----------------+
| mutex | lock()/unlock() -- защищает блок кода
+-------+--------+
|
| реализован через
v
+----------------+
| futex | fast path: CAS в user space (~10 ns)
| | slow path: futex() syscall, поток спит
+-------+--------+
|
| построен на
v
+----------------+
| CAS | одна атомарная аппаратная
| (процессор) | read-modify-write инструкция
+----------------+
Compare-and-swap
CAS (compare-and-swap) — атомарная операция, выполняющая чтение, сравнение и условную запись как единое целое. Она принимает три аргумента: адрес ячейки, ожидаемое значение и новое значение. Если текущее содержимое ячейки совпадает с ожидаемым — CAS записывает новое и возвращает успех. Если содержимое изменилось (другой поток успел записать своё) — CAS не трогает ячейку и возвращает неудачу.
С помощью CAS счётчик инкрементируется без потерь:
#include <stdatomic.h>
void atomic_increment(atomic_int *ptr) {
int old;
do {
old = atomic_load(ptr); /* читаем текущее значение */
} while (!atomic_compare_exchange_weak(ptr, &old, old + 1));
/* weak может отказать и при отсутствии реальной конкуренции */
/* (spurious failure), поэтому CAS всегда обёрнут в цикл */
}Цикл работает так: поток читает текущее значение, вычисляет новое, затем пытается записать через CAS. Если значение изменилось (другой поток успел записать своё) или произошёл spurious failure (на архитектурах с LL/SC-реализацией), CAS отказывает — цикл повторяется с обновлённым значением. При низкой конкуренции цикл срабатывает с первой попытки. При высокой конкуренции потоки повторяют попытки, но ни один инкремент не теряется.
На практике для простого инкремента не нужно писать CAS-цикл вручную. В C — atomic_fetch_add(&counter, 1), в Rust — counter.fetch_add(1, Ordering::Relaxed). Компилятор превращает вызов в аппаратный атомарный RMW-примитив.
Стоимость атомарных операций
Атомарная инструкция без конкуренции (uncontended) стоит ~5-20 нс. При конкуренции (contended) цена вырастает до ~50-200 нс: каждая операция требует получения кеш-линии от другого ядра — подробности в атомарных инструкциях и когерентности кешей.
Для счётчика — одной операции read-modify-write — атомарный инкремент идеален. Но что, если критическая секция содержит десяток операций? Банковский перевод: прочитать баланс счёта A, прочитать баланс счёта B, проверить, что на A достаточно средств, списать с A, зачислить на B. CAS защищает одну ячейку за раз — он не может обеспечить атомарность всех пяти шагов вместе.
Мьютекс
Мьютекс (mutex, от mutual exclusion — взаимное исключение) защищает произвольный блок кода. Поток вызывает lock() перед входом в критическую секцию и unlock() при выходе. Если мьютекс уже захвачен другим потоком, lock() блокирует текущий поток до освобождения.
#include <pthread.h>
static int counter = 0;
static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
void *worker(void *arg) {
for (int i = 0; i < 1000000; i++) {
pthread_mutex_lock(&mtx);
counter++; /* критическая секция */
pthread_mutex_unlock(&mtx);
}
return NULL;
}Теперь counter == 2 000 000 всегда. Но производительность упала: каждый инкремент обёрнут в lock/unlock, а это десятки наносекунд накладных расходов. Для счётчика правильным решением остаётся atomic_fetch_add. Мьютекс нужен, когда критическая секция — не одна инструкция, а несколько связанных операций, которые должны выполниться целиком или не выполниться вовсе.
Внутри мьютекс использует CAS для захвата: lock() пытается атомарно записать 1 в поле состояния (0 = свободен, 1 = занят). Если CAS удался — мьютекс захвачен. Вопрос в том, что делать, если CAS не удался и мьютекс занят.
Спинлок и спящий мьютекс
Два варианта ожидания освобождения мьютекса.
Спинлок (spinlock) крутится в цикле, непрерывно повторяя CAS: «занят? а сейчас? а сейчас?». Поток не уходит в ядро, не теряет свой квант — но сжигает процессорное время. Спинлок оправдан, когда критическая секция короче стоимости системного вызова: если мьютекс удерживается 50 нс, а вход в ядро для усыпления стоит 1-2 мкс, дешевле покрутиться. Ядро Linux использует спинлоки для защиты коротких участков кода, выполняемых с запрещёнными прерываниями.
Спящий мьютекс при неудачном CAS просит ядро усыпить текущий поток. Поток переходит из состояния RUNNING в SLEEPING и не потребляет процессорное время. При unlock() ядро будит один из спящих потоков. Стоимость усыпления и пробуждения — 1-2 мкс (два переключения контекста), но эта цена разовая: пока поток спит, ядро работает с другими потоками. Для критических секций длиннее 100 нс или при высокой конкуренции спящий мьютекс выгоднее спинлока.
pthread_mutex_t на Linux — спящий мьютекс. Но реализован он хитрее, чем простой выбор «spin или sleep».
Futex: быстрый путь без ядра
До 2002 года Linux-реализация потоков (LinuxThreads) на каждый lock() и unlock() выполняла системный вызов — переход из user space в kernel space и обратно. Каждый вызов стоил 1-2 мкс, даже если мьютекс свободен и конкуренции нет. Для приложения, выполняющего миллионы операций lock/unlock в секунду, накладные расходы становились узким местом.
Futex (fast userspace mutex) — механизм, введённый в Linux 2.5.7 (2002) вместе с NPTL (Native POSIX Threads Library). Идея: хранить состояние мьютекса в обычной переменной в user space и входить в ядро только тогда, когда действительно нужно ждать.
Захват (lock) работает в два шага. Fast path: CAS пытается атомарно переключить состояние мьютекса с 0 (свободен) на 1 (занят). Если CAS удался — мьютекс захвачен, системного вызова не было. Одна атомарная инструкция, ~10-20 нс. Slow path: если CAS обнаружил, что мьютекс занят, поток выполняет системный вызов futex(FUTEX_WAIT). Ядро проверяет значение переменной ещё раз (чтобы мьютекс не был освобождён между неудачным CAS и входом в ядро) и, если мьютекс по-прежнему занят, усыпляет поток.
Освобождение (unlock): CAS записывает 0 в переменную мьютекса. Если были ожидающие потоки (что определяется по значению переменной: обычно 2 = «занят и есть ожидающие»), выполняется futex(FUTEX_WAKE) — ядро будит один из спящих потоков.
lock():
CAS(state, 0 -> 1)
|
+----+----+
| |
удалось не удалось
| |
готово futex(FUTEX_WAIT)
~10 ns поток спит в ядре
unlock():
state = 0
|
есть ожидающие?
+----+----+
| |
нет да
| |
готово futex(FUTEX_WAKE)
~10 ns будим один поток
Все реализации pthread_mutex_lock на Linux используют futex. Когда профилировщик показывает 10 нс на uncontended lock/unlock, это и есть fast path: два CAS без единого системного вызова.
Задача: futex — это примитив или реализация?
Частая ошибка: считать futex отдельным примитивом синхронизации наравне с мьютексом и семафором. «Можно использовать мьютекс, а можно futex» — так не работает.
Futex — не альтернатива мьютексу, а его реализация на Linux. Программист использует
pthread_mutex_lock, а внутри вызова происходит CAS + при необходимостиfutex(FUTEX_WAIT). Программисту не нужно работать сfutex()напрямую — это API ядра, на котором построены библиотечные примитивы.
Семафор
CAS защищает одну переменную, мьютекс впускает в критическую секцию один поток. Семафор (semaphore) обобщает: он хранит счётчик и впускает до N потоков одновременно.
Операция sem_wait() атомарно уменьшает счётчик на 1. Если счётчик уже 0, поток засыпает. Операция sem_post() увеличивает счётчик на 1 и будит одного из ожидающих потоков. Бинарный семафор (N=1) по поведению похож на мьютекс, но у семафора нет понятия владельца — sem_post() может вызвать любой поток, не обязательно тот, кто вызвал sem_wait().
Типичный пример — пул из 10 подключений к базе данных. Семафор инициализируется значением 10. Каждый поток перед использованием подключения вызывает sem_wait(). Первые 10 потоков проходят без ожидания, одиннадцатый засыпает до тех пор, пока один из первых десяти не вернёт подключение через sem_post().
RWLock: читатели и писатели
Мьютекс последователен: пока один поток читает данные внутри критической секции, остальные ждут, хотя чтение не модифицирует данные и несколько одновременных чтений безопасны. RWLock (read-write lock) разделяет доступ: одновременно допускается произвольное количество читателей ИЛИ один писатель.
RWLock: допустимые состояния
┌─────────────────────────────────────────────┐
│ N читателей (rdlock) 0 писателей │ ok
│ 0 читателей 1 писатель (wrlock)│ ok
│ N читателей 1 писатель │ запрещено
│ 0 читателей M писателей │ запрещено
└─────────────────────────────────────────────┘
RWLock выгоден, когда операций чтения значительно больше, чем записи: конфигурация, роутинг-таблица, кеш ответов. Но захват RWLock стоит дороже мьютекса — 30-50 нс против 10-20 нс, потому что внутри атомарно обновляется счётчик читателей. Если чтения не преобладают (скажем, соотношение 3:1), накладные расходы RWLock могут перевесить выигрыш от параллельности, и простой мьютекс окажется быстрее.
Условная переменная: ожидание события
Мьютекс защищает данные, но не умеет ждать, пока данные изменятся определённым образом. Классическая задача — producer-consumer: один поток добавляет элементы в очередь, другой забирает и обрабатывает. Consumer должен ждать, пока очередь не станет непустой. Крутиться в цикле с lock(); check(); unlock(); — это busy wait (активное ожидание), расход CPU впустую.
Условная переменная (condition variable, condvar) позволяет потоку атомарно освободить мьютекс и уснуть, ожидая сигнала от другого потока. pthread_cond_wait(&cond, &mtx) выполняет три действия как единое целое: освобождает мьютекс, добавляет поток в очередь ожидания, усыпляет поток. Когда другой поток вызывает pthread_cond_signal(&cond), один из ожидающих просыпается, автоматически захватывает мьютекс обратно и продолжает выполнение.
#include <pthread.h>
#include <stdbool.h>
#define QUEUE_SIZE 16
static int queue[QUEUE_SIZE];
static int count = 0;
static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t not_empty = PTHREAD_COND_INITIALIZER;
static pthread_cond_t not_full = PTHREAD_COND_INITIALIZER;
void *producer(void *arg) {
for (int i = 0; i < 1000000; i++) {
pthread_mutex_lock(&mtx);
while (count == QUEUE_SIZE) /* очередь полна */
pthread_cond_wait(¬_full, &mtx); /* спим, мьютекс отпущен */
queue[count++] = i;
pthread_cond_signal(¬_empty); /* будим consumer */
pthread_mutex_unlock(&mtx);
}
return NULL;
}
void *consumer(void *arg) {
for (int i = 0; i < 1000000; i++) {
pthread_mutex_lock(&mtx);
while (count == 0) /* очередь пуста */
pthread_cond_wait(¬_empty, &mtx); /* спим, мьютекс отпущен */
int item = queue[--count];
pthread_cond_signal(¬_full); /* будим producer */
pthread_mutex_unlock(&mtx);
/* обработка item */
}
return NULL;
}Проверка условия — while, не if. Это принципиально. POSIX допускает ложные пробуждения (spurious wakeups): поток может проснуться без явного сигнала. Причина — оптимизация реализации: ядро может разбудить поток при изменении состояния, которое не связано с сигналом condvar. Если проверка — if, поток после ложного пробуждения пройдёт дальше, не проверив условие повторно, и возьмёт из пустой очереди мусор. Цикл while перепроверяет условие после каждого пробуждения.
Банковский перевод: блокировка нескольких ресурсов
Вернёмся к банковскому переводу. transfer(A, B, amount): списать amount с баланса A, зачислить на баланс B. Вся операция должна быть атомарной — промежуточное состояние (деньги списаны, но не зачислены) не должно быть видно другим потокам.
CAS не подходит: можно атомарно обновить баланс A, но между обновлением A и обновлением B другой поток может прочитать несогласованное состояние — деньги исчезли из A, но не появились в B. Нужен мьютекс, причём на оба счёта:
struct account {
int id;
int balance;
pthread_mutex_t mtx;
};
void transfer(struct account *from, struct account *to, int amount) {
pthread_mutex_lock(&from->mtx);
pthread_mutex_lock(&to->mtx);
if (from->balance >= amount) {
from->balance -= amount;
to->balance += amount;
}
pthread_mutex_unlock(&to->mtx);
pthread_mutex_unlock(&from->mtx);
}Код работает — но содержит ловушку.
Deadlock
Поток 1 вызывает transfer(A, B, 100): захватывает мьютекс A, пытается захватить мьютекс B. Параллельно поток 2 вызывает transfer(B, A, 50): захватывает мьютекс B, пытается захватить мьютекс A. Каждый поток удерживает ресурс, который нужен другому, и ждёт ресурс, который удерживает другой. Ни один из них не может продвинуться. Это взаимная блокировка (deadlock).
поток 1 поток 2
------- -------
lock(A.mtx) -- ok lock(B.mtx) -- ok
lock(B.mtx) -- ждёт... lock(A.mtx) -- ждёт...
| |
+--------> deadlock <----------+
Deadlock возникает при одновременном выполнении четырёх условий:
-
Взаимное исключение (mutual exclusion) — ресурс может удерживаться только одним потоком. Это свойство самого мьютекса, его нельзя убрать.
-
Удержание и ожидание (hold and wait) — поток, удерживая один ресурс, ожидает другой.
-
Неотнимаемость (no preemption) — мьютекс не может быть отобран у потока принудительно, только добровольно освобождён.
-
Циклическое ожидание (circular wait) — поток 1 ждёт ресурс потока 2, поток 2 ждёт ресурс потока 1.
Достаточно разорвать одно из четырёх условий, чтобы deadlock стал невозможен. На практике разрывают четвёртое — циклическое ожидание — через упорядочивание блокировок (lock ordering): всегда захватывать мьютексы в фиксированном порядке. В банковском переводе — по возрастанию id счёта:
void transfer_safe(struct account *a, struct account *b, int amount) {
struct account *first = a->id < b->id ? a : b;
struct account *second = a->id < b->id ? b : a;
pthread_mutex_lock(&first->mtx);
pthread_mutex_lock(&second->mtx);
if (a->balance >= amount) {
a->balance -= amount;
b->balance += amount;
}
pthread_mutex_unlock(&second->mtx);
pthread_mutex_unlock(&first->mtx);
}Теперь оба потока сначала захватывают мьютекс с меньшим id. Циклического ожидания быть не может: если мьютекс A (id=1) захвачен, мьютекс B (id=2) ещё не захвачен — второй поток будет ждать A, не удерживая B.
Альтернативный подход — try-lock с откатом: pthread_mutex_trylock() пытается захватить мьютекс без блокировки. Если захват не удался, поток освобождает все удерживаемые мьютексы, ждёт случайное время (backoff — отступление с паузой) и пытается заново. Этот подход разрывает условие «hold and wait», но при высокой конкуренции приводит к livelock (живая блокировка) — потоки бесконечно отпускают и перезахватывают, не продвигаясь.
Типы мьютексов pthread
pthread_mutex инициализируется с атрибутами, которые меняют поведение при ошибках программиста. Тип задаётся через pthread_mutexattr_settype() до pthread_mutex_init().
PTHREAD_MUTEX_DEFAULT (он же NORMAL) — базовый тип. Если поток попытается захватить мьютекс, который он сам уже держит — deadlock: поток ждёт освобождения мьютекса, который сам же удерживает. Если поток B попытается unlock() мьютекс, захваченный потоком A — поведение не определено.
PTHREAD_MUTEX_ERRORCHECK — при повторном захвате тем же потоком lock() возвращает EDEADLK вместо зависания. При unlock() чужого мьютекса — возвращает EPERM. Дороже на ~5–10 нс: реализация pthread хранит владельца и проверяет его на userspace fast path, а при конкуренции всё равно уходит в ядро через futex. Это полезный режим для отладки, потому что ловит баги раньше, чем они превращаются в немой deadlock.
PTHREAD_MUTEX_RECURSIVE — разрешает одному потоку захватывать мьютекс несколько раз. Внутри ведётся счётчик: первый lock() — счётчик 1, второй lock() из того же потока — счётчик 2. Каждый unlock() уменьшает счётчик, мьютекс освобождается при 0. Полезен, когда функция A захватывает мьютекс и вызывает функцию B, которая тоже его захватывает. На практике считается антипаттерном — если функции не знают, под каким мьютексом вызваны, архитектура запутана.
Отмена потока
Иногда поток нужно остановить извне — пользователь нажал «отмена», результат больше не нужен. pthread_cancel(thread_id) не убивает поток мгновенно — устанавливает флаг «отмена запрошена». Поток завершается, когда достигает cancellation point (точки отмены) — большинство блокирующих функций: read(), write(), sleep(), pthread_cond_wait().
Поток контролирует отмену через pthread_setcancelstate(): PTHREAD_CANCEL_DISABLE игнорирует запрос, PTHREAD_CANCEL_ENABLE разрешает. pthread_setcanceltype() выбирает момент: DEFERRED (по умолчанию, только в cancellation points) или ASYNCHRONOUS (в любой момент — почти невозможно использовать корректно, ресурсы остаются незакрытыми).
На практике cancellation используется редко. Более предсказуемый подход — cooperative cancellation через атомарный флаг:
atomic_bool should_stop = false;
// в потоке:
while (!atomic_load(&should_stop)) {
do_work();
}
// извне:
atomic_store(&should_stop, true);
pthread_join(thread_id, NULL);За пределами мьютекса
Мьютекс и CAS решили задачу корректного доступа к общим данным. CAS стоит 5-20 нс без конкуренции, мьютекс через futex — 10-20 нс. Существует и радикально иной подход: вместо координации доступа к общим данным между потоками — обрабатывать все запросы в одном потоке, исключив гонки по конструкции. Event loop Redis работает именно так: один поток обрабатывает все команды последовательно, а мультиплексирование позволяет ему обслуживать тысячи клиентов без блокировки.
Для флага «данные готовы» кажется достаточным простой записи в переменную из одного потока и чтения из другого — два обращения к памяти, никаких блокировок. На x86 это работает. На ARM — нет: процессор может переупорядочить инструкции, и читающий поток увидит флаг «готово» раньше, чем данные, которые этот флаг сигнализирует. Почему так происходит и как это контролировать — тема модели памяти.
См. также
- Ruby Mutex и GVL —
Mutex#synchronizeобёрнут надpthread_mutex; GVL — внутренний мьютекс VM, сериализующий исполнение Ruby bytecode даже при явных lock’ах пользователя
Sources
- Michael Kerrisk, 2010, The Linux Programming Interface — Chapters 30-31: Threads: Synchronization — https://man7.org/tlpi/
- Ulrich Drepper, 2011, Futexes Are Tricky — https://www.akkadia.org/drepper/futex.pdf
man 2 futex— семантика futex — https://man7.org/linux/man-pages/man2/futex.2.htmlman 7 pthreads— потоки POSIX — https://man7.org/linux/man-pages/man7/pthreads.7.html