Многопоточность на С
Многопоточность на С
Один процесс — одна запущенная программа. Внутри него можно создать несколько потоков (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) часто достаточен для утилит и сетевых демонов средней нагрузки. Потоки оправданы, когда блокирующие вызовы неизбежны или есть готовый пул воркеров.
См. также: Системное программирование, Справочник.