понедельник, 24 марта 2008 г.

.Net Thread Pool и IOCP

В программировании часто перед нами встает задача выполнить какое-то действие "параллельно" с текущим. Методов решения может быть море: использование дополнительных потоков, откладывание задачи "на потом" (вручную или с помощью APC), использование каких-либо очередей задач и другие варианты асинхронного выполнения.
Нас же интересует именно Thread Pool в .Net. Его использование зачастую считается хорошим тоном, а некоторые методы CLR используют его неявно для своих целей (в т.ч. System.Net.Sockets и делегаты в асинхронных вызовах). Недостаток у него я усматриваю лишь один: универсальность. Все люди разные, но на заводах шьют одежду по общим меркам и размерам. Каждая книга уникальна, но для нее определяют категорию и ставят на полку с другими, похожими..

Так и наш Thread Pool пытается решать все задачи единым подходом: задача поступает в очередь и через какое-то время выбирается один из свободных потоков для ее выполнения. Если свободных потоков нет, то с некоторой периодичностью создаются новые.
Проблема в том, что нас не всегда может устроить тот факт, что все задачи равноправны и все выполнятся "как можно быстрее". Нас изредка волнует, насколько затормозят работу БД 25 задач извлечения данных, выполненных одновременно, нам хочется указать, что такая-то задача может быть выполнена после всех остальных. Конечно, в 95% случаев это не имеет значения, но когда мы говорим о time-critical приложениях, таких как сервера онлайн-игр, это надо учитывать. Те же 25 обращений к БД эффективны лишь в том случае, если на сервере БД есть 25 процессоров, способных выполнить эти запросы, иначе задачи либо поступят там в очередь выполнения, либо будут выполняться в 25 разных потоках, что может привести к увеличению накладных расходов на блокировки, совместное использование жесткого диска и пр. Практика показывает, что 25 таких задач почти всегда быстрее выполнятся последовательно, чем параллельно.

Посмотрим в сторону кастомизабельности Thread Pool. Он позволяет указывать количество потоков (SetMaxThreads/SetMinThreads), а также сам рассчитывает нужное кол-во потоков исходя из количества процессоров:

There is one thread pool per process. The thread pool has a default size of 25 worker threads per available processor, and 1000 I/O completion thread.


Количество потоков дет нам возможность управления над выполнением: совсем "тяжелые" задачи есть смысл выполнять в 2-3 потока, не критичные по времени - в одном потоке, а "легкие", в которых точно нет блокировок, правильно выполнять в таком же количестве потоков, сколько и процессоров в системе. К сожалению, это все было бы возможно, если бы можно было использовать несколько пулов с разными параметрами. Самое начало цитаты не оставляет нам выбора - надо придумывать что-то собственное.
Тут есть смысл посмотреть в сторону приятнейшей технологии IOCP: Input Output Completion Ports. Как видно из названия, разрабатывалась она для облегчения асинхронного выполнения операций ввода/вывода. Нам же важно, что технология эта позволяет ставить задачи в очередь и выполнять их с помощью указанного количества потоков. А именно, мы сами создаем потоки для IOCP и вызываем в них метод GetQueuedCompletionStatus. На этом выполнение потока блокируется и дальнейшую судьбу его решит уже ядро системы. Ожидающие потоки ставятся в LIFO (Last In First Out) очередь и возобновляются тогда, когда для них есть задачи. Использование именно LIFO позволяет использовать одни потоки чаще, чем остальные, что уменьшает расходы на переключение между ними.

Реализация собственного Thread Pool с IOCP довольно проста. Для операций надо будет извлечь с помощью P/Invoke четыре функции из kernel32.dll:
  • CreateIoCompletionPort
  • CloseHandle
  • GetQueuedCompletionStatus
  • PostQueuedCompletionStatus
При создании собственного пула надо инициализировать объект IOCP через вызов метода CreateIoCompletionPort, потом создать кужное количество потоков, в которых в цикле будет вызываться GetQueuedCompletionStatus и затем использовать PostQueuedCompletionStatus для постановки задачи в очередь. Саму задачу можно передать в виде ссылки на делегат. Делается это без особых сложностей самой CLR (если при описании P/Invoke указать делегат как параметр к PostQueuedCompletionStatus), либо вручную через класс Marshal, или же через GCHandle. ToIntPtr/GCHandle. FromIntPtr.
После окончания использования IOCP надо закрыть методом CloseHandle.

Такой подход дает нам возможность использовать любое количество собственных Thread Pool с любым количеством потоков.