Примечание
Для доступа к этой странице требуется авторизация. Вы можете попробовать войти или изменить каталоги.
Для доступа к этой странице требуется авторизация. Вы можете попробовать изменить каталоги.
Таймеры и ввод-вывод в пуле потоков
В этой финальной части по пулу потоков в Windows 7 я намерен обсудить оставшиеся два объекта, генерирующие обратные вызовы и предоставляемые API. Я мог бы написать по пулу потоков еще больше, но после пяти статей, охватывающих практически все его возможности, вы должны уже достаточно уверенно использовать его мощь для эффективной работы своих приложений
В статьях за август (msdn.microsoft.com/magazine/hh335066) и ноябрь (msdn.microsoft.com/magazine/hh547107) я описывал объекты работы и ожидания соответственно. Объект работы (work object) позволяет передавать некую работу (в форме функции) непосредственно в пул потоков для выполнения. Эта функция начнет выполняться при первой же возможности. Объект ожидания (wait object) сообщает пулу потоков ждать на синхронизирующем объекте режима ядра в ваших интересах и поставить функцию в очередь, когда синхронизирующий объект перейдет в свободное состояние. Это альтернатива традиционным синхронизирующим примитивам, обеспечивающая масштабирование, и эффективная альтернатива опросу. Однако во многих случаях нужны таймеры для выполнения какого-то кода после определенного интервала времени или через некий регулярный промежуток. Это может понадобиться из-за отсутствия поддержки «проталкивания» («push») в некоторых веб-протоколах или потому, что вы реализуете коммуникационный протокол в стиле UDP и хотите обрабатывать повторные передачи. К счастью, API пула потоков предоставляет объект таймера для эффективной и теперь уже знакомой обработки всех этих сценариев.
Объекты таймеров
Объект таймера создается функцией CreateThreadpoolTimer. Если функция завершается успешно, то возвращает непрозрачный указатель, представляющий объект таймера. В ином случае возвращается null-значение указателя, и дополнительная информация доступна через функцию GetLastError. При наличии объекта таймера функция CloseThreadpoolTimer уведомляет пул потоков о том, что этот объект может быть освобожден. Если вы читали всю серию моих статей, все это должно звучать весьма знакомо. Вот класс traits, который можно использовать с удобным шаблоном класса unique_handle, введенным мной в статье за июль 2011 г. (msdn.microsoft.com/magazine/hh288076):
Теперь я могу использовать этот typedef и создать объект таймера следующим образом:
Как обычно, последний (необязательный) параметр принимает указатель на среду (environment), чтобы вы могли сопоставить объект таймера со средой, как я описывал в своей статье за сентябрь 2011 г. (msdn.microsoft.com/¬magazine/hh416747). Первый параметр — это функция обратного вызова, которая будет ставиться в очередь к пулу потоков при каждом срабатывании таймера. Обратный вызов таймера объявляется следующим образом:
Для управления тем, когда и как часто срабатывает таймер, используется функция SetThreadpoolTimer. Естественно, ее первый параметр предоставляет объект таймера, но второй параметр указывает время, через которое должен сработать таймер. При этом применяется структура FILETIME, которая описывает либо абсолютное, либо относительное время. Если вы не уверены в том, как она действует, советую прочитать прошлую статью, где я подробно описал семантику, связанную со структурой FILETIME. Вот простой пример, где указывается срабатывание таймера через пять секунд:
И вновь, если вы не уверены в том, как работает функция relative_time, пожалуйста, прочитайте мою статью за ноябрь 2011 г. В этом примере таймер сработает через пять секунд, после чего в очередь пула потоков будет поставлен экземпляр функции обратного вызова its_time. Пока действие не будет выполнено, другие обратные вызовы ставиться в очередь не будут.
Вы также можете использовать SetThreadpoolTimer для создания регулярно срабатывающего таймера, который будет ставить в очередь обратный вызов через определенный интервал. Вот пример:
В этом примере обратный вызов таймера сначала ставится через пять секунд, а потом через каждые полсекунды, пока объект таймера не будет сброшен или закрыт. В отличие от времени срабатывания период просто указывается в миллисекундах. Учтите, что периодический таймер будет ставить в очередь обратный вызов по истечении заданного периода независимо от того, сколько времени занимает выполнение этого обратного вызова. То есть вполне вероятно, что несколько обратных вызовов будут выполняться параллельно или с перекрытием, если интервал окажется достаточно малым или обратные вызовы потребуют достаточно длительного времени на выполнение.
Если вам нужно гарантировать отсутствие перекрытия обратных вызовов, а точное время начала для каждого периода не столь важно, тогда к созданию периодического таймера следует подойти иначе. Вместо указания периода в вызове SetThreadpoolTimer просто сбрасывайте таймер в самом обратном вызове. Тогда вы добьетесь того, что обратные вызовы никогда не будут перекрываться. Как минимум, это упростит отладку. Вообразите, как вы пошагово выполняете обратный вызов таймера в отладчике только для того, чтобы обнаружить, что пул потоков уже поставил в очередь несколько других экземпляров, пока вы анализировали свой код (или подливали себе кофе). При таком подходе этого никогда не случится. Вот как он выглядит:
Как видите, начальное время срабатывания равно пяти секундам, а затем в конце обратного вызова я сбрасываю это время и указываю 500 мс. Я воспользовался тем преимуществом, что сигнатура обратного вызова предоставляет указатель на исходный объект таймера, что сильно упрощает сбрасывание таймера. Кроме того, вы, возможно, захотите задействовать RAII, чтобы гарантировать вызов SetThreadpoolTimer до возврата управления обратным вызовом.
Вы можете вызывать SetThreadpoolTimer с null-значением указателя вместо времени срабатывания, чтобы прекратить любые будущие срабатывания таймера, которые могут привести к дальнейшим обратным вызовам. Кроме того, вам также понадобится вызывать WaitForThreadpool¬TimerCallbacks, чтобы избежать возникновения конкуренции потоков. Конечно, объекты таймеров равным образом хорошо работают с группами очистки, о которых я рассказывал в статье за октябрь 2011 г.
Последний параметр в SetThreadpoolTimer может оказаться не совсем понятным, потому что в документации его называют то длиной окна (window length), то задержкой (delay). О чем вообще речь? На самом деле это параметр, который помогает в энергосбережении. Функциональность, скрывающаяся за этим параметром, основана на методике, называемой объединением таймеров (timer coalescing). Очевидно, что самое лучшее решение — вообще избегать таймеры и вместо них использовать события. Это позволяет увеличить время простоя процессоров в системе, тем самым давая им возможность перейти в состояние с максимально низким энергопотреблением. Тем не менее, если таймеры все же нужны, их объединение сокращает общее энергопотребление за счет уменьшения количества прерываний по таймеру. Объединение таймеров основано на идее «допустимой задержки» (tolerable delay) в срабатывании. При указании некой допустимой задержки ядро Windows может подстроить время реального срабатывания так, чтобы оно совпадало со временем срабатывания любых других существующих таймеров. Хорошее эмпирическое правило — задавать задержку равной одной десятой используемого периода. Например, если таймер должен срабатывать через 10 секунд, указывайте задержку в одну секунду, если это подходит для вашего приложения. Чем больше задержка, тем больше возможностей у ядра оптимизировать прерывания от таймеров. С другой стороны, все, что меньше 50 мс, особого выигрыша не дает, потому что это приближается к пределам интервала часов по умолчанию в ядре.
Объекты завершения ввода-вывода
Теперь пора представить жемчужину API пула потоков: объект завершения ввода-вывода (I/O completion object), или просто объект ввода-вывода (I/O object). Когда я впервые говорил об API пула потоков, я упомянул, что пул потоков опирается на API порта завершения ввода-вывода. Традиционно реализация наиболее масштабируемого ввода-вывода в Windows была возможна только при использовании API порта завершения ввода-вывода. Я писал об этом API в прошлом. Хотя он не особо сложен в использовании, его не всегда легко интегрировать с другими средствами поддержки многопоточности, необходимыми приложению. Однако благодаря API пула потоков вы получаете лучшее из двух миров с единым API для работы, синхронизации, таймеров, а теперь еще и ввода-вывода. Другое преимущество заключается в том, что завершение перекрытого ввода-вывода с помощью пула потоков интуитивно понятнее, чем использование API порта завершения ввода-вывода, особенно когда дело доходит до обработки множества описателей файлов и множества перекрытых операций, выполняемых параллельно.
Как вы, возможно, догадались, объект ввода-вывода создается функцией CreateThreadpoolIo, а функция CloseThreadpoolIo сообщает пулу потоков, что этот объект может быть освобожден. Вот класс traits для шаблона класса unique_handle:
Функция CreateThreadpoolIo принимает описатель файла, подразумевая, что объект ввода-вывода способен управлять вводом-выводом для одного объекта. Естественно, этот объект должен поддерживать перекрытый ввод-вывод, но эта поддержка имеется в популярных типах ресурсов, таких как файлы файловой системы, именованные каналы, сокеты и др. Позвольте мне продемонстрировать это на простом примере ожидания получения UDP-пакета через сокет. Для управления сокетом я буду использовать unique_handle со следующим классом traits:
В отличие от показанных до сих пор классов traits в этом случае функция invalid не возвращает null-значение указателя. Это связано с тем, что функция WSASocket, как и функция CreateFile, использует необычное значение, сообщая о неправильном описателе. При наличии этого класса traits и typedef я могу довольно легко создать сокет и объект ввода-вывода:
Функция обратного вызова, которая уведомляет о завершении какой-либо операции ввода-вывода, объявляется так:
Уникальные параметры этого обратного вызова должны быть знакомы вам, если вы когда-нибудь пользовались перекрытым вводом-выводом. Поскольку перекрытый ввод-вывод асинхронный по своей природе и обеспечивает возможность выполнения перекрытых операций ввода-вывода (отсюда и произошло название «перекрытый ввод-вывод»), нужен какой-то способ идентификации конкретной операции ввода-вывода, которая только что завершилась. Для этого предназначен параметр overlapped. Через него передается указатель на структуру OVERLAPPED или WSAOVERLAPPED, которая определяется при первой инициации конкретной операции ввода-вывода. Традиционный подход с упаковкой структуры OVERLAPPED в более крупную структуру для передачи большего объема данных через этот параметр тоже можно использовать. Параметр overlapped позволяет идентифицировать конкретную операцию ввода-вывода, которая только что завершилась, а параметр context (как обычно) предоставляет контекст для конечной точки ввода-вывода независимо от конкретной операции. Благодаря этим двум параметрам у вас не должно возникнуть никаких проблем в координации потока данных, проходящего через ваше приложение. Параметр result сообщает, была ли перекрытая операция успешной, передавая обычное значение ERROR_SUCCESS, или 0. Наконец, параметр bytes_copied информирует о том, сколько байтов была считано или записано на самом деле. Предполагать, будто копируется столько байтов, сколько было запрошено, — распространенная ошибка. Не совершайте ее: именно для этого и существует данный параметр.
Единственная часть поддержки ввода-вывода в пуле потоков, которая не так-то проста, — обработка самого запроса на ввод-вывод. Чтобы правильно закодировать ее, нужно быть очень внимательным. Перед вызовом функции, инициирующей некую асинхронную операцию ввода-вывода, например ReadFile или WSARecvFrom, вы должны вызвать функцию StartThreadpoolIo, чтобы пул потоков знал о начале операции ввода-вывода. Хитрость в том, что, если операция ввода-вывода окажется завершаемой синхронно, вы должны уведомить пул потоков об этом, вызвав функцию CancelThreadpoolIo. Учтите, что завершение ввода-вывода не обязательно равнозначно успешному завершению. Операция ввода-вывода может закончиться успешно или неудачно как при синхронном, так и при асинхронном выполнении. В любом случае, если операция ввода-вывода не уведомит порт завершения о своем окончании, вам нужно будет дать знать об этом пулу потоков. Вот как это может выглядеть в контексте приема UDP-пакета:
Как видите, я начинаю процесс с вызова StartThreadpoolIo, чтобы сообщить пулу потоков о начале операции ввода-вывода. Затем вызываю WSARecvFrom для выполнения операции. Крайне важная часть — интерпретация результата. Функция WSARecvFrom возвращает 0, если операция завершилась успешно, но это не отменяет необходимости уведомить об этом порт завершения, и я изменяю результат на WSA_IO_PENDING. Любой другой результат от WSARecvFrom указывал бы на неудачу. Само по себе WSA_IO_PENDING просто означает, что данная операция успешно инициирована, но будет завершена позже. Далее, если результат уже получен, я вызываю CancelThreadpoolIo, чтобы не мешать дальнейшей работе пула потоков. Разные конечные точки ввода-вывода могут требовать разной семантики. Например, файловый ввод-вывод можно сконфигурировать так, чтобы обходиться без уведомления порта завершения при синхронном окончании операции. Тогда вам понадобилось бы вызывать CancelThreadpoolIo, только когда это действительно необходимо.
Подобно другим объектам, генерирующим обратные вызовы, в API пула потоков незавершенные (pending) обратные вызовы для объектов ввода-вывода можно отменять с помощью функции WaitForThreadpoolIoCallbacks. Но помните, что она отменит все незавершенные обратные вызовы, кроме самих незавершенных операций ввода-вывода. Вам все равно понадобится использовать подходящую функцию для отмены операции, чтобы избежать создания условий для конкуренции потоков. Это позволяет безопасно освобождать любые структуры OVERLAPPED и т. д.
И на этом все об API пула потоков. Как я говорил, об этом мощном API можно было бы написать гораздо больше, но изложенного мной материала должно хватить, чтобы вы смогли самостоятельно использовать его в своем новом приложении. Присоединяйтесь ко мне в следующем месяце — я продолжу исследование Windows и C++.
Кенни Керр (Kenny Kerr) — высококвалифицированный специалист в области разработки ПО для Windows. С ним можно связаться через kennykerr.ca.