Начиная новый проект, вы должны спрашивать самого себя, будет ваша программа интенсивно использовать процессорные ресурсы или ввод-вывод? Как подсказывает мой опыт, в большинстве случаев это один из двух вариантов. Возможно, вы работаете над библиотекой аналитических функций, которая обрабатывает большие объемы данных и заваливает работой не один процессор, перемалывая эти данные в набор агрегатов. В качестве альтернативы ваш код может большую часть времени проводить в ожидании неких событий, например поступления данных по сети, щелчка одного из UI-элементов пользователем или выполнения эдакого сложного жеста из шести пальцев. В таком случае потоки в вашей программе ничем особенным не заняты. Конечно, бывают случаи, когда программы интенсивно используют как ввод-вывод, так и процессорные ресурсы. На ум сразу же приходит СУБД SQL Server, но в современном программировании такие случаи все же не типичны. Гораздо чаще ваша программа координирует работу других. Это может быть веб-сервер или клиент, который работает с базой данных SQL, передает какие-то вычисления графическому процессору (GPU) или представляет некий контент пользователю для взаимодействия с ним. Учитывая все эти разнообразные сценарии, как решить, какие возможности многопоточности потребует ваша программа и какие строительные блоки параллельной обработки необходимы или полезны? Что ж, универсального ответа на этот вопрос нет, и вам придется кое-что проанализировать перед тем, как браться за новый проект. В связи с этим очень полезно понимать эволюцию многопоточности в Windows и C++, чтобы принять обоснованное решение на основе доступных вариантов.
Конечно, для пользователя потоки не имеют никакой прямой ценности. Вашу программу никто больше не станет считать круче, если вы используете в два раза больше потоков, чем другие. Важно то, что вы делаете с этими потоками. Чтобы проиллюстрировать эти идеи и показать, в каком направлении со временем эволюционировала многопоточность, позвольте мне взять пример чтения неких данных из файла. Я пропущу библиотеки C и C++, поскольку их поддержка ввода-вывода в основном синхронная, или блокирующая, а это, как правило, не представляет интереса, если только вы не создаете простую консольную программу. Безусловно, в ней нет ничего неправильного. Некоторые из моих любимых программ являются именно консольными, которые делают что-то одно и делают это по-настоящему хорошо. Тем не менее, это не очень интересно, поэтому идем дальше.
Один поток
Начну с Windows API и доброй старой функции ReadFile (у которой даже имя точно соответствует ее предназначению). Прежде чем считывать содержимое файла, мне нужно получить описатель этого файла, который предоставляется очень мощной функцией CreateFile:
auto fn = L"C:\\data\\greeting.txt";
auto f = CreateFile(fn, GENERIC_READ, FILE_SHARE_READ, nullptr,
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
ASSERT(f);
Чтобы сохранить краткость примеров, я буду пользоваться макросами ASSERT и VERIFY, указывающими, где потребуется добавить обработку любых ошибок, о которых могут сообщать различные API-функции. В данном фрагменте кода функция CreateFile используется для открытия, а не создания файла. Одна и та же функция годится для обеих операций. Слово «Create» в ее имени больше относится к тому факту, что создается объект ядра «файл», а не какой-то файл в файловой системе. Параметры достаточно ясны и не имеют особого отношения к нашей теме, кроме предпоследнего параметра: он задает набор флагов и атрибутов, указывающих тип поведения ввода-вывода, который вы хотите получить от ядра. Здесь я использовал константу FILE_ATTRIBUTE_NORMAL, которая сообщает, что файл должен быть открыт для обычного синхронного ввода-вывода. Не забудьте вызвать функцию CloseHandle, чтобы освободить блокировку режима ядра для данного файла, когда вы закончите работу с ним. Здесь хорошо подойдет класс оболочки описателя, например описанный мной в статье «C++ and the Windows API» за июль 2011 г. (msdn.microsoft.com/magazine/hh288076).
Теперь можно двигаться дальше и вызвать функцию ReadFile для чтения содержимого файла в память:
char b[64];
DWORD c;
VERIFY(ReadFile(f, b, sizeof(b), &c, nullptr));
printf("> %.*s\n", c, b);
Как и следовало ожидать, первый параметр задает описатель файла. Следующие два параметра описывают память, в которую будет считываться содержимое файла. ReadFile также вернет реальное число скопированных байтов, если оно окажется меньше запрошенного. Последний параметр применяется только для асинхронного ввода-вывода, и я вскоре вернусь к нему. В этом упрощенном примере я вывожу на экран символы, считанные из файла. Естественно, при необходимости функцию ReadFile можно вызывать многократно.
Два потока
Эта модель ввода-вывода проста в понимании и определенно полезна во многих небольших программах, особенно консольных. Но она не слишком хорошо масштабируется. Если вам нужно одновременно читать два разных файла, возможно, для поддержки нескольких пользователей, то понадобятся два потока. Нет проблем: для этого предназначена функция CreateThread. Вот простой пример:
auto t = CreateThread(nullptr, 0, [] (void *) -> DWORD
{
CreateFile/ReadFile/CloseHandle
return 0;
},
nullptr, 0, nullptr);
ASSERT(t);
WaitForSingleObject(t, INFINITE);
Здесь, чтобы представить процедуру потока, я использую лямбду без поддержки состояния (stateless lambda) вместо функции обратного вызова. Компилятор Visual C++ 2012 удовлетворяет спецификации языка C++11 в том, что такие лямбды должны быть неявно преобразуемыми в указатели на функции. Это удобно, и компилятор Visual C++ автоматически создает соответствующее соглашение о вызовах в архитектуре x86, где существует множество соглашений о вызовах.
Функция CreateThread возвращает описатель, представляющий поток, который я потом ожидаю, используя функцию WaitForSingleObject. Поток сам блокируется на время чтения файла. Благодаря этому несколько потоков могут выполнять разные операции ввода-вывода в тандеме. Я мог бы вызвать WaitForMultipleObjects, чтобы ждать до тех пор, пока все потоки не закончат свою работу. Также не забудьте вызвать CloseHandle, чтобы освободить ресурсы ядра, относящиеся к потоку.
Однако эта методика не обеспечивает масштабирование за пределы группы пользователей или файлов. Дело не в том, что нельзя масштабировать количество операций чтения. Совсем наоборот. Проблема в издержках, связанных с потоками и их синхронизацией, которые сведут на нет масштабируемость такой программы.
Обратно к одному потоку
Одно из решений этой проблемы — использовать ввод-вывод с оповещением (alertable I/O) через асинхронные вызовы процедур (asynchronous procedure calls, APC). В этой модели ваша программа полагается на очередь APC, которую ядро сопоставляет с каждым потоком. APC могут быть как режима ядра, так и пользовательского режима. То есть процедура или функция, которая ставится в очередь, может принадлежать программе в пользовательском режиме или даже какому-либо драйверу режима ядра. Последний вариант — простой способ, с помощью которого ядро позволяет драйверу выполнять некий код в контексте адресного пространства потока, относящегося к пользовательскому режиму, чтобы у него был доступ к его виртуальной памяти. Но этот же фокус доступен и программистам, работающим в пользовательском режиме. Поскольку ввод-вывод по своей природе на аппаратном уровне (а значит, и в ядре) является асинхронным, имеет смысл начать чтение содержимого файла и возложить на ядро постановку APC в очередь, когда чтение закончится.
Флаги и атрибуты, передаваемые в функцию CreateFile, нужно изменить для поддержки перекрытого ввода-вывода (overlapped I/O), чтобы ядро не сериализовало операции над файлом. Термины «асинхронный» и «перекрытый» являются синонимами в Windows API и означают одно и то же. Так или иначе, при создании описателя файла нужно использовать константу FILE_FLAG_OVERLAPPED:
auto f = CreateFile(fn, GENERIC_READ, FILE_SHARE_READ, nullptr,
OPEN_EXISTING, FILE_FLAG_OVERLAPPED, nullptr);
И вновь единственное отличие этого фрагмента кода в том, что я заменил FILE_ATTRIBUTE_NORMAL константой FILE_FLAG_OVERLAPPED, но различие в период выполнения огромно. Чтобы предоставить APC, который ядро сможет поставить в очередь при завершении ввода-вывода, мне нужно использовать альтернативную функцию ReadFileEx. Хотя ReadFile годится для инициации асинхронного ввода-вывода, только ReadFileEx позволяет предоставлять APC для вызова по завершении этого ввода-вывода. После этого поток может продолжить работу и заняться другими задачами, возможно, запустив дополнительные асинхронные операции, пока ввод-вывод выполняется в фоновом режиме.
Благодаря C++11 и Visual C++ для представления APC снова можно задействовать лямбду. Фокус в том, что APC скорее всего потребуется доступ к только что заполненному буферу, но буфер не является одним из параметров APC, и, поскольку разрешены лишь лямбды без поддержки состояния, вы не сможете использовать лямбду для захвата переменной buffer. Решение — подцепить buffer к структуре OVERLAPPED. Так как указатель на структуру OVERLAPPED доступен APC, вы сможете тогда просто приводить результат к структуре по своему выбору. Несложный пример показан на рис. 1.
Рис. 1. Ввод-вывод с оповещением на основе APC
struct overlapped_buffer
{
OVERLAPPED o;
char b[64];
};
overlapped_buffer ob = {};
VERIFY(ReadFileEx(f, ob.b, sizeof(ob.b), &ob.o, [] (DWORD e, DWORD c,
OVERLAPPED * o)
{
ASSERT(ERROR_SUCCESS == e);
auto ob = reinterpret_cast<overlapped_buffer *>(o);
printf("> %.*s\n", c, ob->b);
}));
SleepEx(INFINITE, true);
Помимо указателя на OVERLAPPED, APC также передаются код ошибки в первом параметре и количество скопированных байтов во втором. В некий момент ввод-вывод завершается, но для запуска APC тот же поток должен быть переведен в «тревожное» состояние для оповещений. Самый простой способ сделать это — использовать функцию SleepEx, которая пробуждает поток, как только в очередь ставится какой-то APC, и до возврата управления выполняет любые имеющиеся в очереди APC. Конечно, поток может вообще не приостанавливаться, если в очереди уже есть APC. Кроме того, вы можете проверять значение, возвращаемое SleepEx, чтобы узнать, что привело к ее возобновлению. Вы можете даже использовать нулевое значение вместо INFINITE, чтобы сбрасывать очередь APC для продолжения без задержки.
Однако использование SleepEx далеко не столь полезно и может легко привести невнимательных программистов к опросу на наличие APC, а это всегда плохая идея. Скорее всего, если вы используете асинхронный ввод-вывод из одного потока, этот поток также охватывает цикл обработки сообщений вашей программы. В любом случае вы также можете задействовать функцию MsgWaitForMultipleObjectsEx для ожидания более одного APC и создания более эффективной однопоточной исполняющей среды для своей программы. Потенциальный недостаток APC в том, что они могут вносить некоторые трудноулавливаемые ошибки реентерабельности, поэтому будьте осторожны.
Один поток на каждый процессор
По мере расширения функционала программы вы, возможно, заметите, что процессор, на котором выполняется поток вашей программы, используется все интенсивнее, а остальные процессоры «прохлаждаются» в ожидании какой-нибудь работы. Хотя APC — едва ли не самый эффективный способ асинхронного ввода-вывода, их очевидный недостаток заключается в том, что они завершаются только в том потоке, который инициировал соответствующую операцию. Отсюда вытекает задача разработать решение, позволяющее масштабировать это на все доступные процессоры. Вы могли бы придумать свою архитектуру, например, координируя работу между несколькими потоками с уведомляемыми (alertable) циклами обработки сообщений, но ничто из этого никогда не сравнится по производительности и масштабируемости с портом завершения ввода-вывода (I/O completion port) — в основном из-за его глубокой интеграции с различными частями ядра.
Если APC позволяет выполнять асинхронные операции ввода-вывода в рамках одного потока, то порт завершения дает возможность начать операцию ввода-вывода в одном потоке, а обработать результаты — в любом другом. Порт завершения — это объект ядра, создаваемый вами до его сопоставления с любым количеством объектов файлов, сокетов, каналов и др. Порт завершения предоставляет интерфейс постановки в очередь, а ядро может записывать пакет завершения в очередь при завершении ввода-вывода. После этого ваша программа может извлечь этот пакет из очереди в любом доступном потоке и обработать его. При необходимости вы даже можете ставить в очередь собственные пакеты завершения. Самое сложное — вникнуть в довольно запутанный API. На рис. 2 показан простой класс-оболочка для порта завершения, проясняющий, как используются функции этого API и как они взаимосвязаны.
Рис. 2. Оболочка порта завершения
class completion_port
{
HANDLE h;
completion_port(completion_port const &);
completion_port & operator=(completion_port const &);
public:
explicit completion_port(DWORD tc = 0) :
h(CreateIoCompletionPort(INVALID_HANDLE_VALUE, nullptr, 0, tc))
{
ASSERT(h);
}
~completion_port()
{
VERIFY(CloseHandle(h));
}
void add_file(HANDLE f, ULONG_PTR k = 0)
{
VERIFY(CreateIoCompletionPort(f, h, k, 0));
}
void queue(DWORD c, ULONG_PTR k, OVERLAPPED * o)
{
VERIFY(PostQueuedCompletionStatus(h, c, k, o));
}
void dequeue(DWORD & c, ULONG_PTR & k, OVERLAPPED *& o)
{
VERIFY(GetQueuedCompletionStatus(h, &c, &k, &o, INFINITE));
}
};
В основном путаница связана с двойными обязанностями, возложенными на функцию CreateIoCompletionPort, которая сначала создает объект порта завершения, а потом сопоставляет его с перекрытым объектом файла. Порт завершения создается лишь раз и впоследствии связывается с любым количеством файлов. С технической точки зрения, вы можете выполнить оба шага в одном вызове, но это удобно, только если вы используете порт завершения с единственным файлом. А какой в этом смысл?
При создании порта завершения единственное, чему следует уделить особое внимание, — последний параметр, указывающий количество потоков. Это максимальное число потоков, которым будет дозволено одновременно извлекать пакеты завершения из очереди. Если оно равно нулю, ядро разрешит один поток на каждый процессор.
Добавление файла называется сопоставлением; главное, что нужно отметить, — параметр, указывающий ключ, который сопоставляется с файлом. Так как к концу описателя нельзя «подвесить» дополнительную информацию, как это мы сделали со структурой OVERLAPPED, этот ключ дает вам возможность связывать с файлом какую-либо специфичную для программы информацию. Всякий раз, когда ядро ставит в очередь пакет завершения, относящийся к этому файлу, в него включается и данный ключ. Это особенно важно, потому что описатель файла не добавляется в пакет завершения.
Как я уже говорил, вы можете ставить в очередь собственные пакеты завершения. В этом случае предоставляемые вами значения обрабатываются только вами. Ядро не волнуют эти значения, и оно никак не интерпретирует их. Таким образом, вы можете передать липовый указатель на OVERLAPPED, и точно такой же адрес будет храниться в пакете завершения.
Однако в большинстве случаев вы будете ожидать, когда ядро поставит пакет завершения в очередь по окончании асинхронной операции ввода-вывода. Как правило, программа создает один или более потоков на каждый процессор и в бесконечном цикле вызывает GetQueuedCompletionStatus или мою функцию-оболочку dequeue. Вы можете ставить в очередь специальный управляющий пакет завершения (по одному на каждый поток), когда программе нужно прекратить все операции ввода-вывода и вы хотите завершить выполнение этих потоков. Как и в случае APC, вы можете подключить к структуре OVERLAPPED свои данные, чтобы связать с каждой операцией ввода-вывода дополнительную информацию:
completion_port p;
p.add_file(f);
overlapped_buffer ob = {};
ReadFile(f, ob.b, sizeof(ob.b), nullptr, &ob.o);
Здесь я снова использую исходную функцию ReadFile, но на этот раз передаю в последнем параметре указатель на структуру OVERLAPPED. Ожидающий поток может извлечь пакет завершения из очереди так:
DWORD c;
ULONG_PTR k;
OVERLAPPED * o;
p.dequeue(c, k, o);
auto ob = reinterpret_cast<overlapped_buffer *>(o);
Пул потоков
Если вы некоторое время читали мою рубрику, то вспомните, что в прошлом году я посвятил пять статей подробному описанию пула потоков в Windows. Для вас также не будет сюрпризом, что API этого пула потоков реализован с применением портов завершения ввода-вывода, предоставляя ту же модель постановки работы в очередь, но без необходимости вручную управлять потоками. Кроме того, в вашем распоряжении оказывается множество средств, которые делают этот API весьма привлекательной альтернативой прямому использованию объекта порта завершения. Если вы еще так не делали, советую прочитать те статьи, чтобы получить представление об API пула потоков в Windows. Список всех моих статей вы найдете по ссылке bit.ly/StHJtH.
Как минимум, вы можете использовать функцию TrySubmitThreadpoolCallback, чтобы пул потоков на внутреннем уровне создал один из своих рабочих объектов и немедленно передал для выполнения обратный вызов. Вот насколько это просто делается:
TrySubmitThreadpoolCallback([](PTP_CALLBACK_INSTANCE, void *)
{
// Работа помещается сюда!
},
nullptr, nullptr);
Если вам нужен несколько больший контроль, вы определенно можете напрямую создать рабочий объект и сопоставить его со средой пула потоков и группой очистки. Это также обеспечит максимально возможную производительность.
Конечно, эта статья посвящена перекрытому вводу-выводу, и пул потоков как раз предоставляет для этого объекты ввода-вывода. Я не буду тратить время на рассмотрение этого, так как подробно описывал это в своей рубрике за декабрь 2011 г. «Thread Pool Timers and I/O» (msdn.microsoft.com/magazine/hh580731), но на рис. 3 приведен новый пример.
Рис. 3. Ввод-вывод в пуле потоков
OVERLAPPED o = {};
char b[64];
auto io = CreateThreadpoolIo(f, [] (PTP_CALLBACK_INSTANCE,
void * b, void *, ULONG e, ULONG_PTR c, PTP_IO)
{
ASSERT(ERROR_SUCCESS == e);
printf("> %.*s\n", c, static_cast<char *>(b));
},
b, nullptr);
ASSERT(io);
StartThreadpoolIo(io);
auto r = ReadFile(f, b, sizeof(b), nullptr, &o);
if (!r && ERROR_IO_PENDING != GetLastError())
{
CancelThreadpoolIo(io);
}
WaitForThreadpoolIoCallbacks(io, false);
CloseThreadpoolIo(io);
Учитывая, что CreateThreadpoolIo позволяет передавать дополнительный параметр context в обратный вызов, который ставится в очередь, мне больше не нужно подключать буфер к структуре OVERLAPPED, хотя при желании это можно сделать. Главное, о чем следует здесь помнить: StartThreadpoolIo нужно вызывать до начала асинхронной операции ввода-вывода, а CancelThreadpoolIo — после успешного или неудачного выполнения операции ввода-вывода.
Быстрые потоки
Выводя концепцию пула потоков на новые высоты, новый Windows API для приложений Windows Store тоже предоставляет абстракцию пула потоков, хотя намного более простую с гораздо меньшими возможностями. К счастью, ничто не мешает вам использовать альтернативный пул потоков, подходящий для вашего компилятора и библиотек. Другой вопрос — удастся ли вам протащить это мимо дружелюбно настроенных кураторов Windows Store. Тем не менее, о пуле потоков для приложений Windows Store стоит упомянуть; кроме того, в него интегрирован шаблон асинхронной работы, реализуемый Windows API для приложений Windows Store.
Прекрасные расширения C++/CX предоставляют сравнительно простой API для асинхронного выполнения какого-либо кода:
ThreadPool::RunAsync(ref new WorkItemHandler([] (IAsyncAction ^)
{
// Работа помещается сюда!
}));
С синтаксической точки зрения, здесь все прямолинейно. Можно даже надеяться, что в будущей версии Visual C++ это станет еще проще, если компилятор научится автоматически генерировать делегат C++/CX на основе лямбда — по крайней мере, на концептуальном уровне (так же, как он делает это сейчас для указателей на функции).
Однако этот сравнительно простой синтаксис сильно контрастирует с большой внутренней сложностью. На высоком уровне ThreadPool является статическим классом, если одолжить подходящий термин из языка C#, и поэтому создать его экземпляр нельзя. Он предоставляет несколько перегруженных версий статического метода RunAsync, и это все. Каждая из них, как минимум, принимает в первом параметре делегат. Здесь я конструирую делегат с помощью лямбды. Методы RunAsync также возвращают интерфейс IAsyncAction, обеспечивая доступ к асинхронной операции.
Это работает довольно хорошо и отлично интегрируется в модель асинхронного программирования, которая пронизывает весь Windows API для приложений Windows Store. Вы можете, например, обернуть интерфейс IAsyncAction, возвращаемый методом RunAsync, в задачу Parallel Patterns Library (PPL) и достичь уровня компонуемости, аналогичного тому, о котором я рассказывал в своей рубрике за сентябрь и октябрь 2012 г. «The Pursuit of Efficient and Composable Asynchronous Systems» (msdn.microsoft.com/magazine/jj618294) и «Back to the Future with Resumable Functions» (msdn.microsoft.com/magazine/jj658968).
Однако разобраться в том, что на самом деле представляет этот кажущийся безобидным код, и полезно, и даже слегка отрезвляет. В центре расширений C++/CX лежит исполняющая среда, основанная на COM и ее интерфейсе IUnknown. Такая объектная модель на основе интерфейса, по всей видимости, не может предоставлять статические методы. Должен существовать объект, выступающий в роли интерфейса, и какая-то разновидность фабрики классов, создающая этот объект. И все это действительно есть.
Windows Runtime определяет нечто под названием класс исполняющей среды (runtime class), который во многом похож на традиционный COM-класс. Если вы представитель старой школы, то могли бы даже определить этот класс в IDL-файле и выполнить этот файл с помощью новой версии компилятора MIDL, специально предназначенного для этой задачи; он генерирует файлы метаданных .winmd и соответствующие заголовочные файлы.
Класс исполняющей среды может иметь как методы экземпляра, так и статические методы. Они определяются разными интерфейсами. Интерфейс, содержащий методы экземпляра, становится интерфейсом класса по умолчанию, а интерфейс, содержащий статические методы, приписывается к классу исполняющей среды через сгенерированные метаданные. В данном случае в классе исполняющей среды ThreadPool нет активируемого (activatable) атрибута и интерфейса по умолчанию, но после создания можно запросить статический интерфейс, а затем вызывать эти не совсем статические методы. На рис. 4 показан пример того, к чему это может привести. Помните, что большая часть кода была бы сгенерирована компилятором, но она дает хорошее представление о том, какой ценой делается так, чтобы этот простой вызов статического метода позволил асинхронно выполнять делегат.
Рис. 4. Пул потоков в WinRT
class WorkItemHandler :
public RuntimeClass<RuntimeClassFlags<ClassicCom>,
IWorkItemHandler>
{
virtual HRESULT __stdcall Invoke(IAsyncAction *)
{
// Работа помещается сюда!
return S_OK;
}
};
auto handler = Make<WorkItemHandler>();
HSTRING_HEADER header;
HSTRING clsid;
auto hr = WindowsCreateStringReference(
RuntimeClass_Windows_System_Threading_ThreadPool,
_countof(RuntimeClass_Windows_System_Threading_ThreadPool)
- 1, &header, &clsid);
ASSERT(S_OK == hr);
ComPtr<IThreadPoolStatics> tp;
hr = RoGetActivationFactory(
clsid, __uuidof(IThreadPoolStatics),
reinterpret_cast<void **>(tp.GetAddressOf()));
ASSERT(S_OK == hr);
ComPtr<IAsyncAction> a;
hr = tp->RunAsync(handler.Get(), a.GetAddressOf());
ASSERT(S_OK == hr);
Это сильно отличается от относительной простоты и эффективности вызова функции TrySubmitThreadpoolCallback. Всегда полезно понимать издержки используемых вами абстракций, даже если в конечном счете вы решите, что производительность труда превыше быстродействия программы. Давайте кратко рассмотрим этот вопрос.
Делегат WorkItemHandler на самом деле является основанным на IUnknown интерфейсом IWorkItemHandler с единственным методом Invoke. Реализация этого интерфейса осуществляется не API, а компилятором. Это имеет смысл, так как предоставляет удобный контейнер для любых переменных, захваченных лямбдой, и тело лямбды естественным образом размещается в методе Invoke, генерируемом компилятором. В данном примере я просто полагаюсь на класс шаблона RuntimeClass из Windows Runtime Library (WRL), который реализует IUnknown за меня. Потом я могу использовать удобную функцию шаблона Make, чтобы создать экземпляр своего WorkItemHandler. В случае лямбд без поддержки состояния и указателей на функции я бы предпочел, чтобы компилятор создавал статическую реализацию с пустой реализацией (no-op implementation) IUnknown и тем самым позволил бы избежать издержек динамического создания.
Чтобы создать экземпляр класса исполняющей среды, мне нужно вызвать функцию RoGetActivationFactory. Однако она требует передачи идентификатора класса. Заметьте, что это не CLSID традиционной COM, а скорее полное имя типа — в данном случае Windows.System.Threading.ThreadPool. Здесь я использую массив констант, сгенерированный компилятором MIDL, чтобы избежать вычисления строки в период выполнения. Как будто этого мало, я еще должен создать HSTRING-версию этого идентификатора класса. В этом случае я использую функцию WindowsCreateStringReference, которая в отличие от обычной функции WindowsCreateString не создает копию исходной строки. Для удобства WRL также содержит класс HStringReference, который обертывает эту функциональность. Теперь я могу вызвать функцию RoGetActivationFactory, напрямую запросив интерфейс IThreadPoolStatics и сохранив полученный указатель в смарт-указателе, предоставляемом WRL.
Теперь я наконец-то могу вызвать метод RunAsync в этом интерфейсе, передав ему свою реализацию IWorkItemHandler вместе с адресом смарт-указателя на IAsyncAction, представляющий конечный объект операции.
Тогда уже не удивительно, что функциональность и гибкость API этого пула потоков и близко не лежали рядом с таковыми, которые обеспечиваются API базового пула потоков Windows или Concurrency Runtime. Однако преимущество C++/CX и классов исполняющей среды реализуется на границах между программой и самой исполняющей средой. Как программист на C++ вы можете сказать спасибо за то, что Windows 8 не является совершенно новой платформой и традиционный Windows API по-прежнему всегда в вашем распоряжении, если он, конечно, вам нужен.