Пул потоков Win32 — параллелизм через CreateThreadpoolWork без своих потоков
· Обновлено: · Го Комура · Windows, Многопоточность, C++, Разработка под Windows, Win32 API, Повышение производительности
История изменений (первая версия, опубликована 22 Aug 2026)
- Первая публикация
Цитирование статьи(DOI (зарегистрированный архив): 10.5281/zenodo.22176817)
Приведённые ниже DOI относятся к ранее зарегистрированным архивным версиям, которые могут отличаться от текущего текста. Для ссылки на текущий текст используйте URL этой страницы.
Го Комура (2026). Пул потоков Win32 — параллелизм через CreateThreadpoolWork без своих потоков. KomuraSoft LLC. https://comcomponent.com/ru/blog/win32-thread-pool-api/
- DOI (зарегистрированный архив)
- 10.5281/zenodo.22176817
- DOI (последняя зарегистрированная версия)
- 10.5281/zenodo.22176818
«По одному CreateThread на клиента». «Один на таймер». «Один на ожидание события». В нативном коде Windows рефлекс — добавлять поток на каждую мелкую работу. Каждый поток расходует стек и объект ядра; создание и уничтожение тоже чего-то стоят.
Этот разрыв закрывает штатный API пула потоков Win32. Приложение отдаёт нужную обработку как обратный вызов и оставляет управление рабочими потоками ОС. «Не создавать потоки» не значит, что потоки становятся ненужными; значит, что приложение не создаёт и не уничтожает поток само на каждое задание.1
Статья рассчитана на разработчиков, которые пишут приложения, службы и DLL Windows на C/C++, и идёт в порядке выбор инструмента → выбор объекта → реализация work → оговорки для обратных вызовов и завершения. Предмет — семейство API CreateThreadpoolWork, переработанное в Windows Vista. Код — выдержка, которая показывает скелет использования; части, специфичные для приложения, вроде очереди, опущены.
1. Сначала выводы
Если обрабатываете много короткоживущих заданий, управляйте заданиями, а не потоками. Когда задание останавливается и когда его можно освободить, однако, по-прежнему проектирует приложение.
Осей суждения три.
- Выберите инструмент. Если хватает стандартного C++ или .NET, берите их. Очередь этого API — когда хотите в Win32 свести таймеры, ожидание и завершение ввода-вывода или когда хотите развести пулы.
- Отдавайте работу единицами заданий. Пользуйтесь work, timer, wait и io нового API и оставляйте на выделенных потоках работу, которой нужно состояние, специфичное для потока, вроде приоритета или COM STA.
- Стройте завершение как часть набора. Базовая форма — «остановить постановку → дождаться завершения → закрыть». Внутри обратного вызова не блокируйтесь надолго, не ждите синхронно работу того же пула и восстанавливайте состояние потока.
Если сначала хотите что-то запустить, начните с примера work в главе 4 и затем проверьте дисциплину обратного вызова в главе 5. Массовое завершение нескольких объектов — глава 6, выгрузка DLL — глава 7.
2. Выбор между пулом, выделенным потоком и стандартной библиотекой
2.1 Пул подходит короткоживущей, массовой, ожидающей работе
Пул потоков — набор рабочих потоков, которыми управляет ОС. Рабочие выполняют обратные вызовы один за другим, а ОС подстраивает их число под нагрузку. Отдав работу, которую раньше делали созданием и разборкой своих потоков, вы снижаете и код управления, и стоимость создания и уничтожения.1
Официальная документация относит к кандидатам приложения, которые параллельно ставят много мелких единиц работы, часто создают и уничтожают короткоживущие потоки, в фоне параллельно обрабатывают независимую работу и держат выделенные потоки в ожидании объектов ядра или событий. Типичны поиск, сетевой ввод-вывод и сведение потоков, которые только ждут.1
С другой стороны, работа, которой нужна смена приоритета потока, нужен COM STA или которая идёт всё время жизни процесса, остаётся на выделенном потоке. Критерий — достаточно ли, чтобы обработка просто выполнялась, или самому потоку нужна «личность».
flowchart TB
accTitle: Выбор между выделенным потоком и пулом
accDescr: Сначала проверить, нужны ли потоку особые свойства вроде приоритета или STA и выполняется ли работа долго; в пул идёт только короткоживущая, массовая или ожидающая работа, к которой это не относится
q1{"Нужны приоритет, STA и т.п.?"} -->|"Да"| ded["Держать на выделенном потоке"]
q1 -->|"Нет"| q2{"Выполняется долго?"}
q2 -->|"Да"| ded
q2 -->|"Нет"| pool["Отдать пулу потоков"]
pool -.-> ex["Короткоживущая работа, ожидания, таймеры, завершения ввода-вывода"]
Рис. 1: В пул идёт только работа, которой «личность не нужна и которая быстро заканчивается». Остальное по-прежнему на выделенном потоке.
2.2 Сначала спросите, хватает ли стандартного C++ или .NET
Если гранулярность покрывается C++ std::async или std::thread, стандартная библиотека — первый кандидат. Она переносима, а поведение std::async и future можно вести на основе стандарта.2
Причины вызывать пул потоков Win32 напрямую — требования вроде хотеть единый механизм обратных вызовов, который включает timer, wait и io; хотеть отдельные пулы или число потоков по видам работы; не хотеть владеть потоками внутри DLL или COM-компонента. Отделяйте вопрос, просто ли нужен параллелизм, от того, нужен ли контроль, специфичный для Windows.
flowchart TB
accTitle: Решение, инструментами какого слоя писать
accDescr: Если хватает стандартного C++ async или thread, используйте его; пул потоков Win32 вызывайте напрямую, когда нужно свести таймеры, ожидание и завершение ввода-вывода, развести пулы или управлять числом потоков, или не владеть потоками внутри DLL или COM-компонента
q1{"Хватает стандартных инструментов C++?"} -->|"Да"| std["std::async / std::thread"]
q1 -->|"Нет"| q2{"Что нужно?"}
q2 -->|"Свести timer, wait и io"| tp["Пул потоков Win32"]
q2 -->|"Развести пулы / управлять числом"| tp
q2 -->|"Не держать свои потоки в DLL"| tp
Рис. 2: Если сомневаетесь, начинайте со стандартной библиотеки; очередь этого API — когда появляется требование, которое она не выражает.
В .NET ту же роль играют ThreadPool и Task, а завершение ввода-вывода связано с IOCP. Подробное объяснение связи см. в статье об IOCP и пуле потоков .NET. Нет нужды спускаться к Win32 API там, где хватает инструментов слоя выше.
2.3 В новом коде используйте новый API начиная с Vista
Поколений API пула потоков Win32 два: устаревший API, который идёт с Windows 2000, вроде QueueUserWorkItem и RegisterWaitForSingleObject, и новое семейство CreateThreadpoolWork, целиком переработанное в Windows Vista.
Новый API свёл типы рабочих потоков к одному и дал единую очередь таймеров, выделенные постоянные потоки, несколько независимых пулов внутри процесса и группы очистки. Официальная документация тоже называет преимуществами нового API простоту, надёжность, производительность и гибкость.13
У устаревшего API есть и структурное ограничение: нет способа отменить работу, уже поставленную в очередь. В новом коде используйте новый API, а при пересмотре существующего кода начинайте со следующего соответствия.3
flowchart TB
accTitle: Соответствие между устаревшим API пула потоков и новым API
accDescr: QueueUserWorkItem устаревшего API заменяется объектом work нового API, очереди таймеров — timer, зарегистрированные ожидания — wait, а BindIoCompletionCallback — io
o1["QueueUserWorkItem"] --> n1["work"]
o2["Очереди таймеров"] --> n2["timer"]
o5["Зарегистрированные ожидания"] --> n3["wait"]
o4["BindIoCompletionCallback"] --> n4["io"]
Рис. 3: Цель миграции с устаревшего API фиксирована один к одному. Инвентаризацию существующего кода можно начать с этой таблицы соответствия.
3. Как это устроено — четыре объекта с разными условиями срабатывания
3.1 Выбирайте по тому, что должно запускать обратный вызов
В центре нового API — следующие четыре вида. Выбирайте не только по «что обрабатывать», но и по тому, что должно запускать обратный вызов.4
| Объект | Функция создания | Условие срабатывания обратного вызова |
|---|---|---|
| work | CreateThreadpoolWork |
Когда поставлено через SubmitThreadpoolWork |
| timer | CreateThreadpoolTimer |
Когда наступает указанное время или период |
| wait | CreateThreadpoolWait |
Когда объект ядра становится сигнальным |
| io | CreateThreadpoolIo |
Когда завершается асинхронный ввод-вывод на связанном дескрипторе |
Условия срабатывания разные, но выполняют рабочие того же пула. Вместо того чтобы писать периодическую обработку, реакцию на события и обработку завершения ввода-вывода каждое на своём выделенном потоке, можно выровнять их на общем механизме обратных вызовов.
flowchart TB
accTitle: Отделение условий срабатывания от выполнения обратного вызова
accDescr: Рабочие пула выполняют обратные вызовы, которые объекты с условиями срабатывания сделали готовыми, а рабочий, который закончил, используется и для следующего обратного вызова, поэтому приложение не создаёт поток на каждое задание
app["Приложение задаёт задание и условие срабатывания"] --> obj["Объект ждёт условия"]
obj --> ready["Обратный вызов становится готовым к выполнению"]
ready --> workers["Рабочие пула выполняют его"]
workers --> done["Закончить и вернуть рабочего"]
done --> next["Переиспользуется и для следующего обратного вызова"]
Рис. 4: Отделение механизма, который ждёт триггер, от рабочих, которые выполняют обработку, снижает управление потоками на каждое задание.
3.2 «Потоки, которые только спят и ждут» тоже можно заменить
Выгода пула не ограничивается работой, нагружающей CPU. Таймеры сводятся в одну очередь таймеров, а ожидания нескольких дескрипторов — на небольшое число потоков ожидания.1
Например, если у вас пять потоков, которые спят только затем, чтобы «сработать, когда событие станет сигнальным», их можно мыслить как замену пятью объектами wait. Вместо того чтобы держать поток, который сам спит на каждом, обработку по сигналу выполняете как обратный вызов.
flowchart TB
accTitle: Замена потоков, которые только ждут, объектами wait
accDescr: Потоки, которые только ждали и спали по одному на событие, при замене объектами wait сводятся на потоки ожидания пула, и обратный вызов выполняется только по сигналу
old2["5 потоков, которые только ждут, спящих по отдельности"] -.-> waste["Расходует 5 стеков и 5 потоков"]
new2["5 объектов wait"] --> agg["Сведены на потоки ожидания пула"]
agg --> cb2["Обратный вызов выполняется только по сигналу"]
Рис. 5: «Потоки, которые только спят и ждут», можно убрать, превратив их в объекты wait. Это очевидный первый шаг миграции на пул.
4. Основы реализации — создать work, поставить и безопасно завершить
4.1 Сначала один круговой путь от создания до завершения
Пройдите использование один раз на самом базовом объекте, work. CreateThreadpoolWork привязывает обратный вызов к контексту, а SubmitThreadpoolWork запрашивает выполнение. Если третий аргумент — NULL, используется пул процесса по умолчанию. Многим сценариям этого пула по умолчанию достаточно.56
Ниже — выдержка, которая показывает процедуру, а не полная программа, которая компилируется как есть. WORK_QUEUE, ITEM, Enqueue, Dequeue и ProcessItem обозначают обработку на стороне приложения. Взаимное исключение очереди, остановку постановщика и обработку отказов реализуйте отдельно, и если создание work не удалось, не переходите к последующим постановке, ожиданию и освобождению.
VOID CALLBACK WorkCallback(PTP_CALLBACK_INSTANCE instance,
PVOID context, PTP_WORK work)
{
// context фиксируется при создании. Данные по элементам передают через синхронизированную очередь
WORK_QUEUE* queue = (WORK_QUEUE*)context;
ITEM* item = Dequeue(queue); // взять один элемент под взаимным исключением
ProcessItem(item);
}
// 1) Создать (связать обратный вызов с общим контекстом — очередью)
PTP_WORK work = CreateThreadpoolWork(WorkCallback, &queue, NULL);
if (!work) { /* обработка ошибки через GetLastError */ }
// 2) На каждый элемент в очереди — одна постановка (число элементов и число Submit должны совпадать)
Enqueue(&queue, item);
SubmitThreadpoolWork(work);
// 3) Остановить постановщика, затем ждать завершения (TRUE также пытается отменить не начатую работу)
WaitForThreadpoolWorkCallbacks(work, FALSE);
// 4) Закрыть
CloseThreadpoolWork(work);
flowchart TB
accTitle: Жизненный цикл объекта work
accDescr: Создать через CreateThreadpoolWork; постановка через SubmitThreadpoolWork запускает обратные вызовы параллельно. При завершении сначала остановить новые постановки, дождаться всех обратных вызовов через WaitForThreadpoolWorkCallbacks, затем закрыть через CloseThreadpoolWork
c["Создать через CreateThreadpoolWork"] --> s["Поставить через SubmitThreadpoolWork (можно повторять)"]
s --> run["Обратные вызовы выполняются параллельно"]
run --> stop3["Остановить новые постановки"]
stop3 --> w["Дождаться завершения через WaitForThreadpoolWorkCallbacks"]
w --> cl["Закрыть через CloseThreadpoolWork"]
Рис. 6: Порядок завершения — «остановить постановку → дождаться завершения → закрыть». Пропустите любой шаг — получите use-after-free или гонку.
4.2 work можно переиспользовать, но context на постановку не меняется
Один и тот же объект work можно ставить несколько раз, даже до того, как предыдущий обратный вызов закончился. Каждая постановка запускает обратный вызов, и несколько экземпляров выполняются параллельно. Число реально используемых потоков пул может подстроить ради эффективности.7
Что здесь различать — work и данные каждой единицы работы. Контекст, передаваемый обратному вызову, фиксируется в момент создания. Чтобы обработать N единиц разных данных одним work, сделайте синхронизированную очередь контекстом, как в примере выше. Ставьте один раз на каждую единицу, которую кладёте в очередь, и пусть обратный вызов берёт одну единицу из очереди. Схема, которая создаёт объект work на единицу, тоже нормальна.5
flowchart TB
accTitle: Приём данных единицы через фиксированный контекст
accDescr: Контекст фиксируется на общую очередь при создании work; постановщик ставит один раз на каждую единицу, которую кладёт в очередь, и каждый обратный вызов, выполняющийся параллельно, берёт по одной единице из очереди под взаимным исключением
create["Зафиксировать контекст при создании work"] -.-> queue["Общая очередь с взаимным исключением"]
item["Положить одну единицу в очередь"] --> queue
item --> submit["Поставить один раз на единицу"]
submit --> callbacks["Обратные вызовы выполняются параллельно"]
callbacks --> dequeue["Каждый извлекает одну единицу"]
queue --> dequeue
dequeue --> process["Обработать взятую единицу"]
Рис. 7: Фиксируется контекст, который указывает на очередь; данные единицы передаются через синхронизированную очередь.
4.3 При завершении остановите постановку до того, как начнёте ждать
Безопасный порядок завершения — «остановить новые постановки → дождаться завершения через WaitForThreadpoolWorkCallbacks → закрыть через CloseThreadpoolWork». Память, на которую ссылается обратный вызов, тоже нельзя освобождать до подтверждения завершения. Если освободить то, на что ссылаются, пока ещё есть выполняющаяся или ожидающая обработка, получите use-after-free.6
Думать «я вызвал ожидание завершения, значит безопасно» недостаточно. Если другой поток всё ещё может вызвать SubmitThreadpoolWork, постановка после ожидания гоняется с Close. Чтобы ожидание было вехой завершения, сначала остановить постановщика — предпосылка.
Если второй аргумент WaitForThreadpoolWorkCallbacks — FALSE, он ждёт завершения; если TRUE, ещё запрашивает отмену обратных вызовов, которые ещё не стартовали. Это не значит, что уже выполняющуюся обработку можно бросить. Как завершать несколько объектов вместе — глава 6.
5. Дисциплина обратного вызова — не монополизируйте и не загрязняйте заимствованный поток
5.1 Заявляйте долгую работу и проверяйте возвращаемое значение тоже
Пул подстраивает число потоков из расчёта, что обратные вызовы быстро возвращают управление. Если продолжать долгую обработку или долгое ожидание, ничего не сказав, выполнение других обратных вызовов задерживается. Когда работа может занять много времени, либо сообщите пулу об этой возможности через CallbackMayRunLong, либо перенесите её на выделенный поток.8
Однако одного вызова CallbackMayRunLong недостаточно. Он возвращает FALSE, когда не может предоставить рабочего для других обратных вызовов. Вместо того чтобы игнорировать возвращаемое значение и продолжать блокироваться, в этом случае склоняйтесь к тому, чтобы не забивать пул: разрежьте обработку, перенесите на выделенный поток и так далее.8
flowchart TB
accTitle: Решение, как обращаться с долго выполняющимся обратным вызовом
accDescr: Работу, которой нужна долгая обработка или долгое ожидание, либо переносят на выделенный поток, либо заявляют пулу через CallbackMayRunLong; если возвращается FALSE, потому что другого рабочего предоставить не удалось, избегайте блокировки, разрезав работу или перенеся её на выделенный поток
long["Нужна долгая обработка или ожидание"] --> choice{"Где выполнять"}
choice -->|"Выделить"| dedicated["Перенести на выделенный поток"]
choice -->|"Выполнять на пуле"| notify["Заявить через CallbackMayRunLong"]
notify --> result{"Другой рабочий обеспечен?"}
result -->|"TRUE"| run["Делать долгую обработку"]
result -->|"FALSE"| split["Разрезать или перенести на выделенный"]
Рис. 8: Заявление долгой работы — набор, который включает проверку возвращаемого значения и решение о следующем действии.
5.2 Не ждите синхронно работу того же пула изнутри рабочего
Схема, в которой обратный вызов A ставит задание B в тот же пул и ждёт его завершения через WaitForThreadpoolWorkCallbacks или подобное, требует осторожности. Как только каждый рабочий окажется «в ожидании задания другого рабочего», свободного рабочего, который мог бы выполнить B, не останется, и получится взаимная блокировка из-за исчерпания пула.
Средство — не резервировать рабочего на ожидание, а сменить на форму продолжения, в которой обратный вызов завершения B ставит следующее задание. Не пишите зависимости как ожидания, которые занимают рабочего.
flowchart TB
accTitle: Структура взаимной блокировки из-за исчерпания пула
accDescr: Если каждый рабочий поток синхронно ждёт завершения другой работы, поставленной в тот же пул, свободного рабочего, который мог бы выполнить эту работу, нет, и все ждут вечно во взаимной блокировке
w1["Рабочий 1: ждёт завершения задания X"] --> q["Задания X и Y ждут выполнения"]
w2["Рабочий 2: ждёт завершения задания Y"] --> q
q -.-> none["Нет свободного рабочего, чтобы их выполнить"]
none -.-> dead["Все ждут вечно (взаимная блокировка из-за исчерпания)"]
Рис. 9: Синхронно ждите рабочего изнутри рабочего — и некому выполнить ожидаемое задание.
5.3 Восстановите состояние потока до возврата
Рабочий поток используется и для следующего, несвязанного обратного вызова. Оставить изменённый приоритет, оставить состояние инициализации COM, оставить значение в TLS или забыть снять блокировку переносится на следующее задание. Нельзя считать, что функция, которую вы ставите в пул, или обработка, которую она вызывает, выполняется на выделенном потоке.9
Есть и API, которые привязывают очистку к концу обратного вызова. Например, LeaveCriticalSectionWhenCallbackReturns просит пул снять критическую секцию после возврата из обратного вызова.4
flowchart TB
accTitle: Не переносите состояние потока на следующий обратный вызов
accDescr: Потому что другой обратный вызов переиспользует того же рабочего, возврат с оставленным приоритетом, COM, TLS или состоянием блокировки загрязняет следующее задание; очистка и восстановление состояния предотвращают этот перенос
first["Обратный вызов A заимствует рабочего"] --> cleanup{"Почистили до возврата?"}
cleanup -->|"Нет"| dirty["Переиспользован с оставленным состоянием"]
dirty --> impact["Затрагивает несвязанный B"]
cleanup -->|"Да"| clean["Рабочий возвращён в исходном состоянии"]
clean --> next["Следующий обратный вызов B его использует"]
Рис. 10: Поток заимствован, поэтому вы отвечаете не только за результат обработки, но и за состояние потока, когда его возвращаете.
5.4 Не давайте необработанным исключениям выйти из рабочего
Необработанное исключение на рабочем потоке может уронить весь процесс. Как и с функцией потока выделенного потока, применяйте политику ловить исключения на входе обратного вызова и журналировать их. Отдать вещи пулу не снимает нужды обрабатывать отказы, которые возникают внутри задания.
6. Разведение конфигурации — собственные пулы и группы очистки
6.1 Используйте собственный пул, чтобы изолировать виды работы
Когда пула по умолчанию уже недостаточно, независимый пул можно создать через CreateThreadpool. SetThreadpoolThreadMaximum и SetThreadpoolThreadMinimum задают верхнюю и нижнюю границы числа рабочих потоков.10
Типичная цель — изоляция. Чтобы «пакетная обработка, которая может быть медленной» не израсходовала рабочих «работы, которая должна отвечать сразу», пулы разводят и каждому дают бюджет потоков. Это не просто о добавлении потоков; это о том, какая работа какими рабочими пользуется.
6.2 Свяжите цель выполнения и очистку через среду обратного вызова
Что указывает, на каком пуле выполнять, — TP_CALLBACK_ENVIRON. Эту среду обратного вызова инициализируют, пул задают через SetThreadpoolCallbackPool и передают в CreateThreadpoolWork и подобные. Третий аргумент, который в примере главы 4 был NULL, — как раз место для среды.5
Через ту же среду можно привязать и группу очистки. Собственный пул — цель выполнения; группа очистки — единица очистки. Если видеть эти две роли отдельно, конфигурацию проще проследить.
flowchart TB
accTitle: Привязка конфигурации через среду обратного вызова
accDescr: Среда обратного вызова указывает на собственный пул и группу очистки; объекты work и timer, созданные с этой средой, выполняются на этом пуле, а массовая операция группы очистки собирает ожидание завершения и освобождение
env["Среда обратного вызова (TP_CALLBACK_ENVIRON)"] --> cp["Собственный пул (управляет числом потоков)"]
env --> cg["Группа очистки"]
env --> obj["Передаётся при создании work / timer / wait / io"]
cg -.-> close["Дождаться завершения и освободить массово"]
Рис. 11: Среда обратного вызова — механизм, который в момент создания объекта внедряет «на каком пуле выполняется и кто чистит».
6.3 Соберите ожидание завершения и освобождение нескольких объектов
Когда внутри модуля множатся объекты work, timer и другие, завершение становится списком «каждого дождаться, каждый закрыть». Если создать группу через CreateThreadpoolCleanupGroup и сделать объекты, создаваемые через среду обратного вызова, её членами, один CloseThreadpoolCleanupGroupMembers собирает ожидание завершения и освобождение всех объектов-членов.46
И здесь цель — не оставлять выполняющийся обратный вызов. Выбирайте между завершением объектов work по отдельности, как в главе 4, и завершением на уровне группы, по числу объектов, которыми управляете.
7. Использование из DLL — не выгружайте раньше обратных вызовов
7.1 Ждите завершения в явной функции завершения, а не в DllMain
Самое опасное в DLL — чтобы DLL выгрузили, пока её код обратного вызова ещё может выполниться. Если выполняется уже выгруженный код, будет нарушение доступа.
Базовое правило — остановить постановку, дождаться завершения обратных вызовов и закрыть объекты в явной функции завершения DLL до выгрузки. Будь то отдельные функции ожидания или группа очистки из главы 6, эту проверку завершения пропускать нельзя.6
Не выполняйте это ожидание внутри DllMain. Из-за связи с блокировкой загрузчика это даёт другую взаимную блокировку. Подробности — в статье о DllMain и блокировке загрузчика.
7.2 Сочетайте FreeLibraryWhenCallbackReturns со ссылкой, взятой до постановки
Для ситуации «этот обратный вызов — последняя работа, поэтому хочу освободить ссылку на DLL после его возврата» есть FreeLibraryWhenCallbackReturns.4
Однако одного этого API недостаточно, чтобы предотвратить выгрузку до старта обратного вызова. Он освобождает одну ссылку на модуль, когда выполняющийся обратный вызов возвращает управление. Используйте как пару: возьмите ссылку на модуль для этой обработки через GetModuleHandleEx до постановки и освободите её из обратного вызова этим API.
flowchart TB
accTitle: Закрытие DLL в функции завершения против возврата ссылки из обратного вызова
accDescr: Базовая форма — функция завершения, отличная от DllMain, которая останавливает постановки, ждёт завершения и освобождает до выгрузки DLL; в схеме, где последний обратный вызов возвращает ссылку, берите ссылку через GetModuleHandleEx до постановки и сочетайте с освобождением после возврата через FreeLibraryWhenCallbackReturns
shutdown["Явная функция завершения"] --> stop["Остановить постановки, дождаться, освободить"]
stop --> unload["Затем выгрузить DLL"]
note["Не ждать в DllMain"] -.-> shutdown
acquire["Взять ссылку на модуль до постановки"] --> submit["Поставить обратный вызов"]
submit --> callback["Запланировать освобождение внутри обратного вызова"]
api["FreeLibraryWhenCallbackReturns"] -.-> callback
callback --> returned["Освободить одну ссылку после возврата"]
Рис. 12: Не путайте ожидание завершения в функции завершения с освобождением ссылки, взятой для обратного вызова; в обоих случаях сначала проектируйте срок жизни DLL.
8. Итог — начните с work и заменяйте вместе с завершением
Пул потоков Win32 — основание, которое меняет параллелизм в нативном коде с «создания потоков» на «отдачу заданий как обратных вызовов». Можно свести короткоживущие задания и потоки, которые только ждут, и оставить управление потоками ОС.
Внедрение может быть постепенным. Сначала на work сделайте создание, постановку, остановку постановок, ожидание завершения и освобождение одним набором. Затем замените потоки, которые только ждут, на wait, а потоки только для таймера — на timer. Интеграцию io и разведение пулов рассматривайте, когда они станут нужны.
flowchart TB
accTitle: Поэтапная миграция существующего кода на пул потоков
accDescr: Пересмотреть существующие самостоятельно управляемые потоки, оставить работу, которая подходит стандартной библиотеке или выделенному потоку, перенести работу, подходящую пулу, на объекты work включая остановку постановок и ожидание завершения, и поэтапно заменить ожидания на wait, а таймеры на timer
inventory["Пересмотреть существующие самостоятельно управляемые потоки"] --> choose{"Подходит пулу?"}
choose -->|"Нет"| keep["Выбрать стандартную библиотеку или выделенный"]
choose -->|"Да"| work["Перенести на work с завершением как набор"]
work --> more["Ожидания в wait, таймеры в timer"]
more --> optional["Интеграция ввода-вывода, разведение пулов по мере нужды"]
Рис. 13: Не просто снизить число потоков, а завершать завершение на каждом этапе — основа поэтапной миграции.
Что держите до конца — три дисциплины: не блокироваться надолго, не ждать синхронно тот же пул и не загрязнять состояние потока. В DLL дополнительно предотвращайте гонки с выгрузкой. То, что могут закрыть инструменты стандартного C++ или .NET, оставляйте им, а этот API используйте там, где нужна интеграция или контроль, специфичные для Windows.
Похожие статьи
- Глубины ввода-вывода Windows (часть 3) — порты завершения ввода-вывода (IOCP) и пул потоков .NET: подвал под async/await
- Практические приёмы многопоточности в C++: как RAII и jthread убирают аварии из конструкции
- Многопоточность на C: практические рекомендации — безопасно по правилам Win32 API
- Ложное пробуждение: почему wait на условной переменной возвращается без уведомления и как ждать в Windows
- DllMain и блокировка загрузчика — почему говорят «в инициализации DLL ничего не делайте»
- Почему в Windows стоит предпочитать ожидание события, а не Sleep(1)
Смежные области консультирования
KomuraSoft LLC занимается проектом миграции с нативного кода, в котором расплодились потоки, на пул потоков, ревью параллелизма в приложениях и DLL на C++ и расследованием причин зависаний и падений из-за исчерпания пула или обратных вызовов. К нам можно обратиться начиная с инвентаризации существующего кода.
- Разработка Windows-приложений
- Техническая консультация и ревью проекта
- Расследование сбоев и анализ причин
- Связаться с нами
Справочные ссылки
-
Microsoft Learn, Thread Pools. О том, что пул потоков — набор рабочих потоков, которые эффективно выполняют асинхронные обратные вызовы от имени приложения; о типах приложений, которым он подходит (параллельно ставить много мелких единиц работы, часто создавать и уничтожать короткоживущие потоки, параллельно обрабатывать независимую работу, исключительно ждать объекты ядра и так далее); и о полной переработке в Vista (сведение типов рабочих потоков, единая очередь таймеров, выделенные постоянные потоки, группы очистки, несколько пулов внутри процесса и новый API). ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, <future>. О том, что асинхронное выполнение на уровне задач через std::async и future даёт стандартная библиотека, так что параллелизм можно писать, не управляя потоками напрямую. ↩
-
Microsoft Learn, Thread Pooling. О структуре устаревшего API пула потоков (QueueUserWorkItem, очереди таймеров, зарегистрированные ожидания, BindIoCompletionCallback); о том, что нет способа отменить работу, уже поставленную в очередь; и о прямом утверждении, что новый API пула потоков, введённый в Vista, проще и лучше по надёжности, производительности и гибкости. ↩ ↩2
-
Microsoft Learn, threadpoolapiset.h header. О списке функций, который включает четыре функции создания объектов CreateThreadpoolWork, CreateThreadpoolTimer, CreateThreadpoolWait и CreateThreadpoolIo; группы очистки (CreateThreadpoolCleanupGroup); и очистку, привязанную к завершению обратного вызова (LeaveCriticalSectionWhenCallbackReturns, FreeLibraryWhenCallbackReturns и так далее). ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, CreateThreadpoolWork function (threadpoolapiset.h). О создании объекта work из функции обратного вызова и указателя на контекст; и о том, что третий аргумент, TP_CALLBACK_ENVIRON, может задать среду выполнения обратного вызова (пул, к которому он принадлежит, и так далее), а NULL значит выполнение в среде по умолчанию. ↩ ↩2 ↩3
-
Microsoft Learn, Using the Thread Pool Functions. О базовой процедуре создания через CreateThreadpoolWork, постановки через SubmitThreadpoolWork, ожидания завершения через WaitForThreadpoolWorkCallbacks и закрытия через CloseThreadpoolWork; и о примере конфигурации, которая сочетает собственный пул со средой обратного вызова и группой очистки. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, SubmitThreadpoolWork function (threadpoolapiset.h). О том, что один и тот же объект work можно ставить несколько раз, не дожидаясь завершения предыдущего обратного вызова, так что обратные вызовы выполняются параллельно; и о том, что пул может подстраивать (ограничивать) число потоков ради эффективности. ↩
-
Microsoft Learn, CallbackMayRunLong function (threadpoolapiset.h). О уведомлении пула, что текущий обратный вызов может выполняться долго, чем пул пользуется, решая, обеспечивать ли потоки для других обратных вызовов; и о том, что для долго выполняющегося обратного вызова по возможности стоит рассматривать выделенный поток. ↩ ↩2
-
Microsoft Learn, Thread Pooling. О том, что единицы работы, поставленные в пул потоков, и функции, которые они вызывают, должны быть безопасны для пула потоков; о том, что нельзя считать выполняющий поток выделенным постоянным потоком; и о том, что следует избегать TLS и асинхронных вызовов, которым нужен постоянный поток. ↩
-
Microsoft Learn, SetThreadpoolThreadMaximum function (threadpoolapiset.h). О том, что для пула, созданного через CreateThreadpool, можно задать верхнюю границу числа рабочих потоков (нижняя — SetThreadpoolThreadMinimum). ↩
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
DllMain и блокировка загрузчика — почему говорят «в инициализации DLL ничего не делайте»
Почему из DllMain нельзя вызывать LoadLibrary и синхронизироваться с потоками. По первоисточникам: как блокировка загрузчика сериализует ...
Именованные каналы на практике — от проектирования до безопасности IPC в Windows
Практический разбор именованных каналов — стандартного IPC в Windows. По первоисточникам: выбор байтового режима и режима сообщений, серв...
«Не отвечает»: как Windows определяет зависание и как проектировать так, чтобы оно не возникало
Windows считает окно неотвечающим, если 5 секунд не извлекает сообщения, и подменяет его фантомным окном. Разбираем критерий, типичные пр...
Ложное пробуждение: почему wait на условной переменной возвращается без уведомления и как ждать в Windows
wait на условной переменной может вернуться и без уведомления (ложное пробуждение). Почему спецификация это допускает и как правильно жда...
Time Travel Debugging — записывать и перематывать ошибки, которые не воспроизводятся в долгоживущих приложениях
Ошибка раз в месяц оставляет в дампе только результат. Записывайте и перематывайте исполнение через WinDbg Time Travel Debugging (TTD): T...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
Бизнес-приложения, интеграция оборудования и средства связи — от требований до разработки.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Чем пул потоков лучше своих потоков через CreateThread?
- Эффективностью, когда нужно выполнить много короткоживущих заданий, и тем, что меньше кода управления потоками. Создание и уничтожение потока стоят недёшево, поэтому приложение, которое на каждое задание вызывает CreateThread и уничтожает поток по завершении, или держит много потоков только затем, чтобы спать в ожидании события, после перехода на пул снижает число потоков и переключений контекста. Официальная документация тоже относит к кандидатам на пул приложения, которые параллельно ставят много мелких единиц работы, часто создают короткоживущие потоки и держат потоки исключительно для ожидания объектов ядра. Наоборот, работу, которой нужны особые свойства самого потока — смена приоритета, COM STA, выделенная обработка, которая идёт долго, — по-прежнему стоит держать на выделенном потоке.
- Чем это отличается от старых функций пула вроде QueueUserWorkItem?
- В Windows Vista пул потоков переработали целиком. Текущие API семейства threadpoolapiset (CreateThreadpoolWork и остальные) — новый API; QueueUserWorkItem, RegisterWaitForSingleObject и подобные — старый (устаревший) API. Новый API сводит типы рабочих потоков к одному, позволяет завести в процессе несколько независимых пулов и даёт массовое освобождение через группу очистки, а также снятие блокировки или выгрузку DLL, привязанные к завершению обратного вызова. Официальная документация прямо пишет, что новый API проще и лучше по надёжности, производительности и гибкости. У старого API есть и структурные ограничения: например, «нет способа отменить работу, уже поставленную в очередь». В новом коде используйте новый API.
- Чего нельзя делать внутри обратного вызова?
- Три главных запрета. Первое: по умолчанию долго блокироваться или выполнять долгую работу. Пул подстраивает число потоков из расчёта, что обратные вызовы быстро заканчиваются, поэтому для работы, которая займёт много времени, либо заявите это через CallbackMayRunLong, либо используйте выделенный поток. Второе: синхронно ждать завершения другой работы, которую вы сами отправили в тот же пул. Если все рабочие окажутся «в ожидании другого рабочего», получится взаимная блокировка из-за исчерпания пула. Третье: опираться на свойства конкретного потока. Рабочие потоки общие для обратных вызовов, поэтому вернуть поток с изменённым приоритетом или состоянием инициализации COM либо оставить данные в TLS — значит испортить следующий обратный вызов. Для завершения (снять блокировку, выгрузить DLL) есть отдельные механизмы: LeaveCriticalSectionWhenCallbackReturns и FreeLibraryWhenCallbackReturns.
- На что смотреть, если пул потоков вызывается из DLL?
- Главная опасность — «DLL выгружают, пока обратный вызов ещё выполняется». Если обратный вызов пойдёт уже после выгрузки, будет нарушение доступа. Сторона DLL в функции завершения должна надёжно дождаться своих обратных вызовов — WaitForThreadpoolWorkCallbacks или CloseThreadpoolCleanupGroupMembers у группы очистки — и только потом закрывать объекты. Ждать этого внутри DllMain, однако, можно взаимно заблокироваться из-за блокировки загрузчика, поэтому правило — делать это в явной функции завершения, а не в DllMain. Если сам обратный вызов хочет освободить DLL, потому что «это последняя работа», предусмотрен отдельный API FreeLibraryWhenCallbackReturns.
- Раз есть C++ std::async и ThreadPool в .NET, остаются ли случаи, когда этот API нужно вызывать напрямую?
- Остаются. Критерий — хватает ли инструмента на этом слое. Если нужная в C++ гранулярность параллелизма покрывается std::async или std::thread, стандартная библиотека — первый кандидат и с точки зрения переносимости. С другой стороны, свести таймеры, ожидание объектов ядра и завершение асинхронного ввода-вывода в один механизм обратных вызовов; развести пулы и управлять числом потоков по видам работы; не держать свои потоки внутри DLL или COM-компонента — это как раз область пула потоков Win32. Связь с ThreadPool .NET и IOCP разобрана в смежной статье; пока вы пишете нативно, знать этот механизм нижнего слоя не лишне.
Об авторе
Страница с профилем автора статьи.
Го Комура
Представитель KomuraSoft LLC
Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.