Отмена и очистка являются общеизвестными проблемами, весьма трудными в решении, когда дело доходит до многопоточных приложений. Когда безопасно закрывать описатель? Имеет ли значение, какой поток отменяет операцию? Что еще хуже, некоторые многопоточные API не реентерабельны, потенциально способные повысить производительность, но создающие для разработчика дополнительные сложности.
В прошлой статье я ознакомил вас со средой пула потоков (msdn.microsoft.com/magazine/hh394144). Одна важнейшая особенность этой среды дает возможность поддерживать группы очистки (cleanup groups), и именно на этом я сосредоточусь в этой статье. Группы очистки вовсе не решают все проблемы отмены и очистки. Они просто делают объекты и обратные вызовы пула потоков более управляемыми, а это косвенно помогает облегчить отмену и очистку других API и ресурсов.
До сих пор я показал лишь то, как с помощью шаблона класса unique_handle автоматически закрывать объекты работы через функцию CloseThreadpoolWork. (Подробности см. в моей статье за август 2011 г. по ссылке msdn.microsoft.com/magazine/hh335066.) Однако в этом подходе есть некоторые ограничения. Если вы хотите узнать, отменены или нет еще не обработанные обратные вызовы, вы должны вызвать сначала WaitForThreadpoolWorkCallbacks. Это приводит к двум вызовам, умноженным на количество объектов, генерирующих обратные вызовы и используемых в вашем приложении на данный момент. Если вы предпочтете использовать TrySubmitThreadpoolCallback, то не получите возможность сделать даже это, и вам останется гадать, как отменить или ждать конечного обратного вызова. Конечно, в реальном приложении скорее всего будут не только объекты работы. В следующей статье я начну знакомить вас с другими объектами пула потоков, которые генерируют обратные вызовы, — от таймеров до ввода-вывода и ожидаемых объектов. Координация отмены и очистки всего этого может быстро превратиться в сплошной кошмар. К счастью, группы очистки решают эти и некоторые другие проблемы.
Функция CreateThreadpoolCleanupGroup создает объект группы очистки. Если функция выполняется успешно, она возвращает непрозрачный указатель, представляющий объект группы очистки. В ином случае возвращается null, а дополнительная информация доступна через функцию GetLastError. Функция CloseThreadpoolCleanupGroup, получив объект группы очистки, указывает пулу потоков, что этот объект может быть освобожден. Я уже упоминал об этом мимоходом, но повторить не помешает: API пула потоков не переносит недопустимых аргументов. Вызов CloseThreadpoolCleanupGroup или любой другой API-функции пула потоков с недопустимым, ранее закрытым или null-значением указателя приведет ваше приложение к краху. Это дефекты, допущенные программистом, и они не требуют дополнительных проверок в период выполнения. Шаблон класса unique_handle, который я представил вам в своей статье за июль 2011 г. (msdn.microsoft.com/magazine/hh288076), берет на себя эти детали с помощью класса traits, специфичного для группы очистки:
struct cleanup_group_traits
{
static PTP_CLEANUP_GROUP invalid() throw()
{
return nullptr;
}
static void close(PTP_CLEANUP_GROUP value) throw()
{
CloseThreadpoolCleanupGroup(value);
}
};
typedef unique_handle<PTP_CLEANUP_GROUP, cleanup_group_traits> cleanup_group;
Теперь я могу использовать удобный typedef и создать объект группы очистки следующим образом:
cleanup_group cg(CreateThreadpoolCleanupGroup());
check_bool(cg);
Группа очистки сопоставляется с различными объектами, генерирующими обратные вызовы, посредством объекта среды (environment). Сначала обновите среду, чтобы указать группу очистки, которая будет управлять сроком жизни объектов и их обратных вызовов, например:
environment e;
SetThreadpoolCallbackCleanupGroup(e.get(), cg.get(), nullptr);
После этого вы можете добавлять объекты в группу очистки, на которые потом будете ссылаться как на члены группы очистки. Эти объекты можно также индивидуально удалять из группы очистки, но чаще все члены закрывают одной операцией.
Объект работы может стать членом группы очистки в момент создания простой передачей обновленной среды в функцию CreateThreadpoolWork:
auto w = CreateThreadpoolWork(work_callback, nullptr, e.get());
check_bool(nullptr != w);
Заметьте, что на этот раз я не использовал unique_handle. Только что созданный объект работы теперь является членом группы очистки в environment, и его срок жизни не требуется отслеживать напрямую с помощью RAII.
Вы можете отменить членство объекта работы в группе очистки только его закрытием, что можно сделать на индивидуальной основе с помощью функции CloseThreadpoolWork. Пулу потоков известно, что данный объект работы является членом группы очистки и отменяет это членство перед его закрытием. Это гарантирует, что приложение не рухнет, когда группа очистки позднее попытается закрыть все свои члены. Но обратное недопустимо: если вы сначала указываете группе очистки закрыть все члены, а потом вызываете CloseThreadpoolWork применительно к уже недействительному объекту работу, ваше приложение рухнет.
Конечно, весь смысл группы очистки — освободить приложение от необходимости индивидуально закрывать все генерирующие обратные вызовы объекты, которые оно использовало. Еще важнее то, что это позволяет приложению ждать выполнения и при необходимости отменять любые незавершенные обратные вызовы в операции единого ожидания, избавляя поток приложения от периодического ожидания и возобновления. Все эти и другие сервисы предоставляет функция CloseThreadpoolCleanupGroupMembers:
bool cancel = ...
CloseThreadpoolCleanupGroupMembers(cg.get(), cancel, nullptr);
Эта функция может показаться простой, но на самом деле она выполняет ряд важных операций над всеми своими членами. Сначала в зависимости от значения второго параметра она отменяет любые ожидающие обратные вызовы, обработка которых еще не началась. Затем она ждет любые обратные вызовы, выполнение которых уже началось, и при необходимости любые невыполненные обратные вызовы, если вы предпочтете не отменять их. Наконец, она закрывает все свои объекты-члены.
Некоторые находят сходство между группами очистки и сбором мусора, но я считаю это неправильной аналогией. Если уж на то пошло, группа очистки больше похожа на STL-контейнер объектов, генерирующих обратные вызовы. Объекты, добавленные в группу, не будут автоматически закрыты по любой причине. Если вы забудете вызвать CloseThreadpoolCleanupGroupMembers, в вашем приложении начнется утечка памяти. Даже вызов CloseThreadpoolCleanupGroup для закрытия самой группы не поможет. Вместо этого просто рассматривайте группу очистки как средство управления сроком жизни и параллельной обработки группы объектов. И, конечно, вы можете создать несколько групп очистки в своем приложении, чтобы по-разному управлять каждой группой объектов. Эта абстракция невероятно удобна, но никакой магии в ней нет, и вам придется позаботиться о ее корректном использовании. Возьмем следующий псевдокод:
environment e;
SetThreadpoolCallbackCleanupGroup(e.get(), cg.get(), nullptr);
while (app is running)
{
SubmitThreadpoolWork(CreateThreadpoolWork(work_callback, nullptr, e.get()));
// Rest of application.
}
CloseThreadpoolCleanupGroupMembers(cg.get(), true, nullptr);
Вполне предсказуемо этот код будет использовать неограниченные объемы памяти, и скорость его работы будет постоянно замедляться по мере исчерпания системных ресурсов.
В своей статье за август 2011 г. я продемонстрировал, что использование обманчиво простой функции TrySubmitThreadpoolCallback весьма проблематично, так как простого способа ожидания завершения ее обратного вызова нет. Это связано с тем, что она на самом деле не предоставляет доступа к объекту работы. Однако сам пул потоков от такого ограничения не страдает. Так как TrySubmitThreadpoolCallback принимает указатель на environment, вы можете неявным образом сделать объект работы членом группы очистки. Тогда вы сможете использовать CloseThreadpoolCleanupGroupMembers для ожидания выполнения или отмены соответствующего обратного вызова. Рассмотрим следующий псевдокод:
environment e;
SetThreadpoolCallbackCleanupGroup(e.get(), cg.get(), nullptr);
while (app is running)
{
TrySubmitThreadpoolCallback(simple_callback, nullptr, e.get());
// Rest of application.
}
CloseThreadpoolCleanupGroupMembers(cg.get(), true, nullptr);
Я почти готов простить тех, кто думает о схожести со сбором мусора, поскольку пул потоков автоматически закрывает объект работы, созданный TrySubmitThreadpoolCallback. Конечно, это не имеет ничего общего с группами очистки. Такое поведение я описывал в своей статье за июль 2011 г. Функция CloseThreadpoolCleanupGroupMembers в этом случае изначально не отвечает за закрытие объекта работы, а лишь ожидает и, возможно, отменяет обратные вызовы. В этом примере (в отличие от предыдущего) код будет выполняться неопределенно долго без использования лишних ресурсов и обеспечивать предсказуемые отмену и очистку. С помощью групп обратных вызовов (callback groups) функция TrySubmitThreadpoolCallback сама выполняет очистку, давая нам надежную и удобную альтернативу. В высокоструктурированном приложении, где один и тот же обратный вызов повторно ставится в очередь, все равно было бы эффективнее повторно использовать явный объект работы, но удобство этой функции трудно отрицать.
Группы очистки обладают еще одной особенностью, которая облегчает очистку в вашем приложении. Зачастую недостаточно просто ожидать выполнения пока необработанных обратных вызовов. Вам может понадобиться выполнить некую задачу очистки для каждого генерирующего обратный вызов объекта, как только у вас появится уверенность в том, что никаких обратных вызовов выполняться больше не будет. Управление сроком жизни этих объектов через группу очистки также означает, что пул потоков будет в курсе, когда должны выполняться такие задачи очистки.
Когда вы сопоставляете группу очистки с объектом environment через SetThreadpoolCallbackCleanupGroup, вы также предоставляете обратный вызов, который должен выполняться для каждого члена этой группы в процессе закрытия этих объектов функцией CloseThreadpoolCleanupGroupMembers. Поскольку это атрибут объекта environment, вы можете даже применять разные обратные вызовы к разным объектам, относящимся к одной группе очистки. В следующем примере я создаю environment для группы очистки и обратный вызов очистки:
void CALLBACK cleanup_callback(void * context, void * cleanup)
{
printf("cleanup_callback: context=%s cleanup=%s\n", context, cleanup);
}
environment e;
SetThreadpoolCallbackCleanupGroup(e.get(), cg.get(), cleanup_callback);
Первый параметр обратного вызова очистки — значение context для объекта, генерирующего обратный вызов. Это значение вы указываете, например, при вызове функции CreateThreadpoolWork или TrySubmitThreadpoolCallback, и именно благодаря ему становится известно, для какого объекта вызывается обратный вызов очистки. Второй параметр — это значение, передаваемое как последний параметр при вызове функции CloseThreadpoolCleanupGroupMembers.
Теперь рассмотрим следующие объекты работы и обратные вызовы:
void CALLBACK work_callback(PTP_CALLBACK_INSTANCE, void * context, PTP_WORK)
{
printf("work_callback: context=%s\n", context);
}
void CALLBACK simple_callback(PTP_CALLBACK_INSTANCE, void * context)
{
printf("simple_callback: context=%s\n", context);
}
SubmitThreadpoolWork(CreateThreadpoolWork(work_callback, "Cheetah", e.get()));
SubmitThreadpoolWork(CreateThreadpoolWork(work_callback, "Leopard", e.get()));
check_bool(TrySubmitThreadpoolCallback(simple_callback, "Meerkat", e.get()));
Какие из них отличаются от других? Как бы крошечный сурикат не хотел походить на больших кошек в Южной Африке, он просто никогда не будет одним из них. Что будет при закрытии членов группы очистки таким образом?
CloseThreadpoolCleanupGroupMembers(cg.get(), true, "Cleanup");
В многопоточном коде мало что может быть преопределенным. Если обратные вызовы успеют завершиться до их отмены и закрытия, мы получим следующий вывод:
work_callback: context=Cheetah
work_callback: context=Leopard
simple_callback: context=Meerkat
cleanup_callback: context=Cheetah cleanup=Cleanup
cleanup_callback: context=Leopard cleanup=Cleanup
Очень часто ошибочно полагают, будто обратный вызов очистки вызывается только для объектов, чьи обратные вызовы не получили шанса на выполнение. Windows API слегка запутывает программистов, потому что иногда обратный вызов очистки называется в нем обратным вызовом отмены, а это вовсе не так. Обратный вызов очистки вызывается для каждого текущего члена группы очистки. Вы могли бы рассматривать это как деструктор членов группы очистки, но эта аналогия приемлема ровно до того момента, пока вы не попадаете в функцию TrySubmitThreadpoolCallback, которая вновь усложняет картину. Вспомните, что пул потоков автоматически закрывает нижележащий объект работы, создаваемый этой функцией в момент выполнения его обратного вызова. То есть, будет ли выполняться обратный вызов очистки для этого неявного объекта работы, зависит от того, началось ли выполнение его обратного вызова к моменту обращения к функции CloseThreadpoolCleanupGroupMembers. Обратный вызов очистки будет выполняться для этого объекта лишь в том случае, если его обратный вызов все еще находится в очереди и вы указываете функции CloseThreadpoolCleanupGroupMembers отменить любые обратные вызовы в очереди. Все это весьма непредсказуемо, и поэтому я не советую использовать TrySubmitThreadpoolCallback с обратным вызовом очистки.
В заключение стоит упомянуть, что, даже когда CloseThreadpoolCleanupGroupMembers блокируется, она не тратит время впустую. Любые объекты, готовые к очистке, получат возможность выполнить свои обратные вызовы очистки в вызвавшем потоке, пока он ждет завершения остальных необработанных обратных вызовов. Возможности, предоставляемые группами очистки и, в частности, функцией CloseThreadpoolCleanupGroupMembers просто бесценны для корректной и эффективной очистки всех частей вашего приложения.