Intro
В повседневной хакерской жизни часто случается необходимость включить в свой софт какой-нибудь быстрый сервер - будь то прокси, ftp, mail или master-server управляющий ботами... а может вам просто необходим сврех быстрый эхо сервер (чем мы сегодня и будем заниматься =)). Так или иначе если вы хотите добиться от Winsock максимальных скоростей при большом количестве подключений- то этот туториал для вас ;)

Предпологается что читатель знаком с концепцией Windows sockets.

Main theme
Итак данный туториал большей частью посвящён портам завершения. Естественно что эта техника "глобальна" в Windows и может применяться не только к сетевым операциям - но сегодня мы говорим только о серверах. Для начала обратимся к описанию функции CreateIoCompletionPort:

HANDLE CreateIoCompletionPort (
  HANDLE FileHandle,              // file handle to associate with 
                                  // the I/O completion port
  HANDLE ExistingCompletionPort,  // handle to the I/O completion port
  DWORD CompletionKey,            // per-file completion key for I/O 
                                  // completion packets
  DWORD NumberOfConcurrentThreads // number of threads allowed to 
                                  // execute concurrently
);
С помощью данной функции мы и создадим порт завершения. Пока что наиболее важный для нас параметр NumberOfConcurrentThreads - определяет число потоков, которые могут одновременно выполняться на порту. Для достижения наибольшей производительности каждый порт должен обслуживаться только одним потоком на процессор, для избежания переключения контекстов. Если задать значение 0 мы получим число потоков, равное числу процессоров, те создавать порт завершения мы будем таким образом:
CompletionPort = CreateIoCompletionPort (INVALID_HANDLE_VALUE, NULL, 0, 0);
После создания порта завершения нам необходимо создать несколько рабочих потоков для обслуживания порта. Вот тут можно остановиться и предаться теоретическим измышлениям - количество потоков будет зависеть только от вашей программы - параметр NumberOfConcurrentThreads и реальное количество рабочих потоков могут (и в реальных условиях скорее всего будут) отличаться друг от друга.
Остановимся подробнее на параметре NumberOfConcurrentThreads - он разрешает только определённому количеству потоков работать с портом завершения одновременно (даже если вы создадите гораздо больше потоков*). Однако некоторые потоки могут приостанавливать своё выполнение (например proxy сервер - ждёт ответа с помощью WaitForSingleObject) - и тогда в это время другой поток сможет работать с портом завершения вместо него. Те если ваша программа будет блокировать поток - тогда лучше создать рабочих потоков несколько больше чем NumberOfConcurrentThreads, с другой стороны - если в вашей программе не будет блокировки - тогда не стоит создавать лишних потоков.
Примечание: * - иногда в силу каких-то внутренних глюков число потоков может превысить значение NumberOfConcurrentThread на какой-то маленький промежуток бесконечности, но потом всё возвращается на круги своя - так что по большому счёту можно считать NumberOfConcurrentThread константой.
Когда мы наконец определимся с количеством потоков необходимым для нормальной работы программы необходимо связать между собой сокеты и порты завершения - это делается повторным вызовом CreateIoCompletionPort, при этом параметры принимают следующие значения:
  • FileHandle - сокет, который необходимо связать с портом завершения
  • ExistingCompletionPort - непосредственно порт завершения, который мы создали выше первым вызовом CompletionKey
  • CompletionKey - укзатель на какие-нибудь данные (в нём мы будем хранить структуру, которая отвечает за состояние текущего соединения) - per-handle data - данные описателя.
Учитывая всё выше сказанное каркас нашего простого сервера будет выглядеть таким образом:
//рабочий поток порта завершения:
DWORD WINAPI ServerThread (LPVOID CompletionPortID){
	
}


		........
// somewhere inside main function //

//создаём порт завершения:
CompletionPort = CreateIoCompletionPort (INVALID_HANDLE_VALUE, NULL, 0, 0);

//создание рабочих потоков  - мы создаём два потока на процессор: 
GetSystemInfo (&SysInfo);

for (i = 0; i < SysInfo.dwNumberOfProcessors * 2; i ++){
	// создаём рабочий поток - serverThread, в качестве параметра передаём ей порт завершения
	Thread = CreateThread (NULL, 0, ServerThread, CompletionPort, 0, &ThreadId);
	CloseHandle (Thread);
}

//создание сокета:
Socket = WSASocket (AF_INET, SOCK_STREAM, 0, NULL, 0, WSA_FLAG_OVERLAPPED);

server.sin_family = AF_INET;
server.sin_addr.s_addr = htonl (INADDR_ANY);
server.sin_port = htons (31337);

bind (Socket, (PSOCKADDR) &server, sizeof (server));

listen (Socket, 5);

while (TRUE){
	//приём соединения
	Accept = WSAAccept (Socket, NULL, NULL, NULL, 0);

	// Заполнение PerHandleData
	.....

	// привязка сокета к порту завершения - мы передаём какие-нибудь данные о соединение через PerHandleData:
	CreateIoCompletionPort ((HANDLE)Accept, CompletionPort, (DWORD)PerHandleData, 0);

	//начинаем обрабатывать ввод-вывод на сокете - отправляем что-нить или получаем инфу:
	// WSARecv  / WSASend

	....
}
Учтите что функции WSASend/WSARecv будут возвращать ошибку SOCKET_ERROR со статусом WSA_IO_PENDING, которую необходимо игнорировать. Теперь пришло время обратится к реализации функции ServerThread - рабочего потока порта завершения.

Так как модель портов завершения использует перекрытый ввод-вывод то все функции WinSock будут завершаться сразу после вызова. Наша задача извлечь результаты из структуры OVERLAPPED. Для этого мы должны поставить в очередь ожидания на порту завершения рабочие потоки. Достигается это с использованием функции GetQueuedCompletionStatus:

BOOL GetQueuedCompletionStatus(
  HANDLE CompletionPort,       // the I/O completion port of interest
  LPDWORD lpNumberOfBytesTransferred,
                               // to receive number of bytes 
                               // transferred during I/O
  LPDWORD lpCompletionKey,     // to receive file's completion key
  LPOVERLAPPED *lpOverlapped,  // to receive pointer to OVERLAPPED 
                               // structure
  DWORD dwMilliseconds         // optional timeout value
);
Параметры этой функции следующие:
  • CompletionPort - порт завершения
  • lpNumberOfBytesTransferred - число байт переданных во время ввода-вывода через функции WSASend/WSARecv
  • lpCompletionKey - здесь нам вернутся данные описателя сокета, которые мы задали при привязке сокета к порту заврешния
  • lpOverlapped - этот параметр позволяет получить данные операции ввода-вывода - per I/O operation data
  • dwMilliseconds - таймаут операции в милисекундах (INFINITE - бесконечное ожидание)
Обратим внимание на параметр lpOverlapped - он содержит струтуру OVERLAPPED, за которой могут следовать данные операции. Обычно он определяется таким образом:
typedef struct {
	OVERLAPPED Overlapped;

	//любые полезные нам данные:
	WSABUF DataBuf;
	CHAR Buffer[DATA_BUFSIZE];
	DWORD BytesSend;
	DWORD BytesRecv;
	DWORD OperationType;
	DWORD TotalBytes;

	.....

} PER_IO_OPERATION_DATA;
После обязательной структуры OVERLAPPED мы размещаем буффер для хранения данных данных, количество переданных/принятых байт, тип операции - отправка/приём, адрес и порт клиента... любой параметр который может быть полезен вам, исходя из требований к серверу.

Таким образом при вызове WinSock функций мы должны передавать нашу структуру. Например таким образом:

