Смекни!
smekni.com

Эффективная многопоточность (стр. 1 из 5)

Алексей Ширшов

Введение

Итак, снова многопоточность. Вы скажете, какая избитая тема, уж сколько можно про это писать! Да, написано про нее немало. Практически каждый программист, который с нею сталкивался (то есть хоть раз в жизни вызвал функцию CreateThread), может заявить, что он про нее знает все или почти все. Но это глубокое заблуждение. Создание эффективных многопоточных серверов (а делать многопоточного клиента особого смысла нет) – дело сложное и требующее хороших знаний системных механизмов: многопоточности, синхронизации, асинхронного ввода/вывода и много другого. В этой статье я коснусь темы организации пула потоков для эффективной обработки клиентских запросов.

Зачем это нужно

Зачем организовывать пул потоков? Вопрос очень широко распространен, и на него давно существует ответ. Для тех, кто этот ответ знает, данный раздел не будет чем-то новым, так что можете его пропускать.

При приходе клиентского запроса у сервера имеется несколько вариантов действий:

Обрабатывать все запросы в одном потоке;

Обрабатывать каждый запрос в отдельном потоке;

Организовать пул потоков.

Рассмотрим каждый из сценариев.

Обработка всех запросов в одном потоке

Сразу понятно, что это решение подходит только для очень ограниченного числа случаев, в которых количество клиентов невелико, и обращаются они к серверу не часто. Эта самая простая схема работы: минимум потоков, минимум ресурсов, и не нужно ничего синхронизировать. Главное, что нужно сделать – построить очередь входящих запросов, чтобы они не терялись при последовательной обработке. Это несложно, к тому же можно взять уже готовые решения: например, СОМ-сервер STA singleton.

Обработка каждого запроса в отдельном потоке

Это, пожалуй, самая популярная у разработчиков схема. В ней для каждого клиентского запроса создается отдельный поток. Решение это простое и для многих случаев удовлетворительное, так как при этом нужно заботиться, по большому счету, только о синхронизации общих переменных потоков (а их может и не быть). Такая схема работает следующим образом: первичный поток приложения прослушивает клиентские запросы и при поступлении каждого создает новый поток, передавая ему клиентский пакет (данные или команду). Созданный поток выполняет соответствующую обработку, передает результаты обратно клиенту, или же помещает их в БД (или еще куда-нибудь), и завершает свое существование.

Давайте задумаемся, что произойдет, если клиентов окажется слишком много. Сервер для каждого из них будет создавать поток, а это, с точки зрения системы, непростая операция, требующая определенного времени и ресурсов. Виртуальное адресное пространство процесса также уменьшается как минимум на принятый по умолчанию для потока размер стека. Все это очень плохо. Сервер тратит время и ресурсы на создание потока, который обрабатывает клиентский запрос всего за доли секунды и затем уничтожается. При этом мы должны учитывать, что физически одновременно выполняться могут только количество потоков, не превышающее числа процессоров на компьютере. На ОС Windows NT/2000 при 100 одновременно запущенных потоках наш сервер будет работать очень неоптимально, что отрицательно скажется на времени обработки запроса.

Основные недостатки такой модели:

частое создание и завершение потоков;

малое время работы потока;

нерегулируемое количество потоков;

в большинстве случаев отсутствие очереди клиентских запросов;

большое количество переключений контекстов рабочих потоков.

Для решения этих проблем и предназначен пул потоков.

Организация пула потоков

Что такое пул потоков? В жизни мы очень часто встречаемся с организацией пула. Например, когда вы идете в столовую, вы встречаетесь с пулом подносов. Да-да, не смейтесь. Подносы организованы в пул (попробуйте объяснить это поварам :) ); клиентов может быть намного меньше, чем подносов, и наоборот. Когда подносов много, они лежат без дела, когда подносов мало, клиенты ждут, пока они освободятся. Число подносов, то есть размер пула, заранее определяется так, чтобы в большинстве случаев клиенты не ждали подносов. Однако случаются часы пик, когда клиентов очень много. Просто нереально выделить отдельный поднос каждому клиенту, да и не нужно это. Клиент все равно будет стоять в очереди к кассе, так что траты на подносы не принесут реальных выгод. Это, конечно, очень далекая и несовершенная аналогия, но она показывает, что в природе и жизни пул чего-либо очень часто используется как наиэффективнейшая схема обслуживания запросов.

Рассмотрим механизм работы пула потоков. Имеется главный поток приложения, прослушивающий клиентские запросы. Пул потоков создается заранее или при поступлении первого запроса. Минимальный размер пула обычно выбирается равным 1, однако это непринципиально. При поступлении запроса главный поток выбирает поток из пула и передает ему запрос. Если количество потоков в пуле достигло максимума, запрос помещается в очередь. Если количество потоков меньше максимального, и все они заняты обработкой, создается новый поток, который получает клиентский пакет на обработку. Если количество потоков равно максимальному и все потоки занимаются обработкой, то есть активны, пакет ставится в очередь и ждет освобождения одного из потоков. Алгоритмы добавления потоков в пул и определения оптимального размера пула сильно зависят от решаемой задачи. Более подробно об этом будет сказано позже.

При использовании RPC-транспорта (в случае с СОМ-серверами) о пуле потоков заботиться не нужно. СОМ-сервер MTA singleton – лучшее решение для СОМ в том смысле, что ничего не нужно делать по поводу организации пула потоков. Система (точнее СОМ-runtime) все делает сама. Однако, если вы используете чистый RPC, вам придется все организовывать самому.

Примитивы операционной системы

Сразу оговорюсь, что в качестве операционной системы я буду рассматривать Windows NT версии 3.1 и выше. Для функций, которые появились позже, версия ОС будет оговариваться отдельно. Линейка Windows 9x не предоставляет никаких средств для организации пула потоков.

В операционной системе есть три механизма организации очереди запросов (очередь запросов – неотъемлемая часть пула): DPC – deferred procedure call (отложенный вызов процедуры), APC – asynchronous procedure call (асинхронный вызов процедуры) и объект ядра queue (очередь), которая доступна приложениям пользовательского режима (user mode) в виде более сложного объекта "порт завершения ввода/вывода". DPC используется только в режиме ядра (kernel mode) в основном драйверами устройств для более эффективной обработки запросов ввода/вывода. DPC мы рассматривать не будем, так как эта тема больше касается программирования драйверов устройств, а мы собираемся писать прикладную программу пользовательского режима. APC, в отличии от DPC, всегда выполняется в контексте какого либо потока (с каждым потоком ассоциирована своя очередь APC-запросов) и может генерировать страничные ошибки (page faults), ожидать перехода объекта ядра в сигнальное состояние, и так далее.

ПРИМЕЧАНИЕА почему функции DPC не могут генерировать страничные ошибки? Дело в том, что DPC и APC ставятся в очередь системой с помощью программного прерывания и обрабатываются на определенном уровне прерываний IRQL – interrupt request level. IRQL DPC совпадает с IRQL dispatch, на котором обрабатываются страничные ошибки (он даже называется DPC/dispatch, чтобы отразить это). Как только система поднимает текущий уровень до DPC/dispatch, все прерывания с меньшим или равным уровнем маскируются (блокируются). После обработки DPC система понижает уровень и, если в очереди находиться еще один DPC-запрос, вновь генерируется программное прерывание. Если при обработке DPC-запроса случится обращение к странице памяти, не находящейся в физической памяти, система не сможет «подкачать» эту страницу с диска. Уровень прерывания IRQL APC ниже DPC/dispatch, так что APC могут свободно наслаждаться всеми прелестями виртуального адресного пространства процесса.

APC бывают двух видов: режима ядра и пользовательского режима. APC режима ядра отличается от APC пользовательского режима тем, что система может прервать работу потока для вызова процедуры без его ведома, тогда как для исполнения APC пользовательского режима поток должен находится в специальном «тревожном» (alertable) ожидании, как бы давая согласие на исполнение процедуры. Объект "очередь" и его производный объект "порт завершения ввода/вывода" специально предназначены для организации пула и, кроме очереди запросов, могут управлять ассоциированными с ними потоками. Давайте рассмотрим APC пользовательского режима и порт завершения ввода/вывода более подробно.

APC пользовательского режима

Этот механизм можно использовать, если нужно выполнить какую-либо операцию (функцию) в контексте определенного потока. Для выполнения функции поток должен «дать согласие», перейдя в состояние тревожного ожидания (alertable wait state). Если поток находится в таком состоянии, то, как только мы поставим в очередь APC-запрос с указанием адреса функции и произвольного параметра для нее, поток перейдет к выполнению данной функции, после чего выйдет из состояния ожидания. APC пользовательского режима могут использовать функции ReadFileEx, WriteFileEx, а также SetWaitableTimer, о которой мы поговорим отдельно. Функции ReadFileEx и WriteFileEx предназначены специально для асинхронных операций – для них вы обязаны открывать файл (файл в самом общем смысле) в асинхронном режиме, указывая флаг FILE_FLAG_OVERLAPPED, а также для каждой операции создавать структуру OVERLAPPED. В качестве последнего параметра обе функции принимают адрес специальной функции завершения – FileIOCompletionRoutine. После завершения асинхронной операции, если поток находится в тревожном ожидании, эта функция будет вызвана с помощью механизма APC. В тревожное ожидание поток может перейти с помощью «расширенных» функций ожидания, которые оканчиваются на Ex. Это SleepEx, WaitForSingleObjectEx, WaitForMultipleObjectsEx и другие. Для того чтобы вручную поместить APC-запрос в очередь потока, нужно воспользоваться функцией QueueUserAPC. Вот ее прототип: