Многопоточность на C: практические рекомендации — безопасно по правилам Win32 API

· Обновлено: · · Windows, Многопоточность, C, Win32 API, Бизнес-приложения, Разбор сбоев, Проектирование

История изменений (1 обновлений, последнее 31 Aug 2026)

Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.

Русский текст переписан по текущему навыку технического перевода как полный перевод японского оригинала.
Первая публикация
Цитирование статьи(DOI (зарегистрированный архив): 10.5281/zenodo.22175905)

Приведённые ниже DOI относятся к ранее зарегистрированным архивным версиям, которые могут отличаться от текущего текста. Для ссылки на текущий текст используйте URL этой страницы.

Го Комура (2026). Многопоточность на C: практические рекомендации — безопасно по правилам Win32 API. KomuraSoft LLC. https://comcomponent.com/ru/blog/multithreading-best-practices-c/

DOI (зарегистрированный архив)
10.5281/zenodo.22175905
DOI (последняя зарегистрированная версия)
10.5281/zenodo.22175906

«Пишу постоянно работающий процесс управления оборудованием на C.» «Пришлось добавить потоки в двадцатилетнее приложение на C.» «Останавливаем через TerminateThread, но иногда зависает весь процесс.» — Многопоточность на C — та область, где язык помогает меньше всего. Нет ни исключений, ни RAII, ни шаблонов: корректность синхронизации целиком зависит от того, какие API вы выбираете и насколько дисциплинированно их вызываете.

Эта статья — часть про язык C из практической серии о многопоточности. Она для разработчиков, которые пишут на C с Win32 API, и переносит принципы многопоточного проектирования — не плодить потоки, сокращать общее изменяемое состояние, держать дисциплину блокировок, спроектировать остановку раньше запуска — на инструменты Win32: как создавать потоки (_beginthreadex), как выбирать объекты синхронизации, как строить остановку без TerminateThread и какие ограничения есть у DllMain. Опираемся на первоисточники по состоянию на август 2026. Текст написан так, чтобы его можно было читать отдельно. Те же принципы для других языков разобраны в части про .NET, части про C++ и части про Java.

1. Сначала выводы

  • Потоки создавайте через _beginthreadex, а не через CreateThread. Если поток, вызывающий CRT, создан через CreateThread, CRT может завершить процесс при нехватке памяти.12
  • Внутри процесса блокировка по умолчанию — SRW; CRITICAL_SECTION — только когда нужно рекурсивное взятие. Mutex для взаимного исключения внутри процесса — «частая ошибка»: каждое взятие даёт переход в режим ядра.3
  • Одиночную переменную обновляйте семейством Interlocked. volatile не даёт ни атомарности, ни порядка. Большинство функций Interlocked несёт полный барьер памяти.4
  • Ждите через условную переменную (семейство SleepConditionVariableCS) или через событие плюс функцию ожидания. Цикл опроса на Sleep тратит и процессор, и отзывчивость.5
  • TerminateThread не используйте. Это опасная функция: она портит блокировки, кучу и состояние DLL, и анализ кода помечает её предупреждением C6258. Остановку проектируйте как кооперативную: событие остановки плюс WaitForMultipleObjects.67
  • Короткие задания не распараллеливайте своими потоками — отдавайте пулу потоков Windows (CreateThreadpoolWork). Потоки пула нельзя завершать через ExitThread / TerminateThread.89
  • В DllMain не создавайте потоки, не синхронизируйтесь и не ждите их завершения. Её вызывают, пока удерживается блокировка загрузчика, — типичный источник взаимных блокировок.10
  • <threads.h> из C11 доступен начиная с VS 2022 17.8, но <stdatomic.h> всё ещё экспериментален. Для кодовой базы только под Windows реалистичен подход Win32 API.11

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 23, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

2. Почему многопоточность даётся трудно — состояние гонки и взаимная блокировка

Проблемы, которые приносит многопоточность, независимо от языка сводятся к двум видам.

Состояние гонки (race condition) — ошибка, при которой результат меняется в зависимости от того, в каком порядке несколько потоков доходят до участка кода. Классический пример — общий счётчик: одно выражение count++ на уровне машинного кода распадается на три шага: прочитать, прибавить, записать обратно. Если два потока входят в эти три шага одновременно, сложение одного затирается записью другого и пропадает.4 Результат меняется от запуска к запуску, и какой именно вы получите — непредсказуемо.

Поток BОбщая переменная countПоток AПоток BОбщая переменная countПоток Acount = 10Два сложения, а count = 11Сложение потока A потеряноЧтение (10)Чтение (10)Сложение у себя (11)Сложение у себя (11)Запись обратно (11)Запись обратно (11)

Рис. 1: Классическая гонка, в которой общий счётчик теряет сложение. Если другой поток вклинивается между тремя шагами count++, более поздняя запись затирает чужой результат

Взаимная блокировка (deadlock) — состояние, в котором два потока ждут блокировку, которую держит другой, и ни один не может продолжить. Поток A держит блокировку 1 и ждёт блокировку 2; поток B держит блокировку 2 и ждёт блокировку 1 — этого достаточно, чтобы оба остановились навсегда.

ждёт освобождения блокировки 2ждёт освобождения блокировки 1Поток Aдержит блокировку 1Поток Bдержит блокировку 2

Рис. 2: Циклическое ожидание взаимной блокировки. Как только стрелки ожидания замыкаются в кольцо, все потоки в этом кольце останавливаются навсегда

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

2.1. Что специфично для C — язык ни от чего не страхует

В C у языка нет механизма, который заставил бы соблюдать эти принципы, поэтому их нужно явно зафиксировать как дисциплину.

Во-первых, сделайте освобождение гарантированным за счёт структуры кода. Эквивалента RAII из C++ нет, поэтому снятие блокировок и CloseHandle для дескрипторов защищают шаблоном goto cleanup, который сводит все выходы из функции в одно место, или соглашением писать взятие и освобождение парой. Добавить ранний return и забыть снять блокировку — классическая авария на C.

Во-вторых, к гонкам данных относитесь так же, как в C++. Простое чтение или запись правильно выровненной 32-битной переменной в Windows атомарны, но ничего сверх того — 64-битные переменные в 32-битной Windows, составные операции, согласованность нескольких переменных — не гарантировано.12 Код, который «случайно работает», ломается, стоит сменить компилятор или уровень оптимизации.

В-третьих, явно решите, кому принадлежит ресурс. Культура писать в комментарии к функции, какой поток пишет этот буфер и с какого момента он чей, в многопоточности на C работает не хуже, чем выбор примитива синхронизации.

3. Как создавать потоки — только _beginthreadex

3.1. Почему CreateThread — неверный выбор

Нативный API Win32 — CreateThread, но официальное указание такое: поток, который вызывает функции CRT (библиотеки времени выполнения C), нужно создавать через _beginthreadex. _beginthreadex инициализирует внутренние данные CRT на поток и только потом его запускает. Если поток, созданный через CreateThread, вызывает функцию CRT, CRT может завершить процесс при нехватке памяти.12 printf, malloc и strtok — всё это CRT, поэтому на практике правило простое: «потоки на C всегда создают через _beginthreadex».

_beginthread (без ex) тоже не берите. Ловушка: если созданный поток рано завершится, возвращённый дескриптор уже может быть недействительным и даже указывать на другой поток. _beginthreadex безопаснее: его дескриптор можно передать API синхронизации. Дескриптор, который вернул _beginthreadex, вызывающая сторона закрывает через CloseHandle.13

#include <process.h>

static unsigned __stdcall WorkerMain(void* arg)
{
    WorkerContext* ctx = (WorkerContext*)arg;
    /* ... цикл ожидания из глав 5 и 6 ... */
    return 0;
}

HANDLE hThread = (HANDLE)_beginthreadex(
    NULL, 0, WorkerMain, &ctx, 0, NULL);
if (hThread == NULL) { /* обработка ошибки */ }
/* ...после запроса остановки... */
WaitForSingleObject(hThread, INFINITE);  /* join */
CloseHandle(hThread);

3.2. Короткие задания — в пул потоков Windows

Если хотите «бросить много мелких заданий» или ловите себя на том, что «снова и снова создаёте и уничтожаете короткоживущие потоки», используйте пул потоков Windows (API пула начиная с Vista), а не свои потоки. Создайте объект работы через CreateThreadpoolWork и отправьте его через SubmitThreadpoolWork — рабочие потоки пула выполнят обратный вызов параллельно.8 Числом потоков управляет ОС, стоимость создания и уничтожения потоков исчезает. Это ответ C на принцип «не создавайте потоки сами».

Дисциплина работы с пулом тоже зафиксирована официально: не завершайте поток пула через TerminateThread / ExitThread; состояние, которое меняли внутри обратного вызова (TLS, приоритет потока и тому подобное), восстановите до возврата; дескрипторы ожидания держите живыми, пока пул ими не закончит.9 Ещё одно практическое замечание: пул ограничивает только число рабочих потоков. Невыполненные обратные вызовы, поставленные через SubmitThreadpoolWork, могут копиться без границы. В постоянно работающей конфигурации, где отправки всё время обгоняют обработку, на стороне приложения поставьте ограничение на вход вроде семафора или очередь с ограниченной ёмкостью, чтобы при переполнении отправитель ждал или получал отказ (это противодавление, чтобы перегрузка не превращалась в расход памяти; тот же принцип, что и проектирование очереди в главе 5).

4. Сведите общее изменяемое состояние к минимуму — разнести, сделать только для чтения, передать

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

Разнесите по потокам. При параллельном подсчёте не пишите из каждого потока в общий счётчик: копите промежуточную сумму в локальной переменной потока (или в буфере, выделенном на поток) и один раз в конце сведите её через InterlockedAdd или аналог. Записи в общее состояние падают с «каждой итерации» до «одного раза на поток», и стоимость синхронизации, и окно гонки сжимаются на порядки. Явная фиксация владения из раздела 2.1 — «какому потоку принадлежит этот буфер» — становится чертежом того, как вы разносите данные.

Сделайте только для чтения. Конфигурацию и таблицы, собранные при старте и больше не меняемые, после завершения инициализации можно читать из любого числа потоков. Либо закончите всю инициализацию до запуска потоков, либо, если нужна ленивая инициализация, используйте однократную инициализацию Win32 (InitOnceExecuteOnce) и сделайте в коде явной границу: «с какого момента это только для чтения».3

Передавайте. Поток данных между потоками не стоит строить как общую переменную, которую трогают с обеих сторон: сведите его к очереди производитель/потребитель. На C реализация — как раз шаблон условной переменной из главы 5 (ограниченный кольцевой буфер плюс SleepConditionVariableCS), это официальный разобранный пример. Буфер с пределом ёмкости даёт и естественное противодавление: производство ждёт, как только обгоняет потребление.5

5. Как выбирать объекты синхронизации и дисциплина блокировок

У Win32 много видов примитивов синхронизации, и неверный выбор бьёт и по производительности, и по корректности. Официальное указание — на одной схеме.3

ДаВзаимное исключениеОграничение числа одновременных доступовУведомление о событииНет (внутри процесса)ДаНетДаНетНужна синхронизациямежду процессами?Для чего?Именованный MutexИменованный семафорИменованное событиеНужно рекурсивное взятиетем же потоком?CRITICAL_SECTIONНужен переносимыйкод на C++?std::mutex /std::shared_mutexSRW-блокировка (выбор по умолчанию)

Рис. 3: Как выбрать примитив синхронизации Win32. Первая развилка — «пересекает ли процессы»; суть в том, чтобы не брать объект ядра (Mutex), когда не пересекает

Примитив Область Особенности Когда применять
SRW-блокировка Внутри процесса Быстрая (обычно целиком в режиме пользователя), размером с указатель, нерекурсивная По умолчанию для нового кода. AcquireSRWLockShared также даёт общий доступ на чтение
CRITICAL_SECTION Внутри процесса Быстрая (крутится, затем уходит в ожидание ядра), рекурсивная Когда тому же потоку нужно рекурсивное взятие
Mutex Внутри процесса / между процессами Всегда объект ядра, поэтому медленнее Взаимное исключение между процессами (именованный) или вместе с WaitForMultipleObjects
Семафор Внутри процесса / между процессами Объект ядра Ограничение одновременного доступа к пулу ресурсов
Событие Внутри процесса / между процессами Объект ядра Сообщить, что «что-то случилось» (не для защиты данных)
Функции Interlocked Внутри процесса (и между процессами, если через разделяемую память) Атомарные операции без блокировки Счётчики, флаги, замена указателя4

К таблице и схеме — одно уточнение. Объекты ядра вроде событий, семафоров и Mutex нормально работают для синхронизации внутри процесса, если созданы без имени (событие остановки в главе 6 — как раз безымянное событие). Объект ядра не значит «только между процессами». И наоборот, «без имени» строго не значит «заперт в одном процессе»: если дать дочернему процессу унаследовать дескриптор или продублировать его в другой процесс через DuplicateHandle, тот же объект ядра можно использовать из нескольких процессов даже без имени. Точнее сказать, что имя — один из основных способов дать процессам открыть тот же объект заново. Развилка на рис. 3 фиксирует суть выбора: «не берите объект ядра для внутрипроцессной блокировки». Для уведомления внутри процесса (событие) или ограничения одновременности (семафор) безымянный объект ядра по-прежнему верный ответ.

Семейство Interlocked соответствует классу Interlocked в части про .NET и std::atomic в части про C++. InterlockedIncrement / InterlockedExchange / InterlockedCompareExchange выполняют операции над одной переменной неделимо, и поскольку большинство этих функций несёт полный барьер памяти, вы также получаете гарантию порядка.4 «Пометил volatile — значит, всё в порядке» — заблуждение: нет ни атомарности, ни порядка (см. FAQ). Есть ещё одно условие: выравнивание. Переменная, на которую нацелена функция Interlocked, должна стоять на естественной границе (4 байта для 32-битного значения, 8 байт для 64-битного); иначе поведение непредсказуемо.12 Не применяйте Interlocked к полю внутри структуры с #pragma pack и к полю буфера, напрямую наложенного на сетевой формат. Счётчики и флаги ограничивайте обычными объявленными переменными — теми, которые выравнивает компилятор. Отдельная оговорка про замену указателя через InterlockedExchangePointer и аналоги: неделима только сама замена, и время жизни старого блока после замены никто не охраняет. Если читатель загрузил старый указатель, а писатель в этот момент подменил его и вызвал free, это доступ к уже освобождённой памяти. Схема, которая обновляет общие данные заменой указателя, работает только вместе с протоколом освобождения — блокировкой, подсчётом ссылок или подобным (если сомневаетесь, спокойнее защитить SRW-блокировкой).

Для ожидания есть условные переменные. Создайте одну через InitializeConditionVariable; потребитель спит на SleepConditionVariableCS (в паре с CRITICAL_SECTION), производитель будит его через WakeConditionVariable — это в точности форма официального примера очереди производитель/потребитель на ограниченном буфере.5 Важная дисциплина: проснувшись, всегда заново проверяйте условие (непуста ли очередь) внутри блокировки и, если оно ложно, возвращайтесь к ожиданию. У условных переменных бывают ложные пробуждения без уведомления, и к моменту пробуждения другой потребитель уже мог забрать элемент — поэтому «меня разбудили» не значит «условие выполняется». Это инструмент C, чтобы собрать ту же форму, что канал в разделе 4.3 части про .NET и BlockingQueue в главе 4 части про C++. В паре с SRW-блокировкой используйте SleepConditionVariableSRW.

5.1. Дисциплина блокировок — три принципа, какой бы примитив вы ни выбрали

Правильного примитива самого по себе мало: без дисциплины использования гонки всё равно не предотвратите.

  • Решите взаимно однозначно, какая блокировка защищает какие данные. Назначьте ровно одну блокировку (SRW или CRITICAL_SECTION) каждому набору изменяемых данных, который хотите защитить, и берите ту же блокировку во всех местах, которые трогают эти данные. В C особенно помогает явно написать это в комментарии заголовка: «эту структуру защищает g_lockFoo».
  • Не делайте ничего долгого и ничего внешнего, пока держите блокировку. Единственное, что допустимо под блокировкой, — читать или писать защищаемые ею данные. Файловый ввод-вывод, сеть или вызов обратных функций, пока блокировка ещё удерживается, и удлиняют удержание, и рискуют тем, что вызываемый попытается взять другую блокировку и получится циклическое ожидание с рис. 2.
  • Зафиксируйте порядок взятия нескольких блокировок. Везде, где берутся две и больше блокировок, сделайте правилом, что каждый поток берёт их в одном порядке (иерархия блокировок). Документация лучших практик DLL прямо говорит: обращение этого порядка (lock order inversion) даёт взаимные блокировки, которые трудно отлаживать, и иерархию нужно определить и последовательно ей следовать.10

6. Как проектировать остановку — без TerminateThread

6.1. Что ломает TerminateThread

TerminateThread уничтожает целевой поток, не дав ему выполнить ни строчки кода в режиме пользователя. Последствия, которые перечисляет официальная документация, тяжёлые. Если целевой поток держал критическую секцию, она никогда не освободится; если он был посреди операции с кучей, блокировка кучи останется занятой (и каждый последующий поток, вызывающий malloc, зависнет); если он менял глобальное состояние DLL, это состояние будет разрушено. Официальная позиция — «опасная функция, которую следует использовать только в самых крайних случаях», и анализ кода помечает её предупреждением C6258.67

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

6.2. Правильная форма: событие остановки плюс WaitForMultipleObjects

Устоявшийся приём кооперативной остановки на C — создать одно событие остановки с ручным сбросом и заставить каждый рабочий поток ждать «сигнал работы» и «сигнал остановки» одновременно. Документация предупреждения C6258 сама указывает именно на эту форму — создать событие, пусть каждый поток следит за ним через WaitForSingleObject и сам завершится — как правильный способ завершения.7

HANDLE hStopEvent;   /* CreateEvent(NULL, TRUE, FALSE, NULL): ручной сброс */
HANDLE hWorkEvent;   /* CreateEvent(NULL, FALSE, FALSE, NULL): автосброс.
                        При получении автоматически возвращается в несигнальное
                        (если сделать ручной сброс, после одного сигнала
                        ожидание начнёт проходить насквозь, и получится
                        занятый цикл по пустой очереди) */

static unsigned __stdcall WorkerMain(void* arg)
{
    HANDLE waits[2] = { hStopEvent, hWorkEvent };
    for (;;) {
        DWORD r = WaitForMultipleObjects(2, waits, FALSE, INFINITE);
        if (r == WAIT_FAILED) {          /* недействительный дескриптор и т.п. Без обработки — холостой цикл на полной скорости */
            LogLastError();              /* Запишите GetLastError() и выходите */
            break;
        }
        if (r == WAIT_OBJECT_0)          /* Запрошена остановка */
            break;
        if (r == WAIT_OBJECT_0 + 1) {    /* Есть работа */
            /* Передайте событие остановки и в ProcessNextItem: если он долго ждёт
               внутри одного элемента и там тоже не видит остановку,
               завершение упрётся в этот один элемент */
            while (ProcessNextItem(hStopEvent)) {  /* Обработать один элемент из очереди; FALSE если пусто */
                /* Проверяйте запрос остановки и во время опустошения. Пропустите —
                   и не остановитесь, пока работа копится (остановка «голодает») */
                if (WaitForSingleObject(hStopEvent, 0) == WAIT_OBJECT_0)
                    break;
            }
        }
    }
    Cleanup();                           /* Зачистку делает сам поток */
    return 0;                            /* И завершается сам */
}