PER_IO_OPERATION_DATA PerIoData;
...
WSARecv (...., (OVERLAPPED *)&PerIoData);
И наконец поговорим о том как коректно завершить работу порта завершения - главное не освобождать память со структурой OVERLAPPED, пока выполняется какая-нибудь операция на сокете. После того как вы закроете все сокеты - необходимо завершить все рабочие потоки порта завершения. Для этого воспользуемся функцией PostQueuedCompletionStatus - которая отправит потоку пакет, заставляющий прекратить работу:
BOOL PostQueuedCompletionStatus(
  HANDLE CompletionPort,            // handle to an I/O completion port
  DWORD dwNumberOfBytesTransferred, // return via GetQueuedCompletionStatus
  DWORD dwCompletionKey,            // return via GetQueuedCompletionStatus 
  LPOVERLAPPED lpOverlapped         // return via GetQueuedCompletionStatus
);
Параметр CompletionPort - задаёт порт завершения, а остальные параметры задают значения, которые поток получит из функции GetQueuedCompletionStatus - те мы можем задать непосредственно тип операции для завершения работы - когда поток получит это значение и интерпретирует его соответвующим образом мы можем освободить какие-то ресурсы, сделать какую-то работу, Etc... После закрытия всех рабочих потоков надо закрыть порт завершения через CloseHandle и завершить программу.

Суммировав все полученные выше знания давайте теперь напишем эхо сервер:

// ech0.cpp : Defines the entry point for the console application.
//


/////////////////////////////////////////////////////
// *Пример* простого эхо сервера, 
//  использующего модель портов завершения
///////////////////////////////////////////////////

#include <winsock2.h>
#include <windows.h>
#include <stdio.h>

#define PORT			31337 
#define DATA_BUFSIZE	1024

#define WELCOME_MSG		"Hello, welcome to MaZaFaKa.Ru ech0 server!\r\n"


typedef struct{
   OVERLAPPED Overlapped;

   WSABUF DataBuf;
   CHAR Buffer[DATA_BUFSIZE];
   DWORD BytesSend;
   DWORD BytesRecv;
   DWORD TotalBytes;
   SOCKADDR_IN client;
} PER_IO_OPERATION_DATA, * LPPER_IO_OPERATION_DATA;


typedef struct{
   SOCKET Socket;
} PER_HANDLE_DATA, * LPPER_HANDLE_DATA;


DWORD WINAPI ServerThread (LPVOID CompletionPortID);

int main (int argc, char* argv[])
{
   SOCKADDR_IN server;
   SOCKADDR_IN client;

   SOCKET Socket;
   SOCKET Accept;

   HANDLE CompletionPort;
   SYSTEM_INFO SysInfo;
   HANDLE Thread;

   LPPER_HANDLE_DATA PerHandleData;
   LPPER_IO_OPERATION_DATA PerIoData;

   int i;

   DWORD SendBytes;
   DWORD Flags;
   DWORD ThreadID;
   WSADATA wsaData;
   DWORD Ret;

   // инициализируем WinSock:

   if ((Ret = WSAStartup (0x0202, &wsaData)) != 0){
      printf ("WSAStartup failed with error %d\n", Ret);
      return 1;
   }

   // Создаём порт завершения:

   if ((CompletionPort = CreateIoCompletionPort (INVALID_HANDLE_VALUE, NULL, 0, 0)) == NULL){
      printf ( "CreateIoCompletionPort failed with error: %d\n", GetLastError ());
      return 1;
   }

   // Получаем информацию о системы:

   GetSystemInfo (&SysInfo);

   // создаём два потока на процессор:.

   for (i = 0; i < SysInfo.dwNumberOfProcessors * 2; i++){
      // создаём рабочий поток, в качестве параметра передаём ей порт завершения

      if ((Thread = CreateThread (NULL, 0, ServerThread, CompletionPort, 0, &ThreadID)) == NULL){
         printf ("CreateThread() failed with error %d\n", GetLastError ());
         return 1;
      }

      CloseHandle(Thread);
   }

   // Создаём слушающий сокет:

   if ((Socket = WSASocket (AF_INET, SOCK_STREAM, 0, NULL, 0,WSA_FLAG_OVERLAPPED)) == INVALID_SOCKET){
      printf ("WSASocket() failed with error %d\n", WSAGetLastError ());
      return 1;
   } 

   server.sin_family = AF_INET;
   server.sin_addr.s_addr = htonl (INADDR_ANY);
   server.sin_port = htons (PORT);

   if (bind (Socket, (PSOCKADDR) &server, sizeof(server)) == SOCKET_ERROR){
      printf ("bind() failed with error %d\n", WSAGetLastError ());
      return 1;
   }

   if (listen (Socket, 5) == SOCKET_ERROR){
      printf ("listen() failed with error %d\n", WSAGetLastError ());
      return 1;
   }

   // принимаем соединения и передаём их порту завершения:

   while (TRUE){

	  // принимаем соединение:
	  
      if ((Accept = WSAAccept (Socket, (PSOCKADDR)&client, NULL, NULL, 0)) == SOCKET_ERROR){
         printf ("WSAAccept() failed with error %d\n", WSAGetLastError ());
         continue;
      }

      
	  // Выделяем память под структуру, которая будет хранить информацию о сокете:

      if ((PerHandleData = (LPPER_HANDLE_DATA) GlobalAlloc (GPTR, sizeof (PER_HANDLE_DATA))) == NULL){
         printf ("GlobalAlloc() failed with error %d\n", GetLastError ());
         return 1;
      }

      printf ("Socket %d connected\n", Accept);
      PerHandleData->Socket = Accept; // сохраняем описатель сокета

	  //привязываем сокет к порту завершения:
      if (CreateIoCompletionPort ((HANDLE) Accept, CompletionPort, (DWORD) PerHandleData, 0) == NULL){
         printf ("CreateIoCompletionPort failed with error %d\n", GetLastError());
         return 1;
      }

      // выделяем память под данные операции ввода вывода:

      if ((PerIoData = (LPPER_IO_OPERATION_DATA) GlobalAlloc (GPTR, sizeof (PER_IO_OPERATION_DATA))) == NULL){
         printf ("GlobalAlloc() failed with error %d\n", GetLastError ());
         return 1;
      }

      ZeroMemory (&(PerIoData->Overlapped), sizeof (OVERLAPPED));

	  // задаём изначальные данные для операции ввода вывода:
      PerIoData->BytesSend = 0;
      PerIoData->BytesRecv = 0;
      PerIoData->DataBuf.len = strlen (WELCOME_MSG);
      PerIoData->DataBuf.buf = WELCOME_MSG;
	  PerIoData->client = client;
	  PerIoData->TotalBytes = 0;

      Flags = 0;

	  // отправляем welcome message
	  // остальные операции будут выполняться в рабочем потоке

      if (WSASend (Accept, &(PerIoData->DataBuf), 1, &SendBytes, 0,&(PerIoData->Overlapped), NULL) == SOCKET_ERROR){
		if (WSAGetLastError() != ERROR_IO_PENDING){
               printf("WSASend() failed with error %d\n", WSAGetLastError());
               return 1;
        }
      }
   }

   return 0;

   
}


// рабочий поток порта завершения:

/*
  в нашем эхо сервере мы не грузимся типом операции - 
  тк надо просто отправлять и принимать символы.
  В более серьёзных программах - рекомендую использовать параметр OperationType 
  в структуре PER_IO_OPERATION_DATA, для того что бы точно понимать, что же
  происходит с сервером. Те ваш поток завершения станет чем-то похож на оконную функцию:

  switch (PerIoData->OperationType){
	case SRV_NEW_CONNECTION:
		// это первое подключение:
		...
	case SRV_DATA_SEND:
		// посылаем данные
		...
	case SRV_DATA_RECV:
		// принимаем данные
		...
	case SRV_DISCONNECT:
		// отсоединение

	.......

  }

*/

DWORD WINAPI ServerThread(LPVOID CompletionPortID){

   HANDLE CompletionPort = (HANDLE) CompletionPortID;
   
   DWORD BytesTransferred;

   LPOVERLAPPED Overlapped;
   LPPER_HANDLE_DATA PerHandleData;
   LPPER_IO_OPERATION_DATA PerIoData;

   DWORD SendBytes, RecvBytes;
   DWORD Flags;
   
   while (TRUE){


	   // ожидание завершения ввода-вывода на любом из сокетов
	   // которые связанны с портом завершения:
		
      if (GetQueuedCompletionStatus (CompletionPort, &BytesTransferred,
         (LPDWORD)&PerHandleData, (LPOVERLAPPED *) &PerIoData, INFINITE) == 0){

         printf ("GetQueuedCompletionStatus failed with error %d\n", GetLastError ());
         return 0;
      }


      // проверяем на ошибки. Если была - значит надо закрыть сокет и очистить память за собой:

      if (BytesTransferred == 0){
		 // тк не было переданно ни одного байта - значит сокет закрыли на той стороне
		 // мы должны сделать то же самое:

         printf ("Closing socket %d\nTotal bytes:%d\n", PerHandleData->Socket, PerIoData->TotalBytes);

		 // закрываем сокет:
         if (closesocket (PerHandleData->Socket) == SOCKET_ERROR){
            printf ("closesocket() failed with error %d\n", WSAGetLastError ());
            return 0;
         }

		 // очищаем память:
         GlobalFree (PerHandleData);
         GlobalFree (PerIoData);

		 // погнали дальше - ждём следующую операцию
         continue;
      }

	  PerIoData->TotalBytes += BytesTransferred;

      // Проверим значение BytesRecv - если оно равно нулю - значит мы получили данные от клиента:

      if (PerIoData->BytesRecv == 0){
		 PerIoData->BytesRecv = BytesTransferred;
         PerIoData->BytesSend = 0;
      }
      else{		 
         PerIoData->BytesSend += BytesTransferred;
      }

	  // мы должны отослать все принятые байты назад:
      if (PerIoData->BytesRecv > PerIoData->BytesSend){

         // Шлём данные через WSASend - тк всё сразу может не отослаться 
		 // необходимо слать до упора.
		 // в нашем случае это вряд ли произойдёт - но тем не менее, не забывайте
		 // что за один вызов WSASend все данные могут и не отправится !
         ZeroMemory (&(PerIoData->Overlapped), sizeof (OVERLAPPED));

         PerIoData->DataBuf.buf = PerIoData->Buffer + PerIoData->BytesSend;
         PerIoData->DataBuf.len = PerIoData->BytesRecv - PerIoData->BytesSend;

         if (WSASend (PerHandleData->Socket, &(PerIoData->DataBuf), 1, &SendBytes, 0,
            &(PerIoData->Overlapped), NULL) == SOCKET_ERROR){

				if (WSAGetLastError () != ERROR_IO_PENDING){
				    printf ("WSASend() failed with error %d\n", WSAGetLastError());
					return 0;
				}
		}
      }
      else{
         PerIoData->BytesRecv = 0;

         // ожидаем ещё данные от пользователя:

         Flags = 0;
         ZeroMemory(&(PerIoData->Overlapped), sizeof (OVERLAPPED));

         PerIoData->DataBuf.len = DATA_BUFSIZE;
         PerIoData->DataBuf.buf = PerIoData->Buffer;

         if (WSARecv (PerHandleData->Socket, &(PerIoData->DataBuf), 1, &RecvBytes, &Flags,
            &(PerIoData->Overlapped), NULL) == SOCKET_ERROR){
			 if (WSAGetLastError () != ERROR_IO_PENDING){
               printf ("WSARecv() failed with error %d\n", WSAGetLastError ());
               return 0;
            }
         }
      }
   }
}
Outro

В завершении хочется сказать ещё несколько слов о том каким образом возможно увеличить скорость работы своей сетевой программы в общем случае (применимо ко всем моделям ввода-вывода). Во первых это конечно же функция TransmitFile:

BOOL TransmitFile(
  SOCKET hSocket,                             
  HANDLE hFile,                               
  DWORD nNumberOfBytesToWrite,                
  DWORD nNumberOfBytesPerSend,                
  LPOVERLAPPED lpOverlapped,                  
  LPTRANSMIT_FILE_BUFFERS lpTransmitBuffers,  
  DWORD dwFlags                               
);
Данная функция позволяет быстро передать по сети данные из файла - если программа использует связку WSASend/ReadFile - тогда происходит многократное переключение между режимами ядра и пользовательским - функция TransmitFile позволяет избежать этих переключения за счёт того что весь процесс чтения и отправки происходит целиком в режиме ядра. Параметры функции:
  • hSocket - подключённый сокет, по которому будет передан файл
  • hFile - описатель открытого файла
  • nNumberOfBytesToWrite - количество передаваемых байт из файла (0 - файл целиком)
  • nNumberOfBytesPerSend - количество байт в одном пакете (0 - используется стандартный режим отправки) - например если задать размер по 1024 - тогда в каждом пакете будет по 1 кб данных из файла
  • lpOverlapped - структура OVERLAPPED для перекрытого ввода-вывода
  • lpTransmitBuffers - представляет собой структуру, содержащую данные, которые нужно отправить до и после передачи файла:
    typedef struct _TRANSMIT_FILE_BUFFERS { 
        PVOID Head; 
        DWORD HeadLength; 
        PVOID Tail; 
        DWORD TailLength; 
    } TRANSMIT_FILE_BUFFERS;
    • Head - укзатель на данные длинной HeadLength которые должны передаться до
    • Tail - указатель на данные, длинной TailLength, которые должны передаться после
  • dwFlags - устанавливает режим работы. Возможные значения:
    • TF_DISCONNECT - сокет закрывается после передачи данных
    • TF_REUSE_SOCKET - позвляет повторно использовать в функции AcceptEXx описатель сокета в качестве клиентского
    • TF_USE_DEFAULT_WORKER и TF_USE_SYSTEM_THREAD - указывают что передача будет идти в контексте стандартного системного процесса. Полезно при передаче больших файлов.
    • TF_USE_KERNEL_APC - указывае что передача должна выполняться ядром при помощи асинхронных вызовов процедур (Asynchronous Procedure Calls - APC). Это увеличивает производительность, если для считывания файла в кэш требуется одна операция чтения.
    • TF_WRITE_BEHIND - вызов функции может завершиться, не получив подтверждений о приёме данных от удалённой системы.
Другая функция, на которую стоит обратить внимание AcceptEx:
BOOL AcceptEx ( 
  SOCKET sListenSocket,      
  SOCKET sAcceptSocket,      
  PVOID lpOutputBuffer,      
  DWORD dwReceiveDataLength,  
  DWORD dwLocalAddressLength,  
  DWORD dwRemoteAddressLength,  
  LPDWORD lpdwBytesReceived,  
  LPOVERLAPPED lpOverlapped  
);
  • sListenSocket - слушающий сокет
  • sAcceptSocket - сокет, принимающий входящее соединение, в отличии от функции accept его необходимо создать функцией socket
  • lpOutputBuffer - буффер, принимающий три блока данных: локальный адрес сервера, удалённый адрес клиента, и первый блок данных на новом соединение
  • dwReceiveDataLength - указывает количество байт в lpOutputBuffer, используемых для приёма данных (если равен 0 - то при установлении соединения данные не принимаются)
  • dwLocalAddressLength, dwRemoteAddressLength - размеры соответвенно локального и удалённого адресов при принятии соединения.
  • lpdwBytesReceived - содержит количество принятых байт данных
  • lpOverlapped -структура OVERLAPPED для использования перекрытого ввода-вывода
Кроме того существует дополнительная функция, позволяющая выделить локальный и удалённый адреса из lpOutputBuffer:
VOID GetAcceptExSockaddrs ( 
  PVOID lpOutputBuffer,       
  DWORD dwReceiveDataLength,  
  DWORD dwLocalAddressLength,  
  DWORD dwRemoteAddressLength,  
  LPSOCKADDR *LocalSockaddr,  
  LPINT LocalSockaddrLength,  
  LPSOCKADDR *RemoteSockaddr,  
  LPINT RemoteSockaddrLength  
);
Функцию AcceptEx логично использовать только если сервер будет обрабатывать небольшое количество запросов ввода-вывода при одном соединении - например это Web или прокси сервер. Если же требуется многократная передача различных данных, тогда увеличения производительности естесвенно не произойдёт.

Кроме того в Windows XP/2003 появился ещё более раширенный набор "быстрых" WinSock функций - типа TransmitPackets, однако учитывая большую распространённость серверов под управлением Win2k переходить целиком на эти функции ещё рановато, хотя присмотреться к ним всё же стоит.

Автор: said /20.02.05/ ©

Mazafaka.Ru - E-Zine - 2005 ©