Опубликован: 28.06.2006 | Уровень: специалист | Доступ: платный | ВУЗ: Московский государственный технический университет им. Н.Э. Баумана
Лекция 13:

Разработка параллельных приложений для ОС Windows

< Лекция 12 || Лекция 13: 12 || Лекция 14 >
Аннотация: Рассматривается порт завершения ввода-вывода как механизм эффективного управления пулом потоков; использование стандартного порта завершения ввода-вывода и обсуждается изоляция данных разных потоков и волокон.

Применение потоков и волокон

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

Пулы потоков, порт завершения ввода-вывода

Одна из типовых задач - разработка серверов, обслуживающих асинхронно поступающие запросы. Реализация однопоточного сервера для такой задачи нецелесообразна, во-первых, потому что запросы могут приходить в то время, пока сервер занят выполнением предыдущего, а, во-вторых, потому что такой сервер не сможет эффективно задействовать многопроцессорную систему. Можно, конечно, запускать несколько экземпляров однопоточного сервера - но в этом случае потребуется разработка специального диспетчера, поддерживающего очередь запросов и их распределение по списку доступных экземпляров сервера. Альтернативным решением является разработка многопоточного сервера, создающего по специальному рабочему потоку для обработки каждого запроса. Этот вариант также имеет свои недостатки: создание и удаление потоков требует затрат времени, которые будут иметь место в обработке каждого запроса; сверх того, создание большого числа одновременно выполняющихся потоков приведет к общему снижению производительности (и значительному увеличению времени обработки каждого конкретного запроса).

Эти соображения приводят к решению, получившему название пула потоков (thread pool). Для реализации пула потоков необходимо создание некоторого количества потоков, занятых обслуживанием запросов, и диспетчера с очередью запросов. При наличии необработанных запросов диспетчер находит свободный поток и передает запрос этому потоку; если свободных потоков нет, то диспетчер ожидает освобождения какого-либо из занятых потоков. Такой подход обеспечивает, с одной стороны, малые затраты на управление потоками, с другой - достаточно высокую загрузку процессоров и хорошую масштабируемость приложения.

Обычно имеет смысл ограничивать общее число рабочих потоков либо числом доступных процессоров, либо кратным этому числом. Если потоки занимаются исключительно вычислительной работой, то их число не должно превышать число процессоров. Если потоки, сверх того, проводят некоторое время в состоянии ожидания (например, при выполнении операций ввода-вывода), то число потоков следует увеличить - решение следует принимать, исходя из доли времени, которое поток проводит в состоянии простоя, и из полного времени обработки запроса.

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

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

В Windows такая поддержка реализована в виде порта завершения ввода-вывода. Этот объект ядра берет на себя функциональность, необходимую для организации очереди запросов (используя для этого очередь APC) и списков рабочих потоков, обеспечивая оптимальное управление пулом.

С точки зрения разработчика приложения необходимо:

  • создать порт завершения ввода-вывода;
  • создать пул потоков, ожидающий поступления запросов от этого порта;
  • обеспечить передачу запросов порту.

Порт завершения создается с помощью функции

HANDLE CreateIoCompletionPort(
  HANDLE FileHandle, HANDLE ExistingCompletionPort,
  ULONG_PTR CompletionKey, DWORD NumberOfConcurrentThreads
);

Эта функция выполняет две разных операции - во-первых, она создает новый порт завершения, и, во-вторых, она ассоциирует порт с завершением операций ввода-вывода с заданным файлом. Обе эти операции могут быть выполнены одновременно одним вызовом функции, а могут быть исполнены раздельно. Более того, вторая операция - ассоциирование порта завершения ввода-вывода с реальным файлом - может вообще не выполняться. Две типичных формы применения функции CreateIoCompletionPort:

  1. Создание нового порта завершения ввода-вывода:
    #define CONCURRENTS  4
    
    HANDLE   hCP;
    hCP = CreateIoCompletionPort(
      INVALID_HANDLE_VALUE, NULL, NULL, CONCURRENTS
    );

    При простом создании порта завершения ввода-вывода достаточно указать только максимальное число одновременно работающих потоков (здесь CONCURRENTS, целесообразно ограничивать числом доступных процессоров). Далее, когда будет создаваться пул потоков, в нем можно будет создать и большее число потоков, чем указано при создании порта - система будет отслеживать, чтобы одновременно исполнялся код не более чем указанного числа потоков. При этом поток, перешедший в состояние ожидания, не считается исполняющимся, так что в случае потоков, проводящих часть времени в режиме ожидания, имеет смысл создавать пул потоков большего размера, чем указано при вызове функции CreateIoCompletionPort.

  2. Ассоциирование порта с файлом:
    #define SOME_NUMBER  123
    CreateIoCompletionPort( hFile, hCP, SOME_NUMBER, 0 );

    В этом варианте функция CreateIoCompletionPort не создает нового порта, а возвращает переданный ей описатель уже существующего. Существующий порт завершения ввода-вывода можно связать с несколькими различными файлами одновременно; при этом процедура, обслуживающая завершение ввода-вывода, сможет различать, операция с каким именно файлом поставила в очередь данный запрос, с помощью параметра CompletionKey (здесь SOME_NUMBER ), назначаемого разработчиком. Созданный порт можно не ассоциировать ни с одним файлом - тогда с помощью функции PostQueuedCompletionStatus надо будет помещать в очередь порта запросы, имитирующие завершение ввода-вывода.

Рассмотрим небольшой пример (проверка ошибок для упрощения пропущена):

#include <process.h>
#define _WIN32_WINNT 0x0500
#include <windows.h>

#define MAXQUERIES	 15
#define CONCURENTS	 3
#define POOLSIZE         5

unsigned _ _stdcall PoolProc( void *arg );

int main( void )
{
  int     i;
  HANDLE  hcport, hthread[ POOLSIZE ];
  DWORD   temp;
  /* создаем порт завершения ввода-вывода */
  hcport = CreateIoCompletionPort(
    INVALID_HANDLE_VALUE, NULL, NULL, CONCURENTS
  );

После создания порта надо создать пул потоков. Число потоков в пуле обычно превышает число одновременно работающих потоков, задаваемое при создании порта:

/* создаем пул потоков */
for ( i = 0; i < POOLSIZE; i++ ) {
  hthread[i] = (HANDLE)_beginthreadex(
    NULL,0,PoolProc,(void*)hcport,0,(unsigned*)&temp
  );
}

Если созданный порт ассоциирован с одним или несколькими файлами, то после завершения асинхронных операций ввода-вывода в очереди порта будут размещаться асинхронные запросы, которые система будет направлять для обработки потокам из пула. Однако порт завершения ввода-вывода можно и не связывать с файлами - тогда для размещения запроса можно воспользоваться функцией PostQueuedCompletionStatus, которая размещает запросы в очереди без выполнения реальных операций ввода-вывода.

/* посылаем несколько запросов в порт */
for ( i = 0; i < MAXQUERIES; i++ ) {
  PostQueuedCompletionStatus( hcport, 1, i, NULL );
  Sleep( 60 );
}

Функция помещает в очередь запросов информацию о "как будто" выполненной операции ввода-вывода, полностью повторяя аргументы - такие как размер переданного блока данных, ключ завершения и указатель на структуру OVERLAPPED, содержащую сведения об операции. Мы можем передавать вместо этих значений произвольные данные. В данном примере, скажем, принято, что значение ключа завершения -1 совместно с длиной переданного блока 0 означает необходимость завершить поток:

/* для завершения работы посылаем специальные запросы */
for ( i = 0; i < POOLSIZE; i++ ) {
  PostQueuedCompletionStatus(hcport,0, (ULONG_PTR)-1,NULL);
}
/* дожидаемся завершения всех потоков пула 
     и закрываем описатели */
WaitForMultipleObjects( POOLSIZE, hthread, TRUE, INFINITE );
for ( i = 0; i < POOLSIZE; i++ ) CloseHandle( hthread[i] );
CloseHandle( hcport );
return 0;
}

В общем виде поток в пуле реализует цикл с выбором запросов из очереди с помощью функции GetQueuedCompletionStatus. Следует внимательно ознакомиться с описанием этой функции, чтобы грамотно обрабатывать возможные ошибочные ситуации и предусмотреть при необходимости завершение работы потока.

В данном примере поток в течении 0.3 секунды просто ждет, то есть не исполняется, и порт завершения может передать запросы всем потокам пула, хотя их количество превышает максимальное число одновременно работающих потоков, указанное при создании порта:

unsigned _ _stdcall PoolProc( void *arg )
{
  DWORD      	size;
  ULONG_PTR   	key;
  LPOVERLAPPED 	lpov; 
  while (
    GetQueuedCompletionStatus(
      (HANDLE)arg, &size, &key, &lpov, INFINITE
  )) {
    /* проверяем условия завершения цикла */
    if ( !size && key == (ULONG_PTR)-1 ) break;
    Sleep( 300 );
  }
  return 0L;
}

Рассмотренный механизм управления пулом потоков весьма эффективен, однако требует некоторого объема ручной работы по созданию порта, по созданию пула потоков и по управлению этими потоками.

В современных реализациях Windows предусмотрена возможность автоматического создания и управления пулом потоков с помощью функции

BOOL QueueUserWorkItem(
  LPTHREAD_START_ROUTINE QueryFunction,
  PVOID pContext, ULONG Flags
);

Эта функция при необходимости создает пул потоков (число потоков в пуле определяется числом процессоров), создает порт завершения ввода-вывода и размещает в очереди порта запрос. Если нужный порт и пул потоков уже созданы, то она просто размещает новый запрос в очереди порта. При обработке запроса будет вызвана указанная параметром QueryFunction процедура с аргументом pContext:

DWORD WINAPI QueryFunction( PVOID pContext )
{
  ...
 return 0L;
}

Таким образом, управление пулом потоков сильно упрощается, хотя при этом теряется возможность связывания порта завершения ввода-вывода с конкретными файлами и все запросы должны размещаться в очереди явным вызовом функции QueueUserWorkItem.

Есть и еще одна особенность у такого способа управления пулом - явного механизма задания числа потоков в пуле не предусмотрено. Однако у разработчика есть возможность управлять этим процессом с помощью последнего параметра функции, содержащего специфичные флаги. Так, с помощью флага WT_EXECUTEDEFAULT запрос будет направлен обычному потоку из пула, флаг WT_EXECUTEINIOTHREAD заставит систему обрабатывать запрос в потоке, который находится в состоянии ожидания оповещения (то есть, надо предусмотреть явные вызовы функции типа SleepEx или WaitForMultipleObjectsEx и т.д.). Флаг WT_EXECUTELONGFUNCTION предназначен для случаев, когда обработка запроса может привести к продолжительному ожиданию - тогда система может увеличить число потоков в пуле:

#include <process.h>
#define _WIN32_WINNT 0x0500
#include <windows.h>

#define MAXQUERIES	15
#define POOLSIZE 	3
static LONG    		cnt;
static HANDLE 		hEvent;

DWORD WINAPI QProc( LPVOID lpData )
{
  int  r = InterlockedIncrement( &cnt );
  Sleep( 300 );
  if ( r >= MAXQUERIES ) SetEvent( hEvent );
  return 0L;
}

int main( void )
{
  int  i;
  hEvent = CreateEvent( NULL, TRUE, FALSE, 0 );
  /* в пуле будет не менее POOLSIZE потоков */
  for ( i = 0; i < POOLSIZE; i++ ) {
    QueueUserWorkItem( QProc, NULL, WT_EXECUTELONGFUNCTION );
    Sleep( 60 );
  }
  /* остальные запросы будут распределяться между потоками 
		    пула,даже если их больше, чем число процессоров */
  for ( ; i < MAXQUERIES; i++ ) {
    QueueUserWorkItem( QProc, NULL, WT_EXECUTEDEFAULT );
    Sleep( 60 );
  }
  /* со временем система может уменьшить число потоков пула */
  /* дожидаемся обработки последнего запроса */
  WaitForSingleObject( hEvent, INFINITE );
  CloseHandle( hEvent );
  return 0;
}

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

< Лекция 12 || Лекция 13: 12 || Лекция 14 >
Анастасия Булинкова
Анастасия Булинкова
Рабочим названием платформы .NET было
Bogdan Drumov
Bogdan Drumov
Молдова, Республика
Azamat Nurmanbetov
Azamat Nurmanbetov
Киргизия, Bishkek