BOOL StopWorkers(HANDLE* threads, DWORD count)
{
    BOOL ok = TRUE;
    if (!SetEvent(hStopEvent)) {             /* Если запрос остановки не дошёл, */
        LogLastError();                      /* не входите в бесконечный join */
        return FALSE;
    }
    /* Теперь запрос остановки виден всем сразу */
    for (DWORD i = 0; i < count; i++) {
        if (WaitForSingleObject(threads[i], INFINITE) == WAIT_OBJECT_0) {
            CloseHandle(threads[i]);         /* Закрывайте только те, чей join подтверждён */
        } else {
            LogLastError();                  /* WAIT_FAILED: недействительный дескриптор и т.п. */
            ok = FALSE;                      /* Не сообщайте, что «все остановились» */
        }
    }
    return ok;   /* Если FALSE, не переходите к освобождению общих ресурсов */
}

Есть причина, по которой останавливающая сторона делает join по одному потоку через WaitForSingleObject. WaitForMultipleObjects может ждать сразу не больше MAXIMUM_WAIT_OBJECTS (64) дескрипторов. Передайте больший массив — и само ожидание упадёт с WAIT_FAILED, а вы закроете дескрипторы в уверенности, что дождались всех, хотя на деле не дождались никого. Если нужно просто дождаться завершения всех, цикл по одному без верхней границы — безопасный выбор.

SetEvent(hStopEvent)Останавливающая сторонаСобытие остановки(ручной сброс: видно всем)Рабочий 1:через WaitForMultipleObjectsждёт остановку и работу одновременноРабочий 2:через WaitForMultipleObjectsждёт остановку и работу одновременноДелает зачистку и сам делает returnДелает зачистку и сам делает returnОстанавливающая сторона ждёт дескрипторы потоков и выполняет joinтолько после этого можно сказать «остановлено»

Рис. 4: Шаблон события остановки. Событие с ручным сбросом для сигнала остановки значит, что один SetEvent будит всех ждущих рабочих сразу. Как завершиться, решает каждый поток сам; остановленным он считается только после завершения join

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

Две оговорки про область применения. Во-первых, эта форма «событие плюс опустошить всё» рассчитана на конфигурацию с одним рабочим. Сколько ни вызывайте SetEvent на событии с автосбросом, оно выражает только «есть одно сигнальное состояние» (подряд идущие сигналы сливаются), поэтому при нескольких рабочих просыпается один и разбирает весь всплеск последовательно. Если несколько рабочих делят очередь, сигнал работы смените на семафор и увеличивайте счётчик через ReleaseSemaphore(hSem, 1, NULL) каждый раз, когда элемент ставится в очередь. Успешное ожидание семафора потребляет одну единицу, и соответствие получается верное: просыпается ровно столько ждущих рабочих, по одному, сколько элементов поставлено (это использование целиком в компетенции семафора, как и «ограничение одновременного доступа к пулу ресурсов» в таблице к рис. 3). Но переходя на семафор, смените и сторону потребителя: одно успешное ожидание = обработка ровно одного элемента из очереди. Оставьте цикл опустошения из образца выше как есть — и одно ожидание, потребившее только одно разрешение, опустошит всю очередь, и учёт разъедется: другие рабочие проснутся к пустой очереди на оставшихся разрешениях, а ReleaseSemaphore производителя начнёт падать из-за превышения максимума. Соответствие «одно разрешение = одно задание» — условие семафорного подхода. Во-вторых, проведите путь остановки и через обработку одного элемента. Если ProcessNextItem внутри долго блокирующе ждёт, либо передайте туда же событие остановки и ждите оба вместе, либо повесьте конечный тайм-аут. Проверка только между элементами оставляет дыру, где «завершение ждёт вечно, потому что один элемент не кончается». Это ровно то же, что StopAsync в части про .NET и jthread плюс join в части про C++.

Поток, который ждёт на блокирующем вводе-выводе (канал, сокет, последовательный порт), не может вернуться проверить событие, поэтому стороне ввода-вывода тоже нужен свой проект: либо OVERLAPPED в паре с событием, которое ждёте вместе, либо пробуждение ввода-вывода через CancelIoEx (конкретный пример последовательной связи см. в «Подводные камни приложений последовательной связи»).

7. DllMain и блокировка загрузчика — опасные места при написании DLL

Общие компоненты, написанные на C, часто оказываются DLL, а у DLL есть своё ограничение: блокировка загрузчика. Загрузчик ОС вызывает DllMain, удерживая блокировку загрузчика, поэтому любое из следующего внутри становится источником взаимных блокировок или сбоев.10

  • Синхронизация с другими потоками (взятие блокировок, ожидание завершения потока)
  • Вызов LoadLibrary / FreeLibrary, прямо или косвенно
  • Создание потоков (опасно, если связано с синхронизацией) или вызов ExitThread

«При выгрузке DLL ждать внутри DllMain завершения рабочего потока» выглядит вполне разумно, но это классическая взаимная блокировка: завершающийся поток пытается взять блокировку загрузчика, чтобы доставить DLL_THREAD_DETACH, и стороны ждут друг друга. DLL со своими потоками должна выставить явные функции инициализации и завершения — вроде MyLib_Init / MyLib_Shutdown — и делать запуск и join потоков там. Идеальный DllMain близок к пустой заглушке.10

8. Вариант потоков C11 — где мы сейчас

Если хотите писать переносимый C без зависимости от Win32, вариант — <threads.h> из C11 (thrd_create / mtx_lock / cnd_wait) и <stdatomic.h>. По официальной таблице соответствия поддержка MSVC такова: <threads.h> поддерживается начиная с Visual Studio 2022 17.8 (нужны /std:c11 и соответствующий Windows SDK), а <stdatomic.h> всё ещё экспериментален и требует ключа /experimental:c11atomics.11

Если нужно делить код с Linux, потоки C11 (или обёртка pthread) имеют реальную ценность. Для кодовой базы только под Windows подход Win32 из этой статьи выигрывает объёмом информации, накопленным опытом и удобством отладки. Что бы вы ни выбрали, принципы проектирования выше — сокращать совместное использование, держать соответствие блокировок и данных, останавливаться кооперативно — не меняются.

9. Проверка и отладка — готовьтесь к тому, что «не воспроизводится»

Нельзя рассчитывать, что тесты поймают гонки. Обычные тесты считают успехом запуск, в котором гонка «просто не сработала». Защиту думайте в три слоя.

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

Во-вторых, сделайте аномалии наблюдаемыми. Вместо безусловного ожидания с INFINITE в ключевых местах повесьте тайм-аут и пишите в журнал, когда он срабатывает: зависание, которое иначе длилось бы вечно, превращается в обнаруживаемый сбой. Когда зависание случается в поле, снимите дамп, проверьте стек каждого потока и ищите цикл, кто кого ждёт по блокировке. Для ошибок вокруг DLL официально рекомендуют проверку через Application Verifier.10 Настройка дампов и журналирования разобрана в «Как проектировать логи и дампы при сбое Windows-приложения».

В-третьих, встряхните нагрузкой. Стресс-тесты — гонять больше потоков, чем ядер, рандомизировать порядок обработки, вставлять искусственные задержки — практичный способ повысить шанс поймать гонку на машине разработки. Не забудьте также проверить воспроизведение на оптимизированной release-сборке под высокой нагрузкой.

10. Итоги — контрольный список для C

  1. Каждый поток создан через _beginthreadex (нет смеси с CreateThread / _beginthread)?
  2. Дескрипторы потоков закрываете через CloseHandle только после join (WaitForSingleObject)?
  3. Не плодите ли свои потоки для короткоживущих заданий (нельзя ли отдать их API пула потоков)?
  4. Внутрипроцессное взаимное исключение идёт через SRW / CRITICAL_SECTION (Mutex не используется не по назначению)?
  5. Общие счётчики и флаги обновляются функциями Interlocked, а не в расчёте на volatile?
  6. Не остался ли опрос через Sleep (заменён ли условной переменной или ожиданием события)?
  7. Нет ли нигде TerminateThread (принудительного завершения чужого потока)? Рабочие завершаются через return из функции потока, а не через ExitThread (чтобы зачистка CRT шла правильно через _endthreadex)?
  8. У каждого рабочего есть путь остановки через событие остановки плюс WaitForMultipleObjects, и можете ли вы разбудить потоки, заблокированные на вводе-выводе?
  9. Снятие блокировок и закрытие дескрипторов гарантировано на каждом пути возврата (дисциплина goto cleanup)?
  10. DllMain не создаёт потоки, не синхронизируется и не ждёт их завершения?

Раз язык не страхует, качество многопоточности на C — это ровно выбор API и дисциплина. Сделайте _beginthreadex, SRW-блокировки, функции Interlocked и событие остановки своим набором из четырёх по умолчанию — и даже на C можно проектировать так, чтобы «иногда зависает» случалось реже.

Похожие статьи

Смежные области консультирования

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

Справочные ссылки

  1. Microsoft Learn, CreateThread function. Потоки внутри исполняемого файла, вызывающего CRT, нужно вести через _beginthreadex / _endthreadex, а не CreateThread / ExitThread; если поток, созданный через CreateThread, вызывает CRT, CRT может завершить процесс при нехватке памяти. ↩ ↩2

  2. Microsoft Learn, Multithreading with C and Win32. Программы, вызывающие библиотеку CRT, должны запускать потоки через _beginthread / _beginthreadex, а не через CreateThread / ExitThread Win32; семейство _beginthread инициализирует переменные CRT на поток; SuspendThread может остановить поток, пока он обращается к внутренним структурам CRT, и это может привести к взаимной блокировке. ↩ ↩2

  3. Microsoft Learn, About Synchronization. Указания по выбору примитивов синхронизации Win32: SRW-блокировки по умолчанию для нового кода, размером с указатель и обычно остаются в режиме пользователя; CRITICAL_SECTION — когда нужно рекурсивное взятие; Mutex всегда объект ядра, используется для именованной межпроцессной синхронизации и вместе с WaitForMultipleObjects; Mutex для внутрипроцессной синхронизации — «частая ошибка», на частых операциях заметно медленнее; семафоры ограничивают одновременный доступ к пулу ресурсов, события служат для уведомления. ↩ ↩2 ↩3

  4. Microsoft Learn, Interlocked Variable Access. Функции Interlocked синхронизируют доступ к переменной, общей для нескольких потоков, и выполняют операцию неделимо; InterlockedIncrement / Decrement связывают чтение, сложение и запись обратно в одну атомарную операцию, потому что без синхронизации одновременный инкремент от двух потоков может потерять один из них; семейство InterlockedExchange / InterlockedCompareExchange; применимо между потоками разных процессов, когда переменная в разделяемой памяти; большинство функций Interlocked даёт полный барьер памяти, с вариантами Acquire / Release для выбора семантики порядка. ↩ ↩2 ↩3 ↩4

  5. Microsoft Learn, Using Condition Variables. Разобранный пример очереди производитель/потребитель на ограниченном кольцевом буфере, защищённом CRITICAL_SECTION; структура, где InitializeConditionVariable создаёт условную переменную, потребитель ждёт через SleepConditionVariableCS, а производитель будит через WakeConditionVariable; условные переменные поддерживаются начиная с Windows Vista. ↩ ↩2 ↩3

  6. Microsoft Learn, TerminateThread function. TerminateThread завершает целевой поток, не дав ему выполнить код в режиме пользователя; критическая секция цели не освобождается, если он её держал; блокировка кучи не освобождается, если поток выделял память из кучи; состояние kernel32 или глобальное состояние DLL может быть разрушено; «опасная функция, которую следует использовать только в самых крайних случаях», и вызывать её не следует, пока вы полностью не знаете и не контролируете каждый путь кода, который целевой поток мог бы выполнять. ↩ ↩2

  7. Microsoft Learn, Warning C6258. Предупреждение анализа кода C6258 обнаруживает использование TerminateThread; TerminateThread не может выполнить правильную зачистку потока; правильная процедура завершения: создать событие через CreateEvent, пусть каждый поток следит за состоянием события через WaitForSingleObject и сам завершит выполнение, когда событие станет сигнальным. ↩ ↩2 ↩3

  8. Microsoft Learn, CreateThreadpoolWork function. CreateThreadpoolWork создаёт объект работы, и при каждом вызове SubmitThreadpoolWork рабочий поток пула выполняет обратный вызов; среду выполнения можно задать через среду обратного вызова (TP_CALLBACK_ENVIRON); доступно начиная с Windows Vista. ↩ ↩2

  9. Microsoft Learn, Thread Pools. Пул потоков подходит приложениям, которые выполняют много коротких асинхронных заданий или часто создают короткоживущие потоки; состав нового API пула, переработанного в Vista; лучшие практики: не завершать поток пула через TerminateThread и не вызывать ExitThread из обратного вызова, убирать состояние, созданное в обратном вызове, до возврата, держать дескрипторы ожидания живыми, пока пул ими не закончит. ↩ ↩2

  10. Microsoft Learn, Dynamic-Link Library Best Practices. DllMain вызывают, пока удерживается блокировка загрузчика, что сильно ограничивает, какие API можно безопасно вызывать; синхронизация с другими потоками внутри DllMain ведёт к взаимной блокировке; вызов LoadLibrary входит в список запрещённых действий; ожидание завершения потока внутри DllMain при выгрузке DLL взаимно блокируется с попыткой этого потока взять блокировку загрузчика, чтобы доставить DLL_THREAD_DETACH; идеальный DllMain близок к пустой заглушке, инициализацию следует откладывать насколько возможно; нужно определить иерархию блокировок и поставить блокировку загрузчика наверх. ↩ ↩2 ↩3 ↩4 ↩5

  11. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. Таблица соответствия стандартной библиотеки C: потоки C11 (threads.h) поддерживаются начиная с Visual Studio 2022 17.8; stdatomic.h считается экспериментальным (ключ /experimental:c11atomics); поддержка компилятора C11 / C17 требует Visual Studio 2019 16.8 или новее вместе с соответствующим Windows SDK. ↩ ↩2

  12. Microsoft Learn, Interlocked Variable Access. Простое чтение или запись правильно выровненной 32-битной переменной атомарны, но синхронизация (порядок) доступа не гарантирована; простое чтение или запись 64-битной переменной атомарны в 64-битной Windows, но не гарантированы в 32-битной Windows; переменные других размеров не гарантированы атомарными ни на одной платформе. ↩ ↩2

  13. Microsoft Learn, _beginthread, _beginthreadex. Почему _beginthreadex безопаснее _beginthread: поток, созданный через _beginthread, может оставить возвращённый дескриптор недействительным (и даже указывающим на другой поток), если рано завершится; дескриптор _beginthreadex вызывающая сторона должна закрыть через CloseHandle, и его действительность гарантирована; _beginthreadex позволяет передать дескриптор API синхронизации; функция потока возвращает код завершения по соглашению вызова __stdcall; нужна компоновка с многопоточной CRT. ↩

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Что выбрать: CreateThread или _beginthreadex?
Если поток вызывает функции библиотеки времени выполнения C (CRT), берите _beginthreadex. _beginthreadex сначала инициализирует внутренние данные CRT на поток и только потом его запускает. В официальной документации прямо сказано: если поток, созданный через CreateThread, вызывает функцию CRT, CRT может завершить процесс при нехватке памяти. На практике поток в приложении на C почти наверняка где-то вызывает CRT (printf, malloc, strtok и тому подобное), поэтому правило «всегда _beginthreadex» можно запомнить без оговорок. _beginthread (без ex) тоже не берите: если созданный поток рано завершится, возвращённый дескриптор может стать недействительным, поэтому тоже выбирайте _beginthreadex.
Нельзя останавливать поток через TerminateThread?
Нельзя. TerminateThread уничтожает целевой поток, не дав ему выполнить ни строчки кода в режиме пользователя. Если поток держал критическую секцию, она так и не освободится; если он выделял память из кучи, блокировка кучи останется занятой; если он менял глобальное состояние DLL, это состояние будет разрушено. Официальная документация прямо называет её «опасной функцией, которую следует использовать только в самых крайних случаях», а анализ кода помечает вызов предупреждением C6258. Правильная остановка — кооперативная: создайте событие остановки, пусть каждый поток следит за ним через WaitForSingleObject / WaitForMultipleObjects, сам сделает зачистку и сам завершится.
Я использовал Mutex для взаимного исключения внутри процесса. В чём проблема?
Работать будет, но производительность сильно пострадает. Mutex в Win32 всегда объект ядра, поэтому каждое взятие и освобождение даёт переход в режим ядра. Для взаимного исключения внутри одного процесса гораздо быстрее SRW-блокировка или CRITICAL_SECTION: они остаются в режиме пользователя и уходят в ожидание ядра только при конкуренции. Официальная документация прямо называет Mutex для внутрипроцессной синхронизации «частой ошибкой». Mutex нужен, когда исключение должно пересекать процессы как именованный объект или когда его ждут вместе с другими объектами ядра через WaitForMultipleObjects.
Можно ли в Windows пользоваться threads.h и stdatomic.h из C11?
В MSVC потоки C11 (threads.h) поддерживаются начиная с Visual Studio 2022 17.8 (нужны /std:c11 и соответствующий Windows SDK). stdatomic.h по-прежнему считается экспериментальным и требует ключа /experimental:c11atomics — так сказано в официальной таблице соответствия на август 2026. Это рабочий вариант, если переносимость — главный приоритет. Для кодовой базы только под Windows реалистичнее писать на Win32 API (_beginthreadex, SRW-блокировки, условные переменные, функции Interlocked): и опыта больше, и информации больше.
Достаточно ли пометить общий флаг как volatile, чтобы он стал безопасным?
Нет. volatile в C только подавляет оптимизации компилятора вроде кэширования значения в регистре — он не даёт ни атомарности операции, ни порядка памяти между процессорами. Простое чтение или запись правильно выровненной 32-битной переменной в Windows само по себе атомарно, но «прочитать, прибавить и записать обратно» распадается на отдельные шаги, и порядок относительно соседних операций с памятью тоже не задан. Общий счётчик или флаг обновляйте семейством Interlocked. Большинство функций Interlocked несёт полный барьер памяти, так что гарантию порядка вы получаете сразу. Когда нужно защитить несколько переменных вместе, берите SRW-блокировку или CRITICAL_SECTION.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог