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

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

«Пишу резидентный процесс управления оборудованием на 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.11

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

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

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

Поток BОбщая переменная countПоток AПоток BОбщая переменная countПоток Acount = 10count = 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 runtime), должен создаваться через _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

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

Семейство Interlocked соответствует классу Interlocked в издании .NET и std::atomic в издании C++. InterlockedIncrement / InterlockedExchange / InterlockedCompareExchange выполняют операции над одной переменной неделимо, и поскольку большинство этих функций несёт полный барьер памяти, вы также получаете гарантию порядка.4 «Ничего, я пометил volatile» — заблуждение: 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Прибирается и сам возвращаетсяПрибирается и сам возвращаетсяОстанавливающая сторона ждёт дескрипторы потоков и делает 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

«Ждать внутри DllMain завершения рабочего потока при выгрузке DLL» выглядит вполне разумно, но это классическая взаимоблокировка: завершающийся поток пытается взять блокировку загрузчика, чтобы доставить 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 повесьте тайм-аут в ключевых местах и пишите в журнал, когда он срабатывает, превращая зависание, которое иначе длилось бы вечно, в обнаруживаемый сбой. Когда зависание случается в поле, снимите дамп, проверьте стек каждого потока и ищите цикл, кто кого ждёт по блокировке. Проверка через Application Verifier официально рекомендуется для ошибок вокруг DLL.10 Настройка дампов и журналирования разобрана в «Проектирование Windows-приложений, сохраняющих логи и дампы при сбое».

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

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

  1. Каждый поток создан через _beginthreadex (без смеси CreateThread / _beginthread)?
  2. Делаете ли вы join дескрипторов потоков (WaitForSingleObject) перед CloseHandle?
  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 занимается ревью многопоточного проектирования резидентных процессов, приложений управления оборудованием и DLL, написанных на C; расследованием первопричин (анализ дампов) зависаний и сбоев из-за TerminateThread или утекших блокировок; и техническим консультированием по добавлению потоков в унаследованный код на C.

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

  1. Microsoft Learn, CreateThread function. О том, что потоки внутри исполняемого файла, вызывающего CRT, нужно вести через _beginthreadex / _endthreadex, а не CreateThread / ExitThread, и о том, что CRT может завершить процесс при нехватке памяти, когда поток, созданный через CreateThread, вызывает 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. 

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

Приложения, которые ломаются после выхода из сна — как работают события питания Windows и как строить бизнес-приложения, которые это переживают

Открыли ноутбук — и соединения бизнес-приложения мертвы: причина в проекте, который не учитывал сон. Статья разбирает поток уведомлений W...

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

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

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

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

Следует ли использовать CreateThread или _beginthreadex?
Используйте _beginthreadex для любого потока, который вызывает функции библиотеки времени выполнения C (CRT). _beginthreadex инициализирует внутренние данные на поток, нужные CRT, прежде чем запустить поток. Официальная документация прямо говорит: если поток, созданный через CreateThread, вызывает функцию CRT, CRT может завершить процесс при нехватке памяти. На практике потоки в приложении на C почти всегда где-то вызывают функцию CRT (printf, malloc, strtok и так далее), поэтому правило «всегда _beginthreadex» запомнить безопасно. Избегайте и _beginthread (без ex) — у него ловушка: возвращённый дескриптор может стать недействительным, если созданный поток рано завершится, поэтому выбирайте _beginthreadex, чей дескриптор можно передать API синхронизации.
Нельзя останавливать поток через TerminateThread?
Нельзя. TerminateThread стирает целевой поток, не дав ему выполнить ни строчки кода в режиме пользователя, поэтому если поток держал критическую секцию, она никогда не освобождается; если он выделял память из кучи, блокировка кучи остаётся занятой; если он менял глобальное состояние DLL, это состояние портится. Официальная документация прямо называет её «опасной функцией, которую следует использовать только в самых крайних случаях», а анализ кода помечает её предупреждением C6258. Правильная остановка — кооперативное завершение: создайте событие остановки, пусть каждый поток следит за ним через WaitForSingleObject / WaitForMultipleObjects, и пусть каждый поток сам приберётся и сам завершится.
Я использовал Mutex для исключения внутри процесса. В чём проблема?
Работает, но сильно бьёт по производительности. Mutex Win32 всегда объект ядра, поэтому каждое взятие и освобождение вызывает переход в режим ядра. Для исключения внутри одного процесса SRW-блокировка или CRITICAL_SECTION — они остаются в режиме пользователя и падают в ожидание ядра только при конкуренции — заметно быстрее, и официальная документация прямо называет использование Mutex для внутрипроцессной синхронизации «частой ошибкой». Mutex окупается, когда нужно исключение между процессами как именованный объект или когда хотите ждать его вместе с другими объектами ядра через WaitForMultipleObjects.
Можно ли использовать threads.h и stdatomic.h из C11 в Windows?
В 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, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

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

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