Системное программирование на C Потоки и синхронизация: pthreads, мьютексы, условные переменные, атомарность
0%

Потоки и синхронизация: pthreads, мьютексы, условные переменные, атомарность

Потоки и синхронизация: pthreads, мьютексы, условные переменные, атомарность

В предыдущей статье трека — https://courses.digitable.life/post/systems-programming/08-processes-and-signals/ — процессы разделены аппаратно: у каждого своё адресное пространство, и чтобы испортить память соседа, нужно очень постараться. Потоки устроены ровно наоборот. Поток — это отдельный поток управления внутри уже существующего адресного пространства. Своих у него ровно четыре вещи: стек, регистры, thread-local-хранилище и маска сигналов. Всё остальное — глобальные переменные, куча, дескрипторы, обработчики сигналов — общее.

Из этой одной строчки следует всё остальное. Дешевизна: создание потока в Linux — это clone() с флагами общего адресного пространства, порядка 10–30 микросекунд против сотен для fork(), а переключение между потоками не сбрасывает TLB. И симметричная цена: изоляции нет вообще. Ошибка синхронизации не даёт ни исключения, ни сигнала, ни строчки в логе. Она даёт неопределённое поведение, которое проявится через час на четырёхсокетной машине под нагрузкой — и не проявится ни разу на вашем ноутбуке за неделю отладки.

Статья про то, как писать многопоточный код на C так, чтобы он был корректен по построению, а не по результатам тестирования. Теорию конкурентности разбирает https://courses.digitable.life/post/computer-science/14-concurrency-and-parallelism/, планировщик и вытеснение — https://courses.digitable.life/post/operating-systems/03-processes-and-scheduling/; здесь — механика на уровне C и железа.

Что общее, а что своё

Разделяемое адресное пространство процесса и приватные части каждого потока

Практические следствия схемы:

  • Указатель на локальную переменную — обычный указатель. Отдали наружу &local — другой поток разыменует его без препятствий и получит use-after-free, как только владелец вышел из функции.
  • errno — thread-local: это макрос вида (*__errno_location()), а не глобальная переменная. Зато strerror возвращает статический буфер — нужен strerror_r. Тот же класс проблем у strtok, localtime, rand (есть _r-варианты).
  • exit() из любого потока убивает весь процесс, и возврат из main ему эквивалентен: остальные потоки умирают в произвольной точке с недописанными буферами. Завершать надо явно: закрыть очередь, дождаться pthread_join, потом выходить.
  • Стек потока конечен и не растёт, в отличие от стека главного потока: в glibc по умолчанию 8 МБ (от ulimit -s), выделяется mmap разом, снизу прикрыт страницей-стражем. Глубокая рекурсия или char buf[4 * 1024 * 1024] даёт SIGSEGV без предупреждения.
// threads_basic.c — сборка: gcc -std=c17 -Wall -Wextra -pthread -o t threads_basic.c
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

struct task { int id; long result; };

static void *worker(void *arg) {
    struct task *t = arg;                  // аргумент — void *, приводим обратно
    t->result = (long)t->id * t->id;
    return t;                              // вернуть можно что угодно, кроме адреса своей локальной
}

int main(void) {
    enum { N = 4 };
    pthread_t   th[N];
    struct task tasks[N];                  // массив в main живёт дольше потоков — это законно

    for (int i = 0; i < N; i++) {
        tasks[i] = (struct task){ .id = i };
        int rc = pthread_create(&th[i], NULL, worker, &tasks[i]);
        if (rc != 0) {                     // pthread_* возвращают КОД ОШИБКИ, а не -1 и errno
            fprintf(stderr, "pthread_create: %s\n", strerror(rc));
            exit(EXIT_FAILURE);
        }
    }
    for (int i = 0; i < N; i++) {
        void *ret = NULL;
        pthread_join(th[i], &ret);         // блокирует до завершения и забирает ресурсы потока
        printf("поток %d вернул %ld\n", ((struct task *)ret)->id, ((struct task *)ret)->result);
    }
    return 0;
}

Два места, где ошибаются почти все. Передача аргумента: классика — pthread_create(..., &i), когда все потоки получают адрес одной переменной цикла и читают её в непредсказуемый момент; правильный шаблон — отдельная структура на поток, живущая дольше потока. Утечка ресурсов: незаджойненный завершившийся поток держит TCB и стек — примерно 8 КБ плюс виртуальная память; если результат не нужен, вызывайте pthread_detach или создавайте с PTHREAD_CREATE_DETACHED, но тогда дождаться завершения уже нельзя, а значит, нельзя и безопасно освободить данные, которыми поток пользуется.

Флаг компиляции — именно -pthread, а не -lpthread: он ещё и определяет _REENTRANT, и выставляет правильные опции компоновки для платформы. С glibc 2.34 libpthread слит с libc, но -pthread остаётся корректным способом. C11 добавил переносимый <threads.h> (thrd_create, mtx_lock, cnd_wait), поддержанный в glibc с 2.28, но распространения он не получил: pthreads есть везде, богаче и лучше документирован.

Гонка данных — это неопределённое поведение

Формально (C17, 5.1.2.4p25): гонка данных — два доступа к одному объекту из разных потоков, где хотя бы один является записью, они не атомарны и не упорядочены отношением happens-before. Программа с гонкой данных имеет неопределённое поведение. Не «неопределённое значение переменной» — неопределённое поведение всей программы, с теми же последствиями, что и выход за границы массива (https://courses.digitable.life/post/systems-programming/11-undefined-behavior/).

Начнём с инкремента общего счётчика — gcc -O0 -S -masm=intel:

; три отдельные инструкции: читать, изменить, записать
mov  rax, QWORD PTR counter[rip]
add  rax, 1
mov  QWORD PTR counter[rip], rax

Между ними может вклиниться другой поток, прочитать старое значение и потерять чужой инкремент. Ожидаемо. Неожиданно то, что даёт gcc -O2 -S -masm=intel:

; ОДНА инструкция — и она всё равно НЕ атомарна
add  QWORD PTR counter[rip], 1

Одна инструкция x86 — не одна операция на шине. add [mem], 1 внутри распадается на чтение, сложение и запись, и без префикса lock между ними влезает другое ядро. «Оно же компилируется в одну инструкцию» — не аргумент.

Теперь пример, где компилятор ломает код совсем не так, как подсказывает интуиция:

static int done;                          // ни volatile, ни _Atomic
void wait_for_done(void) { while (!done) ; }
; gcc -O2: done читается ОДИН раз, дальше — вечный цикл
wait_for_done:
        mov     eax, DWORD PTR done[rip]
        test    eax, eax
        jne     .L1
.L3:    jmp     .L3                       ; вот и весь «цикл ожидания»
.L1:    ret

Компилятор прав. В отсутствие атомарных операций он вправе считать, что done никто параллельно не меняет, и вынести чтение из цикла — прямое следствие того, что гонка данных объявлена невозможной. Программа зависает намертво, причём только на -O2 и только в релизной сборке.

Пометка volatile int done уберёт вынос чтения — и создаст ложное чувство безопасности: volatile в C не даёт ни атомарности, ни упорядочивания относительно других переменных. Он придуман для регистров памяти-отображённой периферии (https://courses.digitable.life/post/embedded/03-gpio-and-peripherals/) и для sig_atomic_t в обработчиках сигналов. Правильное решение — _Atomic int done.

Модель памяти x86-64 (TSO) сильна: единственное разрешённое переупорядочивание — запись может быть отложена относительно последующего чтения. AArch64 и POWER переставляют почти всё. Код с гонкой, годами работавший на серверах Intel, начинает падать при переезде на Graviton или Apple Silicon — и это не «баг ARM», это ваш баг, который наконец стал видимым. Отсюда правило трека: «работает на моей машине» в многопоточном C не значит ничего.

Мьютекс: не только взаимное исключение

Мьютекс решает две задачи, и вторая важнее первой. Первая — взаимное исключение. Вторая — упорядочивание памяти: pthread_mutex_unlock имеет семантику release, pthread_mutex_lock — acquire. Всё, что поток записал до unlock, гарантированно видно потоку, который затем сделал lock. Без этого свойства мьютекс был бы бесполезен: вы бы честно исключали одновременный доступ и всё равно читали устаревшие данные из чужого кэша.

struct account {
    pthread_mutex_t mtx;
    long            balance;   // всё, что защищено mtx, перечисляйте прямо здесь
};

static pthread_mutex_t g_log_mtx = PTHREAD_MUTEX_INITIALIZER;   // для глобальных объектов

int account_init(struct account *a) {
    pthread_mutexattr_t attr;
    pthread_mutexattr_init(&attr);
    // ERRORCHECK ловит двойной lock и unlock чужого мьютекса — в отладочной сборке обязателен
    pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_ERRORCHECK);
    int rc = pthread_mutex_init(&a->mtx, &attr);
    pthread_mutexattr_destroy(&attr);
    a->balance = 0;
    return rc;
}

int account_withdraw(struct account *a, long amount) {
    pthread_mutex_lock(&a->mtx);
    int rc = -1;
    if (a->balance >= amount) {         // проверка и изменение — в ОДНОЙ критической секции
        a->balance -= amount;
        rc = 0;
    }
    pthread_mutex_unlock(&a->mtx);      // единственный выход: никаких return из-под лока
    return rc;
}
Тип мьютекса Повторный lock тем же потоком Назначение
PTHREAD_MUTEX_NORMAL взаимоблокировка быстрый путь по умолчанию
PTHREAD_MUTEX_ERRORCHECK возвращает EDEADLK отладочные сборки
PTHREAD_MUTEX_RECURSIVE считает вложенность «спасение» плохой архитектуры
PTHREAD_MUTEX_DEFAULT не определено реально NORMAL в glibc

Про рекурсивные мьютексы стоит сказать отдельно: они выглядят удобно, но прячут настоящую проблему. Если функция может быть вызвана и с захваченным локом, и без него, инвариант структуры в момент входа неизвестен. Дэвид Батенхоф, соавтор pthreads, разбирал это в известном письме на comp.programming.threads: рекурсивный мьютекс позволяет вызвать чужой колбэк из середины критической секции, когда структура находится в промежуточном, некорректном состоянии. Здоровая альтернатива — разделять публичные функции (берут лок) и внутренние _locked (требуют, чтобы лок уже был взят), фиксируя это прямо в имени.

Что происходит внутри: futex

pthread_mutex_t в glibc на x86-64 занимает 40 байт, из которых главное — одно 32-битное слово состояния. Захват незанятого мьютекса — атомарный CAS в пользовательском пространстве, без единого системного вызова, десятки наносекунд. В ядро поток уходит, только если мьютекс занят.

Практический вывод: незанятый мьютекс почти бесплатен, занятый стоит на три порядка дороже. Оптимизация многопоточного кода — почти всегда снижение конкуренции за локи, а не замена мьютексов на что-то «более быстрое». Механизм описан в man 2 futex и в статье Ульриха Дреппера Futexes Are Tricky (https://www.akkadia.org/drepper/futex.pdf), где разобрано и то, почему наивные реализации ошибаются. Рядом живут pthread_mutex_trylock (без блокировки) и pthread_mutex_timedlock (с таймаутом по CLOCK_REALTIME, что делает его уязвимым к переводу часов), а атрибут PTHREAD_PRIO_INHERIT включает наследование приоритета и снимает инверсию приоритетов — критично в системах реального времени, см. https://courses.digitable.life/post/embedded/06-rtos/.

Взаимные блокировки

Классические четыре условия Коффмана — взаимное исключение, удержание с ожиданием, отсутствие вытеснения, круговое ожидание. Для мьютексов первые три отменить нельзя, поэтому вся практика сводится к четвёртому: разорвать цикл в графе ожидания.

// Перевод между счетами — канонический пример взаимоблокировки:
// transfer(a, b) и transfer(b, a) параллельно намертво встают друг напротив друга.
// Лечение — глобальный порядок захвата. Для однотипных объектов он даётся бесплатно
// сравнением адресов: любой поток берёт локи в одном и том же направлении.
void transfer(struct account *from, struct account *to, long sum) {
    if (from == to) return;
    struct account *first  = from < to ? from : to;
    struct account *second = from < to ? to   : from;
    pthread_mutex_lock(&first->mtx);
    pthread_mutex_lock(&second->mtx);
    from->balance -= sum; to->balance += sum;
    pthread_mutex_unlock(&second->mtx);
    pthread_mutex_unlock(&first->mtx);
}

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

Инструменты: helgrind (valgrind --tool=helgrind) строит граф порядка захвата и ругается на его нарушение даже без реальной взаимоблокировки; ThreadSanitizer делает то же дешевле; для уже зависшего процесса — gdb -p PID и thread apply all bt, где картина «поток 7 в __lll_lock_wait, поток 12 тоже» видна сразу (https://courses.digitable.life/post/systems-programming/06-debugging/).

Условные переменные: ждать событие, а не лок

Мьютекс отвечает на вопрос «можно ли мне трогать данные», но не отвечает на вопрос «есть ли уже то, что мне нужно». Опрос в цикле (lock; check; unlock; usleep) работает, но жжёт процессор и добавляет задержку. Условная переменная — механизм «уснуть, отпустив мьютекс, и проснуться, когда кто-то скажет, что предикат мог измениться». Ключевое свойство pthread_cond_wait(&cv, &mtx): она атомарно отпускает мьютекс и встаёт в очередь ожидания, а при пробуждении захватывает мьютекс обратно. Без этой атомарности между unlock и постановкой в очередь влезал бы сигнализирующий поток, и его сигнал терялся бы навсегда — классическая проблема потерянного пробуждения.

Из перехода CWAIT → MWAIT → RUN следует главное правило: предикат проверяется в цикле while, никогда в if. Причин три. Ложные пробуждения разрешены стандартом и реально случаются при обработке сигналов. pthread_cond_broadcast будит всех, но условию удовлетворит только первый. И даже после корректного signal между пробуждением и захватом мьютекса влезает третий поток, успевающий забрать ресурс.

// bounded_queue.c — каноническая ограниченная очередь на мьютексе и двух условных переменных
#define QCAP 1024

struct queue {
    void            *items[QCAP];
    size_t           head, tail, count;
    bool             closed;
    pthread_mutex_t  mtx;
    pthread_cond_t   not_empty;   // «в очереди что-то появилось»
    pthread_cond_t   not_full;    // «в очереди освободилось место»
};

int q_push(struct queue *q, void *item) {
    pthread_mutex_lock(&q->mtx);
    while (q->count == QCAP && !q->closed)          // while, а не if
        pthread_cond_wait(&q->not_full, &q->mtx);
    if (q->closed) { pthread_mutex_unlock(&q->mtx); return -1; }
    q->items[q->tail] = item;
    q->tail = (q->tail + 1) % QCAP;
    q->count++;
    pthread_cond_signal(&q->not_empty);             // сигналим ПОД локом — см. ниже
    pthread_mutex_unlock(&q->mtx);
    return 0;
}

int q_pop(struct queue *q, void **out) {
    pthread_mutex_lock(&q->mtx);
    while (q->count == 0 && !q->closed)
        pthread_cond_wait(&q->not_empty, &q->mtx);
    if (q->count == 0) {                            // закрыта И пуста — честный конец работы
        pthread_mutex_unlock(&q->mtx);
        return -1;
    }
    *out = q->items[q->head];
    q->head = (q->head + 1) % QCAP;
    q->count--;
    pthread_cond_signal(&q->not_full);
    pthread_mutex_unlock(&q->mtx);
    return 0;
}

// Корректное завершение: без него воркеры зависнут в cond_wait навсегда
void q_close(struct queue *q) {
    pthread_mutex_lock(&q->mtx);
    q->closed = true;                               // предикат меняем ТОЛЬКО под мьютексом
    pthread_mutex_unlock(&q->mtx);
    pthread_cond_broadcast(&q->not_empty);          // именно broadcast: разбудить ВСЕХ
    pthread_cond_broadcast(&q->not_full);
}

Предикат меняется только под мьютексом — не стилистика, а условие корректности: если выставить closed без лока, поток успеет проверить предикат, решить «ждём» и уснуть уже после того, как broadcast прошёл; пробуждение потеряно, поток висит вечно.

signal или broadcast. signal будит один поток и достаточен, когда ожидающие взаимозаменяемы и одно событие делает возможной ровно одну операцию — как при добавлении одного элемента. broadcast нужен, когда меняется само условие (очередь закрылась) или ожидающие ждут разного на одной переменной. Ошибка «поставил signal вместо broadcast» даёт зависание одного потока из ста раз в сутки — идеальный гейзенбаг. Сигналить под локом или после unlock — оба варианта законны; под локом безопаснее: если разбуженный поток освобождает саму структуру с condvar («последний уходящий гасит свет»), сигнал вне лока превращается в use-after-free.

Таймауты. pthread_cond_timedwait принимает абсолютное время, а не длительность, и по умолчанию работает по CLOCK_REALTIME — перевод системных часов назад заставит ждать лишнее. Практически всегда нужно:

pthread_condattr_t cattr;
pthread_condattr_init(&cattr);
pthread_condattr_setclock(&cattr, CLOCK_MONOTONIC);   // иммунитет к NTP и переводу часов
pthread_cond_init(&q->not_empty, &cattr);

struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
ts.tv_sec += 5;                                       // это дедлайн, а не «подождать 5 секунд»
while (q->count == 0 && !q->closed)                   // цикл сохраняем: таймаут — тоже пробуждение
    if (pthread_cond_timedwait(&q->not_empty, &q->mtx, &ts) == ETIMEDOUT) break;

Атомарные операции и модель памяти C11

C11 внёс в язык полноценную модель памяти и атомарные типы: <stdatomic.h>, спецификатор _Atomic. Это не «быстрый мьютекс», а другой инструмент — операция над одним объектом, которую нельзя расщепить и порядок которой относительно других операций вы задаёте явно.

#include <stdatomic.h>
static atomic_long counter;                   // то же самое, что _Atomic long counter;

void bump(void)      { atomic_fetch_add(&counter, 1); }                    // seq_cst по умолчанию
void bump_fast(void) { atomic_fetch_add_explicit(&counter, 1, memory_order_relaxed); }
; gcc -O2 -S -masm=intel — обе функции на x86-64 дают одно и то же
bump:       lock add QWORD PTR counter[rip], 1
bump_fast:  lock add QWORD PTR counter[rip], 1

Префикс lock — вся разница с неатомарным add, и вся цена: захват кэш-линии в эксклюзивное состояние и сериализация store buffer, 20–100 наносекунд против одной. На x86 relaxed и seq_cst для read-modify-write компилируются одинаково — TSO уже достаточно сильна. На AArch64 разница видна: relaxed даёт ldxr/stxr, seq_cstldaxr/stlxr плюс барьеры. Ещё один повод не проверять корректность порядков экспериментально на x86.

Порядок Что гарантирует Типичное применение
relaxed только атомарность самой операции счётчики статистики, генерация id
acquire (загрузки) последующие обращения не всплывут выше чтение опубликованного указателя
release (записи) предшествующие обращения не опустятся ниже публикация подготовленных данных
acq_rel и то и другое, для read-modify-write вход и выход из критической секции
seq_cst плюс единый глобальный порядок всех seq_cst-операций по умолчанию; когда сомневаетесь

memory_order_consume в стандарте есть, но ни один компилятор не реализует его по-настоящему (все повышают до acquire) — не используйте. Рабочая лошадка — пара release/acquire, реализующая публикацию: поток готовит объект и делает его видимым одной атомарной записью.

struct config { int timeout; char host[64]; };
static struct config *_Atomic g_cfg;          // атомарен указатель, не структура

void publish(void) {
    struct config *c = malloc(sizeof *c);
    c->timeout = 30;                          // обычные записи — они «до» release
    strcpy(c->host, "db.internal");
    atomic_store_explicit(&g_cfg, c, memory_order_release);
}

void use(void) {
    struct config *c = atomic_load_explicit(&g_cfg, memory_order_acquire);
    if (c) printf("%s:%d\n", c->host, c->timeout);   // видим ЗАПОЛНЕННУЮ структуру
}

Release-запись создаёт отношение happens-before со всеми acquire-чтениями, которые эту запись увидели: компилятор и процессор обязаны не переставлять инициализацию за публикацию. Именно это делает знаменитый double-checked locking корректным на C11 и неработающим на обычных переменных, где читатель может увидеть ненулевой указатель раньше содержимого объекта. Освобождение старой конфигурации — отдельная и трудная задача: нужен подсчёт ссылок, hazard pointers или RCU, потому что наивный free даст use-after-free у читателя, взявшего указатель мгновением раньше.

Аппаратура умеет ограниченный набор операций: обмен, сложение, побитовые, сравнение-с-обменом. Всё остальное строится из CAS-цикла.

// Атомарный максимум: такой инструкции нет — собираем из compare_exchange
void atomic_max(_Atomic long *target, long value) {
    long cur = atomic_load_explicit(target, memory_order_relaxed);
    while (cur < value &&
           !atomic_compare_exchange_weak_explicit(
                target, &cur, value,
                memory_order_release,      // порядок при успехе
                memory_order_relaxed))     // порядок при неудаче
        ;   // cur ОБНОВЛЁН самим CAS — перечитывать его не нужно, это частая ошибка
}

weak-версия вправе давать ложные неудачи (на LL/SC-архитектурах прерывание между ldxr и stxr рвёт резервацию), поэтому её применяют только внутри цикла — зато она дешевле; strong нужна там, где цикла нет. Границы применимости честно такие: атомарные операции решают задачу «один объект, одна операция». Как только надо согласованно изменить два поля или проверить условие и изменить состояние, вы возвращаетесь либо к мьютексу, либо к очень сложным lock-free-структурам с проблемами ABA и безопасного освобождения памяти. Практический совет: начинайте с мьютекса, переходите на атомарные операции по данным профилировщика, а собственные lock-free-структуры пишите только при наличии формальной модели и стресс-тестов (https://courses.digitable.life/post/data-structures/14-persistent-and-concurrent/). Проверить, что тип действительно атомарен без внутреннего лока, помогает atomic_is_lock_free: большие структуры под _Atomic компилятор обслуживает через таблицу мьютексов — синтаксис остаётся, производительность исчезает.

Производительность: где теряется масштабирование

Ложное разделение: две независимые переменные в одной кэш-линии и его устранение паддингом

Ложное разделение — самая обидная из проблем: код корректен, гонок нет, а восемь потоков работают медленнее одного. Диагностируется perf c2c record / perf c2c report, который прямо показывает строки исходника, делящие кэш-линию между ядрами. Лечение — выравнивание: struct { _Alignas(64) _Atomic long processed; } stats[MAX_THREADS]; — по счётчику на линию, 64 байта вместо 8, зато запись снова попадает в свой L1.

Конкуренция за лок. Стоимость мьютекса — не время lock/unlock, а сериализация. Закон Амдала в форме S = 1 / (s + (1 - s) / N), где s — доля последовательного кода: при s = 0,05 восемь потоков дают около 5,9x вместо 8x, при s = 0,2 потолок — 5x при любом числе ядер. Отсюда приоритеты: сначала сократить критическую секцию (готовить данные вне лока, под локом только вставка), потом разбить лок (шардирование: массив из 64 мьютексов и hash(key) % 64), потом убрать разделение вовсе (per-thread аккумуляторы со слиянием в конце). Замена мьютекса на атомарные операции — последний шаг, а не первый; глубокий разбор — https://courses.digitable.life/post/performance/07-concurrency-performance/ и https://courses.digitable.life/post/performance/05-cache-and-locality/.

Спинлок оправдан при трёх одновременных условиях: критическая секция — десятки наносекунд, потоков не больше числа ядер, вытеснение маловероятно. Нарушение любого превращает его в катастрофу: вытесненный владелец лока заставляет остальных жечь свои кванты впустую. Правильная форма — test-and-test-and-set (крутиться на чтении, не выбивая линию из чужих кэшей) с инструкцией pause в теле цикла. В пользовательском пространстве спинлок почти всегда проигрывает pthread_mutex_t, у которого адаптивное короткое кручение уже встроено перед уходом в futex; пишите спинлоки в ядре и в прошивках, не в приложениях. pthread_rwlock_t выгоден при явном перевесе чтений и длинных критических секциях: при коротких учёт читателей съедает выигрыш, а кэш-линия со счётчиком читателей сама становится точкой конкуренции.

Инструментарий: чем ловить то, что не воспроизводится

ThreadSanitizer — главный инструмент. Флаг -fsanitize=thread (несовместим с ASan, нужна отдельная сборка), инструментирует каждый доступ к памяти и ведёт векторные часы для вычисления happens-before. Ловит гонки на реально исполненных путях, даже если «плохое» чередование не произошло: достаточно, чтобы два доступа были не упорядочены. Цена — замедление в 5–15 раз и рост потребления памяти в 5–10 раз.

WARNING: ThreadSanitizer: data race (pid=4711)
  Write of size 8 at 0x55a4f8e0a060 by thread T2:
    #0 bump counter.c:12 (app+0x1189)
  Previous read of size 8 at 0x55a4f8e0a060 by thread T1:
    #0 report counter.c:19 (app+0x11c4)
  Location is global 'counter' of size 8 at 0x55a4f8e0a060

Отчёт даёт оба стека и место объекта — этого достаточно, чтобы понять, какой лок забыли. Ложных срабатываний мало, но они бывают на собственных примитивах синхронизации (ассемблерные вставки, свои атомарные протоколы) и размечаются аннотациями __tsan_acquire/__tsan_release. helgrind и DRD из valgrind пересборки не требуют и дополнительно проверяют дисциплину порядка захвата локов: медленнее и шумнее, но незаменимы, когда пересобрать нельзя. Clang умеет ещё и статическую проверку прямо в C — атрибут __attribute__((guarded_by(mtx))) на поле структуры плюс -Wthread-safety укажут на доступ без лока на этапе компиляции.

Регламент, который окупается: TSan-сборка на каждый PR; helgrind — в ночном прогоне; стресс-тесты с числом потоков вдвое-втрое больше числа ядер (вытеснение в неудобных местах — ваш друг); прогон на ARM, если прод хотя бы теоретически может там оказаться. Организационная сторона — https://courses.digitable.life/post/testing/13-tests-in-ci/.

Потоки и всё остальное

Сигналы. Сигнал доставляется процессу, а конкретный поток выбирается ядром среди тех, у кого он не заблокирован. Промышленный шаблон: в самом начале main, до создания потоков, заблокировать интересующие сигналы через pthread_sigmask (маска наследуется всеми потоками) и завести один выделенный поток, который в цикле вызывает sigwait. Обработчик исчезает, а вместе с ним и требование async-signal-safety: в потоке-обработчике можно брать локи и писать в лог. Механика сигналов — https://courses.digitable.life/post/systems-programming/08-processes-and-signals/.

fork в многопоточной программе. В ребёнке существует только вызвавший поток, а мьютекс, который в момент fork держал другой поток, остаётся захваченным навсегда — и первый же malloc может зависнуть, потому что аллокатор внутри берёт лок. POSIX разрешает в ребёнке между fork и exec только async-signal-safe функции. Вывод: после fork делайте exec и ничего больше, а если нужен пул процессов — форкайте до создания потоков. pthread_atfork существует, но надёжно закрыть все чужие локи им невозможно.

Отмена потоков. pthread_cancel выглядит удобно и почти всегда является ошибкой: точки отмены разбросаны по библиотечным вызовам, а обработчики очистки pthread_cleanup_push нужно расставить безупречно, иначе отмена оставляет захваченные локи и утёкшую память. Управляемое завершение — флаг _Atomic bool stop плюс закрытие очереди, как в примере выше.

Приватность вместо синхронизации. _Thread_local int x; (C11) или __thread (GCC) даёт переменную со своей копией в каждом потоке — реализуется через сегментный регистр %fs и стоит одну лишнюю инструкцию; для динамических данных с деструктором есть pthread_key_create с колбэком очистки. Это самый дешёвый способ убрать разделение вообще: буфер форматирования, арена аллокатора, счётчик статистики. А одноразовую инициализацию делает pthread_once(&once, init_library) с pthread_once_t once = PTHREAD_ONCE_INIT (в C11 — call_once): готовое и корректное решение double-checked locking, не изобретайте своё.

Типичные ошибки

  • Захватить лок и выйти по return/goto из середины. Один выход из функции, макрос-обёртка либо __attribute__((cleanup)) для автоматического unlock.
  • Проверять предикат через if вместо while и менять предикат вне мьютекса. Первое — ложные пробуждения, второе — потерянное пробуждение и вечное ожидание.
  • signal там, где нужен broadcast. Проявляется на закрытии очереди и при неоднородных ожидающих.
  • Полагаться на volatile. Он не про многопоточность вообще.
  • Возвращать адрес локальной переменной потока через pthread_exit. Стек уничтожается — классический use-after-free.
  • Проверять корректность порядков памяти запуском на x86. TSO простит почти всё; ARM не простит.
  • Отлаживать гонки через printf. stdio берёт свой лок и меняет тайминги настолько, что баг исчезает.
  • Забывать pthread_join или pthread_detach и создавать поток на единицу работы. Утечка стека и TCB; создание стоит десятки микросекунд, поэтому на коротких задачах пул с очередью выигрывает на порядки.
  • Держать лок во время ввода-вывода. Блокирующий read под мьютексом останавливает всех.

Итог

  • Поток — это свой стек и свои регистры в общем адресном пространстве. Отсюда и дешевизна, и полное отсутствие изоляции.
  • Гонка данных — неопределённое поведение, а не «иногда неверное число»: компилятор вправе выносить чтения из циклов и превращать ожидание в jmp .L3.
  • Мьютекс даёт не только взаимное исключение, но и упорядочивание памяти: release при unlock, acquire при lock. Незанятый мьютекс почти бесплатен благодаря futex; вся цена — в конкуренции.
  • Взаимоблокировки лечатся глобальным порядком захвата и запретом вызывать чужой код под локом.
  • Условная переменная требует трёх вещей: предикат меняется под мьютексом, проверяется в while, broadcast — когда меняется само условие.
  • Атомарные операции — это «один объект, одна операция» с явным порядком; пара release/acquire реализует публикацию. Начинайте с мьютекса, переходите по данным профилировщика.
  • Масштабирование убивают конкуренция за лок и ложное разделение: сначала сократить критическую секцию, потом шардировать, потом не разделять вовсе.
  • Корректность многопоточного кода не проверяется тестированием. Она обеспечивается дисциплиной и подтверждается ThreadSanitizer.

Источники

Смежное на портале: конкурентность с точки зрения теории — https://courses.digitable.life/post/computer-science/14-concurrency-and-parallelism/; планировщик и вытеснение — https://courses.digitable.life/post/operating-systems/03-processes-and-scheduling/; межпроцессное взаимодействие — https://courses.digitable.life/post/operating-systems/07-syscalls-and-ipc/; иерархия кэшей, из которой растёт ложное разделение — https://courses.digitable.life/post/computer-science/06-memory-hierarchy/; альтернативные модели конкурентности — https://courses.digitable.life/post/paradigms/05-concurrent-actors-csp/ и https://courses.digitable.life/post/golang/04-concurrency/. Языки, которые делают гонку данных ошибкой компиляции, а не UB, разбираются в https://courses.digitable.life/post/systems-programming/13-modern-alternatives/.

Что дальше

Мы дошли до уровня, где имеет значение, во что именно превращается counter++ и почему префикс lock меняет всё. Дальше — последний слой абстракции: регистры, стек и соглашения о вызовах. Без них нельзя прочитать вывод objdump, разобрать испорченный кадр стека в core dump или понять, куда пропал ваш аргумент при переходе через границу ABI.

Ассемблер для программиста: регистры, стек, соглашения о вызовах

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов