Перейти к основному содержимому

Многопоточность на С

Разработчику Архитектору

Многопоточность на С

Один процесс — одна запущенная программа. Внутри него можно создать несколько потоков (threads) — у каждого свой стек и счётчик команд, но общие глобальные переменные, куча и код.

Аналогия — один офис (процесс), несколько сотрудников (потоки) с личными блокнотами (стек), но общая доска объявлений (глобальные данные). Если двое одновременно пишут на доске без правил — получится каша (гонка данных, data race).

Память кучи и глобальных данных общая; у каждого потока свой стек. Параллелизм использует несколько ядер CPU, но требует синхронизации (мьютексы, атомики).

Обзор стандартов C11 — в Стандартах языка С; здесь — практика на POSIX и сравнение с <threads.h>.


Когда потоки уместны

  • обработка сетевых соединений, пока главный цикл отвечает на UI или сигналы;
  • фоновое сохранение на диск;
  • параллельная обработка независимых блоков данных (когда накладные расходы на потоки окупаются);
  • перекрытие ожидания I/O с вычислениями.

Процесс (fork в Unix) получает копию памяти (с оптимизациями ОС) — изоляция сильнее, создание тяжелее. Поток дешевле, зато любая ошибка синхронизации бьёт по всему процессу.


Словарь

ТерминСмысл
Потокнезависимая цепочка выполнения внутри процесса
Мьютексзамок: только один поток в критической секции
Критическая секциякод, трогающий общие данные
Гонкадва потока пишут в одну переменную без синхронизации
Deadlockпотоки вечно ждут друг друга
joinдождаться завершения потока

POSIX threads (pthreads)

На Linux, macOS и большинстве Unix потоки создают через pthread:

#include <pthread.h>

void *worker(void *arg)
{
int id = *(int *)arg;
/* ... */
return NULL;
}

int id = 2;
pthread_t tid;
pthread_create(&tid, NULL, worker, &id);
pthread_join(tid, NULL);

Разбор:

  • pthread_create(&tid, NULL, worker, &id) создаёт новый поток и передаёт ему адрес аргумента id.
  • Функция-воркер имеет сигнатуру void *worker(void *arg), потому что pthread API унифицирован по типам аргумента и результата.
  • pthread_join(tid, NULL) синхронизирует завершение: главный поток ждёт конца работы tid и забирает его ресурсы.
  • Без join или detach поток может оставить системные ресурсы в состоянии утечки.

pthread_join ждёт завершения потока. Аргумент arg должен оставаться валидным до старта потока (не передавать адрес локальной переменной main, если main уже вышел из области — лучше malloc или структура с длительным временем жизни).

Компиляция: флаг -pthread (GCC/Clang) подключает библиотеку и макросы.

Минимальная программа с двумя потоками:

Код ITЗагрузка примера кода…

Разбор:

  • Два потока увеличивают одну переменную shared, поэтому без мьютекса возникла бы гонка данных.
  • pthread_mutex_lock/unlock делает инкремент атомарным на уровне логики программы.
  • pthread_join дожидается завершения каждого потока до печати итогового значения.
  • Сборка: gcc -pthread main.c -o main.

Стандарт C11: <threads.h>

Стандарт предлагает переносимый API — thrd_create, thrd_join, mtx_t, cnd_t. Поддержка на всех платформах неполная (исторически Windows опиралась на свои потоки). На современных GCC/Clang в Linux <threads.h> часто доступен.

Выбор для нового кроссплатформенного кода:

APIГде
pthreadsсерверы и Unix, зрелая экосистема
C11 threadsминимальная переносимость там, где есть
обёртка проектаодин слой над платформой

Мьютекс

Мьютекс (взаимное исключение) защищает критическую секцию — код, который трогает общую структуру:

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
int shared_counter = 0;

void increment(void)
{
pthread_mutex_lock(&lock);
shared_counter++;
pthread_mutex_unlock(&lock);
}

Разбор:

  • PTHREAD_MUTEX_INITIALIZER позволяет создать мьютекс в статической области без отдельного вызова init-функции.
  • pthread_mutex_lock открывает критическую секцию и гарантирует эксклюзивный доступ к shared_counter.
  • Инкремент shared_counter++ внутри замка становится атомарным на уровне логики программы.
  • pthread_mutex_unlock должен выполняться на каждом пути выхода из секции, иначе другие потоки зависнут в ожидании.

Правила:

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

В C11: mtx_lock, mtx_unlock из <threads.h>.


Условные переменные

Когда поток должен ждать события (очередь не пуста, буфер готов), используют условную переменную вместе с мьютексом:

/* псевдокод паттерна */
pthread_mutex_lock(&q->lock);
while (q->empty)
pthread_cond_wait(&q->not_empty, &q->lock);
item = dequeue(q);
pthread_mutex_unlock(&q->lock);

Разбор:

  • Паттерн "lock + while + cond_wait" защищает очередь от гонок и ложных пробуждений.
  • pthread_cond_wait атомарно отпускает q->lock и усыпляет поток, чтобы производитель мог положить данные.
  • После пробуждения поток снова захватывает тот же мьютекс и повторно проверяет условие q->empty.
  • while, а не if, нужен для корректности при спонтанных пробуждениях и конкуренции нескольких потребителей.

wait атомарно отпускает мьютекс и засыпает; при пробуждении снова захватывает мьютекс. Всегда ждать в цикле while, а не if — из-за ложных пробуждений.


Атомарные операции C11

Для счётчиков и флагов без полного мьютекса — <stdatomic.h>:

#include <stdatomic.h>

atomic_int requests = 0;

void on_request(void)
{
atomic_fetch_add(&requests, 1);
}

Разбор:

  • atomic_int requests объявляет счётчик, к которому безопасно обращаются несколько потоков без внешнего мьютекса.
  • atomic_fetch_add(&requests, 1) выполняет инкремент как атомарную операцию и возвращает предыдущее значение.
  • Такой подход снижает накладные расходы на блокировки для простых счётчиков и статистики.
  • Для сложных инвариантов между несколькими полями всё ещё нужны мьютексы или более строгие lock-free схемы.

Модели памяти (memory_order_acquire, release, seq_cst) задают, какие перестановки инструкций допустимы между потоками. Для учебного кода часто достаточно последовательной согласованности по умолчанию; для lock-free структур нужна отдельная проработка.


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

ОшибкаПоследствие
гонка на shared++ без атомика/мьютекса"случайные" значения
data race на указателеUB по стандарту С
возврат адреса стека из потокависячий указатель
sqlite3 / FILE * из разных потоков без правил APIповреждение данных
забытый joinутечка ресурсов потока

Инструменты: ThreadSanitizer (-fsanitize=thread), отладочные сборки с проверкой mutex.


Потоки и идиомы ошибок

Глобальные переменные с состоянием усложняют тесты. Предпочтительнее передавать контекст в void *arg worker-функции или использовать очередь задач с одним потоком-потребителем — проще, чем широкие блокировки.

Для встраиваемой БД: одно соединение на поток или явная сериализация доступа.


Когда не усложнять

Один поток + неблокирующий I/O (select, poll, epoll) часто достаточен для утилит и сетевых демонов средней нагрузки. Потоки оправданы, когда блокирующие вызовы неизбежны или есть готовый пул воркеров.

См. также: Системное программирование, Справочник.