Win32 Thread Pool API — параллелизм без создания потоков через CreateThreadpoolWork
· Го Комура · Windows, Многопоточность, C++, Разработка под Windows, Win32 API, Повышение производительности
«Один CreateThread на клиента.» «Один на таймер.» «Один на ожидание события.» — В нативном коде Windows потоки так и норовят размножаться. Каждый поток потребляет стек и объект ядра, а создание и уничтожение тоже имеют цену. Работа мелкозернистая, а поток тяжёлый — пул потоков как раз то, что ОС даёт, чтобы поглотить это несоответствие.
Удобство ThreadPool и Task.Run в .NET хорошо известно, но на самом деле у нативного Win32 тоже есть хорошо спроектированный, стандартный для ОС API пула потоков. Комплексно переработанный в Windows Vista, этот API — основа нативного параллелизма: он умеет обрабатывать работу, таймеры, ожидания и асинхронный ввод-вывод через единый механизм обратных вызовов. Рассчитанная на разработчиков, которые пишут приложения, службы и DLL Windows на C/C++, эта статья разбирает структуру и применение этого API и ловушки, в которые легко попасть, по первичным источникам.
1. Сначала вывод
- Для выдачи большого числа короткоживущих заданий и для замены потоков, которые только ждут, пул потоков лучше собственного
CreateThread. Управление потоками оставляете ОС и можете снизить число потоков и переключений контекста.1 - Использовать нужно новый API (семейство
CreateThreadpoolWork). Переработка Vista сделала его проще, надёжнее и производительнее старого API (семействоQueueUserWorkItem), и в одном процессе можно ещё создать несколько независимых пулов.12 - Есть четыре вида объектов. work, в который отправляют задания; timer, который срабатывает в момент времени или периодически; wait, который срабатывает, когда объект ядра сигнален; и io, который срабатывает при завершении асинхронного ввода-вывода. Все они едут на одном механизме обратных вызовов.3
- Остановка — «подождать, потом закрыть». Нужна дисциплина не оставлять выполняющийся обратный вызов — ждать завершения семейством
WaitForThreadpoolWorkCallbacksили обрабатывать пакетом через группу очистки.4 - Внутри обратного вызова: не блокируйтесь надолго (если будете —
CallbackMayRunLong); не ждите синхронно завершения на том же пуле; не пачкайте состояние потока. Эти три — железные правила.56 - Использование из DLL: следите за гонкой с выгрузкой. Ждите завершения в явной функции остановки и знайте отдельные API вроде
FreeLibraryWhenCallbackReturns.3
2. Зачем пул и когда пул
Идея пула потоков проста. Вместо того чтобы создавать поток на каждое задание, вы бросаете задания (обратные вызовы) группе рабочих потоков, которыми управляет ОС. Рабочие выполняют задания одно за другим, а ОС подстраивает число под нагрузку.
Официальная документация перечисляет конкретные типы приложений, где пул окупается.1
- Приложения, которые выдают параллельно большое число мелких единиц работы (поиск, сетевой ввод-вывод и так далее)
- Приложения, которые часто создают и разбирают короткоживущие потоки
- Приложения, которые обрабатывают независимую работу параллельно в фоне
- Приложения, которые держат потоки, посвящённые ожиданию объектов ядра или событий
Последний пункт легко пропустить. Если у вас пять потоков, которые существуют только чтобы спать и «сработать, когда событие сигнально», их можно заменить пятью объектами wait на пуле, и ожидание агрегируется на потоках-ожидателях пула.
Наоборот, есть и работа, которая пулу не подходит. Работа, которой нужна смена приоритета потока, которой нужен COM STA, которая идёт всю жизнь процесса, — работу, которой нужна «личность» на потоке, держат на выделенном потоке. Рабочий поток — общий ресурс; его одалживают.
flowchart TB
accTitle: Выбор между выделенным потоком и пулом
accDescr: Сначала проверить, нужна ли личность потока вроде приоритета или STA и выполняется ли работа долго; только короткоживущая, массовая или ожидающая работа, которая не подходит ни под то ни под другое, идёт в пул потоков
q1{"Нужна личность вроде приоритета или STA?"} -->|"Да"| ded["Оставить на выделенном потоке"]
q1 -->|"Нет"| q2{"Выполняется долго?"}
q2 -->|"Да"| ded
q2 -->|"Нет"| pool["Положить в пул потоков"]
pool -.-> ex["Короткая работа, ожидания, таймеры, завершения I/O"]
Рис. 1: В пул можно класть только работу, которой «личность не нужна и которая быстро кончается». Всё остальное остаётся на выделенном потоке как прежде.
Есть ещё один исторический момент, который стоит закрепить. У API пула потоков два поколения. Старый API, который идёт с Windows 2000 (QueueUserWorkItem, RegisterWaitForSingleObject и так далее), и новый API, комплексно переработанный в Vista (семейство CreateThreadpoolWork). Новый API унифицирует виды рабочих потоков, даёт выделенные постоянные потоки, несколько пулов в одном процессе, группы очистки и прочее, и официальная документация прямо говорит, что он «проще, надёжнее, производительнее и гибче».1 У старого API есть и структурные ограничения вроде «работу, поставленную в очередь, отменить нельзя».2 Дальше эта статья рассматривает только новый API.
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"]
Рис. 2: Цель миграции со старого API — один к одному. Инвентаризацию существующего кода можно начать с этого соответствия.
3. Четыре объекта — work, timer, wait и io
В центре нового API — четыре вида объектов, у которых различаются условия срабатывания обратного вызова.3
| Объект | Функция создания | Когда срабатывает обратный вызов |
|---|---|---|
| work | CreateThreadpoolWork | Когда его отправляют через SubmitThreadpoolWork |
| timer | CreateThreadpoolTimer | Когда наступает заданное время или период |
| wait | CreateThreadpoolWait | Когда объект ядра становится сигнальным |
| io | CreateThreadpoolIo | Когда завершается асинхронный ввод-вывод на связанном дескрипторе |
flowchart TB
accTitle: Четыре объекта пула потоков и механизм обратных вызовов
accDescr: work срабатывает при явной отправке, timer — по времени, wait — по сигналу объекта ядра, io — по завершению асинхронного ввода-вывода; все они выполняются как обратные вызовы на одной группе рабочих потоков
kind{"Какой объект?"}
kind --> w["work(при отправке)"]
kind --> more{"Timer, wait или io?"}
more --> t["timer(время / период)"]
more --> rest{"Wait или io?"}
rest --> wt["wait(по сигналу)"]
rest --> io["io(завершение I/O)"]
w --> pool["Рабочие выполняют callback"]
t --> pool
wt --> pool
io --> pool
Рис. 3: Условия срабатывания разные, но все четыре унифицированы механизмом, в котором «рабочие одного пула выполняют обратный вызов».
Эта унификация — практическая сила. Вместо того чтобы писать периодическую обработку, реакцию на событие и обработку завершения ввода-вывода каждая на своём выделенном потоке, их можно выровнять на один стиль обратных вызовов. Таймеры собираются в одну очередь таймеров на весь пул, а ожидания агрегируются на небольшом числе потоков-ожидателей — потоки, которые «только спят», исчезают из процесса.1
flowchart TB
accTitle: Замена потоков, которые только ждут, объектами wait
accDescr: Потоки, которые только ждали и спали по одному на событие, становятся объектами wait и агрегируются на потоках-ожидателях пула, так что обратный вызов выполняется только когда есть сигнал
old2["5 выделенных потоков-ожидателей спят порознь"] -.-> waste["Потребляет 5 стеков и 5 потоков"]
new2["5 объектов wait"] --> agg["Агрегированы на ожидателях пула"]
agg --> cb2["Callback только при сигнале"]
Рис. 4: Потоки, которые «только спят и ждут», можно убрать, превратив их в объекты wait. Это ясный первый ход при миграции на пул.
4. Базовый шаблон — один круг с объектом work
Пройдём манеры один раз на объекте work, который используют чаще всего.4
VOID CALLBACK WorkCallback(PTP_CALLBACK_INSTANCE instance,
PVOID context, PTP_WORK work)
{
// The context is fixed at creation time. Per-item data is passed through a synchronised queue
WORK_QUEUE* queue = (WORK_QUEUE*)context;
ITEM* item = Dequeue(queue); // Take one item under exclusive control
ProcessItem(item);
}
// 1) Create (bind the callback to the shared context = the queue)
PTP_WORK work = CreateThreadpoolWork(WorkCallback, &queue, NULL);
if (!work) { /* Failure handling with GetLastError */ }
// 2) Submit once for each item you enqueue (keep the item count and the submit count in step)
Enqueue(&queue, item);
SubmitThreadpoolWork(work);
// 3) Stop the submitter, then wait for completion (TRUE also attempts to cancel work that has not yet started)
WaitForThreadpoolWorkCallbacks(work, FALSE);
// 4) Close
CloseThreadpoolWork(work);
Есть два пункта, которые нужно удержать. Первый: один и тот же объект work можно SubmitThreadpoolWork больше одного раза. Каждая отправка запускает обратный вызов (параллельно).7 Однако контекст, передаваемый обратному вызову, фиксируется в момент создания, поэтому когда «N единиц работы одного вида» пишут одним объектом work, в контекст кладут синхронизированную очередь, как в коде выше, и берут по одному элементу на отправку (схема с объектом work на элемент тоже нормальна). Второй: всегда ждите завершения, прежде чем закрывать. Закрыть объект, пока остаётся выполняющийся или поставленный в очередь обратный вызов, или освободить память, на которую ссылается обратный вызов, — это use-after-free как есть. Чтобы это ожидание стало безопасным закрытием, сначала остановить отправителя — предпосылка: в структуре, где другой поток всё ещё может Submit параллельно с ожиданием, отправка после ожидания гоняется с Close. Передача TRUE вторым аргументом WaitForThreadpoolWorkCallbacks ещё пытается отменить отправки, которые ещё не начались.
flowchart TB
accTitle: Жизненный цикл объекта work
accDescr: Создать через CreateThreadpoolWork; отправить через SubmitThreadpoolWork — и обратные вызовы идут параллельно. При остановке сначала прекратить новые отправки, дождаться завершения всех обратных вызовов через WaitForThreadpoolWorkCallbacks, затем закрыть через CloseThreadpoolWork
c["Создать через CreateThreadpoolWork"] --> s["Отправить через SubmitThreadpoolWork (можно повторять)"]
s --> run["Обратные вызовы идут параллельно"]
run --> stop3["Прекратить новые отправки"]
stop3 --> w["Ждать завершения через WaitForThreadpoolWorkCallbacks"]
w --> cl["Закрыть через CloseThreadpoolWork"]
Рис. 5: Порядок остановки — «прекратить отправки → дождаться завершения → закрыть». Пропустите любой шаг — получите use-after-free или гонку.
По умолчанию обратные вызовы выполняются на пуле процесса по умолчанию. Для многих сценариев этого достаточно. Следующая глава — для случая, когда пулы хочется разделить.
5. Пользовательские пулы и группы очистки
Разделить пул. Независимый пул можно создать через CreateThreadpool и задать верхнюю и нижнюю границы числа потоков через SetThreadpoolThreadMaximum / SetThreadpoolThreadMinimum.8 Типичное применение — изоляция. Чтобы «пакетная работа, которая может быть медленной» не съедала рабочих у «работы, которой нужно отвечать сразу», пулы разделяют и каждому дают свой бюджет потоков.
Связать средой обратного вызова. На каком пуле выполняется работа, задают, инициализируя TP_CALLBACK_ENVIRON (среду обратного вызова), указывая её на пул через SetThreadpoolCallbackPool и передавая третьим аргументом CreateThreadpoolWork и подобных.9
Сложить группой очистки. В модуле, который создаёт много объектов, обработка остановки норовит стать перечислением «подождать все, закрыть все». Если создать группу через CreateThreadpoolCleanupGroup и прикрепить каждый объект к ней через среду обратного вызова, один CloseThreadpoolCleanupGroupMembers выполняет ожидание завершения и освобождение всех объектов-членов вместе.34
flowchart TB
accTitle: Привязка конфигурации через среду обратного вызова
accDescr: Среда обратного вызова указывает на пользовательский пул и группу очистки; work и timer, созданные с этой средой, выполняются на этом пуле, а пакетная операция над группой очистки собирает ожидание завершения и освобождение
env["Среда callback (TP_CALLBACK_ENVIRON)"] --> cp["Свой пул (контроль числа потоков)"]
env --> cg["Группа очистки"]
env --> obj["Передаётся при создании work / timer / wait / io"]
cg -.-> close["Ждать завершения и освободить разом"]
Рис. 6: Среда обратного вызова — механизм, который в момент создания объекта внедряет «на каком пуле выполняется и кто убирает».
6. Ловушки — дисциплина внутри обратного вызова
Почти каждый дефект пула потоков приходит из «делай что хочешь на одолженном потоке».
Долгая блокировка. Пул подстраивает число потоков из допущения, что обратные вызовы быстро возвращаются. Делать долгую работу или долгое ожидание при настройках по умолчанию задерживает выполнение других обратных вызовов. Обратный вызов, который может идти долго, должен заявить «это будет долго» через CallbackMayRunLong (пул берёт это как подсказку добавить поток) либо с самого начала уйти на выделенный поток. Заметьте: CallbackMayRunLong возвращает FALSE, когда не может подготовить рабочего для других обратных вызовов. Если продолжать блокироваться, не проверив возвращаемое значение, пул всё равно забьётся, поэтому при FALSE падайте на сторону, которая не блокируется, — дробить работу, отправить на выделенный поток и так далее.5
Синхронное ожидание завершения на том же пуле. Форма, в которой внутри обратного вызова A вы ждёте через WaitForThreadpoolWorkCallbacks или подобное завершения работы B, отправленной в тот же пул, становится взаимной блокировкой от голодания пула в тот момент, когда каждый рабочий «ждёт другого рабочего». Зависимость между заданиями перепишите не как ожидание, а как продолжение, которое «отправляет следующее из обратного вызова завершения B».
flowchart TB
accTitle: Структура взаимной блокировки от голодания пула
accDescr: Если каждый рабочий поток синхронно ждёт завершения другой работы, отправленной в тот же пул, свободного рабочего, который мог бы эту работу выполнить, не остаётся, и все ждут вечно
w1["Рабочий 1: ждёт завершения работы X"] --> q["Работы X и Y ждут выполнения"]
w2["Рабочий 2: ждёт завершения работы Y"] --> q
q -.-> none["Нет свободного рабочего, чтобы их выполнить"]
none -.-> dead["Все ждут вечно (голодание)"]
Рис. 7: Если синхронно ждать рабочего изнутри рабочего, некому выполнить ожидаемую работу.
Пачканье состояния потока. Рабочий поток переиспользуется для следующего обратного вызова. Смена приоритета потока, состояние инициализации COM, значение, оставленное в TLS, блокировка, которую забыли отпустить, — любое из этого становится загрязнением следующего (несвязанного) обратного вызова. «Функция, которую бросают в пул, не должна зависеть от личности потока» — официальное предостережение ещё со времён старого API.6 Для уборки есть отдельные механизмы; например, LeaveCriticalSectionWhenCallbackReturns может попросить пул «отпустить эту блокировку, когда этот обратный вызов вернётся».3
Гонка с выгрузкой DLL. Если DLL, которая содержит код, выгружают, пока обратный вызов выполняется, получите нарушение доступа. Базовая форма — тщательно ждать завершения в функции остановки DLL; FreeLibraryWhenCallbackReturns предусмотрен для ситуации «этот обратный вызов — последнее задание, и когда оно закончится, я хочу освободить DLL, включая себя». Этот API, однако, только «отпускает одну ссылку, когда выполняющийся обратный вызов возвращается»; он не мешает выгрузке до того, как обратный вызов стартовал. Используйте его парой: возьмите собственную ссылку на модуль через GetModuleHandleEx до отправки и дайте обратному вызову отпустить эту ссылку этим API.3 И это ожидание завершения нельзя делать внутри DllMain — как сказано в «DllMain и блокировка загрузчика», ожидание другого потока внутри DllMain — шаблон взаимной блокировки.
flowchart TB
accTitle: Подготовка к гонке между выгрузкой DLL и обратным вызовом
accDescr: Выгрузка DLL во время выполнения обратного вызова становится нарушением доступа, поэтому базовая форма — ждать завершения в явной функции остановки и затем закрыть; когда последний обратный вызов сам освобождает DLL, используйте FreeLibraryWhenCallbackReturns
risk["Выгрузка в callback"] -.-> av["Access violation"]
g1["Ждать, затем закрыть"] --> safe["Безопасная выгрузка"]
g1 -.-> g1N["В shutdown"]
g2["FreeLibraryWhenCallbackReturns"] --> safe
g2 -.-> g2n["последний callback"]
g1 -.-> ng["Не в DllMain"]
av ~~~ g1
Рис. 8: Базовая форма — делать «подождать, затем закрыть» в явной функции остановки. Ожидание внутри DllMain приглашает другую взаимную блокировку.
Исключения и падения внутри отправленной работы. Необработанное исключение на рабочем потоке уносит процесс с собой. Применяйте политику всесторонне ловить исключения на входе обратного вызова и журналировать их — так же, как для потоковой функции выделенного потока.
7. Как это соотносится со стандартной библиотекой и .NET — на каком слое писать
Наконец, разложим, как это сидит относительно других инструментов.
- Если хватает C++
std::async/std::thread, они — первый кандидат. Они переносимы, код короткий, и даже семантикаfutureзафиксирована стандартом.10 - Причины вызывать пул потоков Win32 напрямую — когда вы (1) хотите единый механизм обратных вызовов, который включает timer, wait и io, (2) хотите разделение пулов или контроль числа потоков или (3) не хотите держать собственные потоки внутри DLL или COM-компонента.
- На стороне .NET ту же роль играют
ThreadPoolиTask, а завершение ввода-вывода привязано к IOCP. Эта подвальная структура разобрана в «IOCP и пул потоков .NET».
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
Рис. 9: Когда сомневаетесь, начинайте со стандартной библиотеки; очередь этого API наступает, когда появляется требование, которое она не выражает.
Иными словами, этот API — основа параллелизма в точке, где вы решили «писать нативно». Реалистичная уборка — поэтапная: как цель миграции с размножения собственного CreateThread сначала введите объект work, затем замените потоки, которые только ждут, на wait, а потоки-таймеры — на timer.
8. Итоги
- Для выдачи большого числа короткоживущих заданий и для уборки потоков, которые только ждут или только тикают, — стандартный для ОС пул потоков, а не собственные потоки. Использовать нужно новый API начиная с Vista.
- В центре — четыре объекта work, timer, wait и io. Условия срабатывания разные; они унифицированы на одной группе рабочих и одном стиле обратных вызовов.
- Манеры — «создать → отправить → дождаться завершения → закрыть». Несколько отправок идут параллельно. Группа очистки может пакетировать обработку остановки.
- Три железных правила обратного вызова: не блокируйтесь надолго (если будете —
CallbackMayRunLong); не ждите синхронно на том же пуле; не пачкайте состояние потока. - Использование из DLL: следите за гонкой с выгрузкой. Ждите завершения в явной функции остановки; не делайте этого в
DllMain. - Где хватает стандартного C++ или .NET, используйте их. Очередь этого API — когда нужна интеграция timer/wait/io или управление пулом.
API пула потоков среди API Win32 — из более новых и лучше спроектированных. Как только закончите сдвиг от идеи «создать поток» к идее «бросить обратный вызов», параллелизм в нативном коде становится заметно яснее писать.
Похожие статьи
- Глубины I/O в Windows (часть 3) — порты завершения ввода-вывода (IOCP) и пул потоков .NET: подвал под async/await
- Практические рекомендации по многопоточности: издание C++ — устранение аварий структурой через RAII и jthread
- Практические рекомендации по многопоточности: издание C — безопасное письмо в духе Win32 API
- Ложные пробуждения — почему условные переменные просыпаются «без уведомления» и как правильно ждать в Windows
- DllMain и блокировка загрузчика — настоящая причина, почему говорят «ничего не делай в инициализации DLL»
- Почему в Windows стоит предпочитать ожидание события, а не Sleep(1)
Смежные области консультирования
KomuraSoft LLC занимается проектированием миграции нативного кода, в котором размножились потоки, на пул потоков, ревью проектирования параллельной обработки в приложениях и DLL на C++ и расследованием первопричин зависаний и падений, вызванных голоданием пула или обратными вызовами. Консультация начиная с инвентаризации существующего кода приветствуется.
- Разработка Windows-приложений
- Техническая консультация и ревью проекта
- Исследование дефектов и анализ первопричин
- Связаться с нами
Справочные ссылки
-
Microsoft Learn, Thread Pools. О том, что пул потоков — совокупность рабочих потоков, которые эффективно выполняют асинхронные обратные вызовы от имени приложения; о типах приложений, которым он подходит (параллельная выдача большого числа мелких единиц работы, частое создание и разбор короткоживущих потоков, параллельная обработка независимой работы, исключительные ожидания объектов ядра и так далее); и о комплексной переработке Vista (унификация видов рабочих потоков, одна очередь таймеров, выделенные постоянные потоки, группы очистки, несколько пулов в одном процессе и новый API). ↩ ↩2 ↩3 ↩4 ↩5
-
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 ↩5 ↩6
-
Microsoft Learn, Using the Thread Pool Functions. О базовой процедуре создания через CreateThreadpoolWork, отправки через SubmitThreadpoolWork, ожидания завершения через WaitForThreadpoolWorkCallbacks и закрытия через CloseThreadpoolWork; и о примере конфигурации, которая сочетает пользовательский пул со средой обратного вызова и группой очистки. ↩ ↩2 ↩3
-
Microsoft Learn, CallbackMayRunLong function (threadpoolapiset.h). Об уведомлении пула, что текущий обратный вызов может выполняться долго, чтобы пул мог использовать это как материал для решения, закреплять ли поток для других обратных вызовов; и о том, что для долгого обратного вызова по возможности стоит рассматривать выделенный поток. ↩ ↩2
-
Microsoft Learn, Thread Pooling. О том, что единицы работы, отправляемые в пул потоков, и функции, которые они вызывают, должны быть безопасны для пула; о том, что нельзя исходить из того, что выполняющий поток — выделенный постоянный поток; и о том, что нужно избегать TLS и асинхронных вызовов, которым нужен постоянный поток. ↩ ↩2
-
Microsoft Learn, SubmitThreadpoolWork function (threadpoolapiset.h). О том, что один и тот же объект work можно отправить больше одного раза, не дожидаясь завершения предыдущего обратного вызова, так что обратные вызовы идут параллельно; и о том, что пул может подстраивать (дросселировать) число потоков ради эффективности. ↩
-
Microsoft Learn, SetThreadpoolThreadMaximum function (threadpoolapiset.h). О том, что для пула, созданного через CreateThreadpool, можно задать верхнюю границу числа рабочих потоков (нижняя граница — SetThreadpoolThreadMinimum). ↩
-
Microsoft Learn, CreateThreadpoolWork function (threadpoolapiset.h). О создании объекта work из функции обратного вызова и указателя контекста; и о том, что третий аргумент, TP_CALLBACK_ENVIRON, может задать среду выполнения обратного вызова (пул, к которому он принадлежит, и так далее), а NULL значит выполнение в среде по умолчанию. ↩
-
Microsoft Learn, <future>. Об асинхронном выполнении по задаче через std::async и future, предоставляемых как стандартная библиотека, так что параллелизм можно писать, не управляя потоками напрямую. ↩
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Именованные каналы на практике — стандартный IPC Windows: от проектирования до безопасности
Практическое руководство по именованным каналам — стандартному межпроцессному взаимодействию Windows. Статья по первичным источникам разб...
Что такое MFC в Windows: основы для сопровождения существующих приложений
Разбираем обзор Microsoft Foundation Classes (MFC): связь с Win32, структуру приложения, карты сообщений, архитектуру Document/View, DDX/...
Чек-лист безопасной работы с дочерними процессами в Windows-приложении
Для безопасной работы с дочерними процессами в Windows-приложении важнее не API запуска, а то, кто владеет деревом процессов, и как спрое...
Распространение Windows-приложения одним файлом — единый бинарный файл и пределы зависимости от ОС
Когда хочется свести Windows-приложение к одному EXE, важно различать «сделать один файл распространения» и «избавиться от зависимости от...
Глубины виртуализации Windows (часть 3) — виртуальные машины, которые загружаются за секунды: почему WSL2, Windows Sandbox и контейнеры такие лёгкие
Почему WSL2 и Windows Sandbox стартуют за секунды и ощущаются такими лёгкими? Статья разбирает механизмы — от динамического базового обра...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы 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, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.