Синхронизация

Права доступа и capabilities | Модель памяти

Права доступа и capabilities определяют, кто может обращаться к ресурсу: ядро сверяет личность вызывающего — числовые идентификаторы пользователя и группы (UID, GID) и набор привилегий (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 000 000 – 1 600 000 */
    return 0;
}

Результат — где-то от 1 000 000 до 1 600 000 вместо 2 000 000, и он меняется от запуска к запуску. Причина в том, что counter++ — не одна неделимая операция.

Компилятор превращает counter++ в три шага: load значения из памяти в регистр, add единицы к регистру, store результата обратно в память. На x86 компилятор может сгенерировать одну инструкцию инкремента в памяти — но и она не атомарна без префикса LOCK, который заставляет процессор удержать ячейку на время операции: иначе процессор выполняет те же три шага, только внутри одной инструкции. Планировщик может вытеснить поток между любыми из этих шагов:

поток 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 (динамический анализатор гонок, встроенный в gcc/clang). Гонка — ошибка на уровне логики, где корректность зависит от тайминга. Классический пример — TOCTOU (time-of-check-to-time-of-use, разрыв между проверкой и использованием): программа проверяет, существует ли файл (access()), затем открывает его (open()). Между проверкой и открытием другой процесс может удалить файл или заменить его символической ссылкой. Каждая из двух операций по отдельности потокобезопасна — гонки данных нет, но логическая гонка есть.

Критическая секция

Участок кода, в котором поток работает с разделяемыми данными, называется критической секцией (critical section). В примере со счётчиком критическая секция — три инструкции load-add-store. Корректность требует, чтобы в каждый момент времени в критической секции находился только один поток.

Напрашивается решение — завести флаг locked:

/* наивная попытка */
volatile int locked = 0;
 
void naive_lock(void) {
    while (locked)          /* ждём, пока занято */
        ;
    locked = 1;             /* захватываем */
}

Пометка volatile запрещает компилятору держать значение в регистре и заставляет каждый раз читать его из памяти — без этого цикл ожидания мог бы крутиться по устаревшей копии. Но видимость свежего значения — это не атомарность: попытка всё равно не работает. Проверка locked == 0 и присваивание locked = 1 — это ровно та же пара load + store. Два потока могут оба прочитать locked == 0 и оба записать 1, считая, что блокировка захвачена. Та же гонка, рекурсия проблемы. Для решения нужна аппаратная поддержка — инструкция, выполняющая чтение и запись как одну неделимую операцию.

Три инструмента синхронизации

Одной атомарной инструкции достаточно, чтобы защитить одну ячейку памяти. Для десятка связанных операций нужен мьютекс. А чтобы мьютекс не обходился в системный вызов при каждом захвате — используется futex. Три уровня не альтернативы, а слои: мьютекс построен поверх CAS, futex — способ сделать этот мьютекс быстрым.

  программист использует:

  +----------------+
  |     mutex      |  lock()/unlock() -- защищает блок кода
  +-------+--------+
          |
          | реализован через
          v
  +----------------+
  |     futex      |  fast path: CAS в user space (~15-20 нс)
  |                |  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));  /* записываем, если не изменилось */
}

Цикл работает так: поток читает текущее значение, вычисляет новое, затем пытается записать через CAS. Если значение изменилось — другой поток успел записать своё — CAS отказывает, и цикл повторяется с уже обновлённым значением. CAS всегда обёрнут в цикл ещё и потому, что weak-вариант на некоторых процессорах может отказать даже без реальной конкуренции: там атомарность строится не на одной инструкции, а на паре «пометить ячейку при чтении — записать, если пометка не сбита» (LL/SC — load-linked/store-conditional, на ARM и RISC-V), и любое внешнее обращение к ячейке между ними сбивает пометку. Повтор цикла одинаково закрывает оба случая. При низкой конкуренции цикл срабатывает с первой попытки. При высокой конкуренции потоки повторяют попытки, но ни один инкремент не теряется.

На практике для простого инкремента не нужно писать CAS-цикл вручную. В C — atomic_fetch_add(&counter, 1). Компилятор превращает вызов в аппаратный атомарный RMW-примитив.

Атомарный инкремент идеален для счётчика — одной операции 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. Атомарная операция без конкуренции стоит ~6-8 нс; мьютекс через futex — ~15-20 нс при отсутствии конкуренции, и значительно дороже, если другой поток уже держит блокировку — подробности в атомарных инструкциях. Для счётчика правильным решением остаётся atomic_fetch_add. Мьютекс нужен, когда критическая секция — не одна инструкция, а несколько связанных операций, которые должны выполниться целиком или не выполниться вовсе.

Внутри мьютекс использует CAS для захвата: lock() пытается атомарно записать 1 в поле состояния (0 = свободен, 1 = занят). Если CAS удался — мьютекс захвачен. Вопрос в том, что делать, если CAS не удался и мьютекс занят.

Спинлок и спящий мьютекс

Два варианта ожидания освобождения мьютекса.

Спинлок (spinlock) крутится в цикле, непрерывно повторяя CAS: «занят? а сейчас? а сейчас?». Поток не уходит в ядро, не теряет свой квант — но сжигает процессорное время. Спинлок оправдан, когда критическая секция короче стоимости системного вызова: если мьютекс удерживается 50 нс, а вход в ядро для усыпления стоит 1-2 мкс, дешевле покрутиться. Ядро Linux использует спинлоки для защиты коротких участков кода там, где усыпление потока невозможно: spin_lock() запрещает вытеснение на текущем CPU, а spin_lock_irq() / spin_lock_irqsave() дополнительно запрещают аппаратные прерывания — последнее нужно только когда защищаемый ресурс доступен из обработчика прерывания.

Спящий мьютекс при неудачном CAS просит ядро усыпить текущий поток. Поток переходит из состояния RUNNING в SLEEPING и не потребляет процессорное время. При unlock() ядро будит один из спящих потоков. Стоимость усыпления и пробуждения — 1-2 мкс (два переключения контекста), но эта цена разовая: пока поток спит, ядро работает с другими потоками. Для критических секций длиннее 100 нс или при высокой конкуренции спящий мьютекс выгоднее спинлока.

pthread_mutex_t на Linux — спящий мьютекс. Но реализован он хитрее, чем простой выбор «spin или sleep».

Futex: быстрый путь без ядра

До 2002 года Linux-реализация потоков (LinuxThreads) при захвате и освобождении мьютекса каждый раз выполняла системный вызов — переход из user space в kernel space и обратно. Каждый вызов стоил 1-2 мкс, даже если мьютекс свободен и конкуренции нет. Для приложения, выполняющего миллионы операций lock/unlock в секунду, накладные расходы становились узким местом.

Futex (fast userspace mutex — «быстрый мьютекс в пространстве пользователя») — механизм ядра, появившийся в Linux 2.5.7 (февраль 2002). Идея: хранить состояние мьютекса в обычной переменной в user space и входить в ядро только тогда, когда действительно нужно ждать. NPTL (Native POSIX Threads Library) — реализация pthreads, пришедшая на смену LinuxThreads в glibc 2.3 в 2003 году — использовала futex как основу для pthread_mutex.

Захват (lock) работает в два шага. Fast path: CAS пытается атомарно переключить состояние мьютекса с 0 (свободен) на 1 (занят). Если CAS удался — мьютекс захвачен, системного вызова не было. Одна атомарная инструкция, ~15-20 нс. Slow path: если CAS обнаружил, что мьютекс занят, поток выполняет системный вызов futex(FUTEX_WAIT). Ядро проверяет значение переменной ещё раз (чтобы мьютекс не был освобождён между неудачным CAS и входом в ядро) и, если мьютекс по-прежнему занят, усыпляет поток.

При освобождении возникает вопрос, который не стоял на fast path: будить ли кого-то. Вызов futex(FUTEX_WAKE) — это снова системный вызов, и платить за него, когда никто не ждёт, означало бы вернуть ту самую цену, ради избавления от которой и придуман futex. Поэтому переменной мьютекса мало двух значений: к «свободен» (0) и «занят» (1) добавляется «занят, и есть ожидающие» (2). Поток, который при slow path уходит в FUTEX_WAIT, перед сном поднимает состояние до 2 — оставляет метку, что его нужно разбудить.

Освобождение (unlock): atomic exchange записывает 0 в переменную мьютекса и возвращает прежнее значение — одна неделимая операция, без сравнения с ожидаемым. Если вернулось 2 — были ожидающие, выполняется futex(FUTEX_WAKE), ядро будит один из спящих потоков; если вернулось 1, будить некого, и unlock обходится без системного вызова.

lock():

    CAS(state, 0 -> 1)
         |
    +----+----+
    |         |
  удалось   не удалось
    |         |
  готово    futex(FUTEX_WAIT)
  ~15-20 нс поток спит в ядре


unlock():

    state = 0
         |
    есть ожидающие?
    +----+----+
    |         |
   нет       да
    |         |
  готово    futex(FUTEX_WAKE)
  ~15-20 нс будим один поток

Все реализации pthread_mutex_lock на Linux используют futex. Когда профилировщик показывает 15-20 нс на uncontended lock/unlock, это и есть fast path: два CAS без единого системного вызова.

Семафор

Представим пул из 10 подключений к базе данных. Мьютекс не подходит: он впускает в критическую секцию ровно один поток, тогда как здесь нужно пропускать до 10 одновременно. Нужен счётчик, который убывает при захвате ресурса и растёт при освобождении, блокируя новых потоков, когда ресурсы исчерпаны.

Семафор (semaphore) — это именно такой счётчик. Операция sem_wait() атомарно уменьшает его на 1, но только пока счётчик положителен; если он уже равен нулю, поток засыпает и ждёт, пока счётчик снова не станет положительным. Операция sem_post() увеличивает счётчик на 1 и будит одного из ожидающих. Бинарный семафор (начальное значение 1) по поведению похож на мьютекс, но у семафора нет понятия владельца — sem_post() может вызвать любой поток, не обязательно тот, кто вызвал sem_wait().

Для пула подключений: семафор инициализируется значением 10. Каждый из первых десяти потоков, вызывая sem_wait(), уменьшает счётчик: 10, 9, … 1, 0 — и проходит без ожидания. Одиннадцатый видит ноль и засыпает. Когда один из первых десяти возвращает подключение через sem_post(), счётчик поднимается до 1, и разбуженный одиннадцатый снова уводит его в 0, заняв освободившееся место.

RWLock: читатели и писатели

Конфигурация маршрутизатора обновляется раз в час, но читается тысячи раз в секунду. С обычным мьютексом все эти тысячи запросов выстраиваются в очередь друг за другом, хотя читают одно и то же — никакого изменения данных не происходит. Параллельное чтение безопасно: два потока, читающих одну переменную, не мешают друг другу.

RWLock (read-write lock) разделяет захват по типу доступа: одновременно допускается произвольное количество читателей ИЛИ один писатель, но не то и другое вместе.

RWLock: допустимые состояния

  ┌─────────────────────────────────────────────┐
  │  N читателей (rdlock)      0 писателей      │  ok
  │  0 читателей               1 писатель (wrlock)│  ok
  │  N читателей               1 писатель        │  запрещено
  │  0 читателей               M писателей       │  запрещено
  └─────────────────────────────────────────────┘

RWLock выгоден, когда операций чтения значительно больше, чем записи: конфигурация, роутинг-таблица, кеш ответов. Но захват RWLock стоит дороже мьютекса — внутри атомарно обновляется счётчик читателей, и без конкуренции это обходится примерно вдвое дороже обычного futex-мьютекса. Если чтения не преобладают (скажем, соотношение 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(&not_full, &mtx); /* спим, мьютекс отпущен */
        queue[count++] = i;
        pthread_cond_signal(&not_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(&not_empty, &mtx); /* спим, мьютекс отпущен */
        int item = queue[--count];
        pthread_cond_signal(&not_full);          /* будим producer */
        pthread_mutex_unlock(&mtx);
        /* обработка item */
    }
    return NULL;
}

Проверка условия — while, не if. Это принципиально, и причин две.

Первая: пробуждение не гарантирует, что условие всё ещё выполнено. pthread_cond_signal лишь переводит поток в готовность — прежде чем продолжить, тот обязан заново захватить мьютекс. Если потребителей несколько, между сигналом и повторным захватом другой потребитель успевает войти в критическую секцию и забрать элемент: разбуженный поток получает мьютекс, но очередь снова пуста. Сигнал был настоящим, а условие уже ложно.

Вторая: POSIX допускает ложные пробуждения (spurious wakeups) — поток может проснуться вообще без сигнала (издержка реализации: ядро может разбудить его при изменении состояния, не связанном с condvar).

В обоих случаях if пропустил бы поток дальше без повторной проверки, и queue[--count] обратился бы к пустой очереди. Цикл while перепроверяет условие после каждого пробуждения и закрывает оба случая.

Банковский перевод: блокировка нескольких ресурсов

Тот самый банковский перевод, для которого CAS одной ячейки оказался мал. 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 возникает при одновременном выполнении четырёх условий:

  1. Взаимное исключение (mutual exclusion) — ресурс может удерживаться только одним потоком. Это свойство самого мьютекса, его нельзя убрать.

  2. Удержание и ожидание (hold and wait) — поток, удерживая один ресурс, ожидает другой.

  3. Неотнимаемость (no preemption) — мьютекс не может быть отобран у потока принудительно, только добровольно освобождён.

  4. Циклическое ожидание (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

Разработчик написал lock() дважды внутри одной функции — программа молча зависает. Задача отладки: как поймать такую ошибку раньше, чем она уйдёт в production? pthread_mutex инициализируется с атрибутами, которые меняют поведение при ошибках программиста. Тип задаётся через pthread_mutexattr_settype() до pthread_mutex_init().

PTHREAD_MUTEX_DEFAULT — базовый тип. В glibc соответствует NORMAL. Если поток попытается захватить мьютекс, который он сам уже держит — deadlock: поток ждёт освобождения мьютекса, который сам же удерживает. Если поток B попытается unlock() мьютекс, захваченный потоком A — поведение не определено.

PTHREAD_MUTEX_ERRORCHECK — при повторном захвате тем же потоком lock() возвращает EDEADLK вместо зависания. При unlock() чужого мьютекса — возвращает EPERM. Накладные расходы небольшие: реализация pthread хранит владельца и проверяет его на fast path, а при конкуренции всё равно уходит в ядро через futex. Это полезный режим для отладки, потому что ловит баги раньше, чем они превращаются в немой deadlock.

PTHREAD_MUTEX_RECURSIVE — разрешает одному потоку захватывать мьютекс несколько раз. Внутри ведётся счётчик: первый lock() — счётчик 1, второй lock() из того же потока — счётчик 2. Каждый unlock() уменьшает счётчик, мьютекс освобождается при 0. Полезен, когда функция A захватывает мьютекс и вызывает функцию B, которая тоже его захватывает. На практике считается антипаттерном — если функции не знают, под каким мьютексом вызваны, архитектура запутана.

Остановка потока через атомарный флаг

Иногда поток нужно остановить извне — пользователь нажал «отмена», результат больше не нужен. Надёжнее всего сигнализировать через атомарный флаг: поток проверяет его в цикле и завершается сам:

atomic_bool should_stop = false;
 
// в потоке:
while (!atomic_load(&should_stop)) {
    do_work();
}
 
// извне:
atomic_store(&should_stop, true);
pthread_join(thread_id, NULL);

Спящий в pthread_cond_wait поток сам флаг не проверяет — он ждёт сигнала. Поэтому установка флага извне работает с такими потоками только в паре с пробуждением: тот, кто пишет should_stop = true, следом вызывает pthread_cond_signal(). Разбуженный поток перепроверит условие цикла, увидит флаг и завершится.

За пределами мьютекса

Существует и радикально иной подход: вместо координации доступа к общим данным между потоками — обрабатывать все запросы в одном потоке, исключив гонки по конструкции. Event loop Redis работает именно так: один поток обрабатывает все команды последовательно, а мультиплексирование позволяет ему обслуживать тысячи клиентов без блокировки.

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

См. также

  • Ruby Mutex и GVLMutex#synchronize обёрнут над pthread_mutex; GVL — внутренний мьютекс VM, сериализующий исполнение Ruby bytecode даже при явных lock’ах пользователя

Sources


Права доступа и capabilities | Модель памяти