Версия для печати


Использование сокетов в Delphi. Часть вторая: сокеты Windows
http://www.delphikingdom.com/asp/viewitem.asp?catalogID=1060

Антон Григорьев
дата публикации 01-10-2004 14:52

Использование сокетов в Delphi. Часть вторая: сокеты Windows

Более полный вариант этой статьи вошёл в книгу "О чём не пишут в книгах по Delphi"

Содержание:
  1. Версии Windows Sockets
  2. Устаревшие функции WinSock 1
  3. Информация о протоколе
  4. Новые функции
  5. Асинхронный режим, основанный на сообщениях
  6. Асинхронный режим, основанный на событиях
  7. Перекрытый ввод-вывод
  8. Многоадресная рассылка
  9. Дополнительные функции
  10. Заключение

В предыдущей статье цикла "Использование сокетов в Delphi" мы рассмотрели те методы работы с сокетами, которые восходят ещё к сокетам Беркли. Разработчики библиотеки сокетов для Windows добавили в неё также поддержку новых методов, упрощающих работу с сокетами для приложений, имеющих традиционную для Windows событийно-ориентированную модель. В Windows можно использовать асинхронные сокеты и перекрытый ввод-вывод.

Как и предыдущая, эта статья не претендует на полноту, а предназначена лишь для знакомства с наиболее часто употребляемыми возможностями библиотеки сокетов. По-прежнему рассматриваются только протоколы TCP и UDP. Не будут затронуты такие вопросы как поддержка качества обслуживания, пространства имён, простые сокеты (RAW_SOCK) и SPI (Service Provider Interface). Тем, кто захочет самостоятельно разобраться с данными вопросами, я рекомендую книгу Э. Джонс, Д. Оланд "Программирование в сетях Microsoft Windows" (СПб.: Питер; М: Издательско-торговый дом "Русская редакция", 2002).

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

Версии Windows Sockets

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

Версия Комментарий
1.0 Упоминается только вскользь. Видимо, настолько старая версия, что её поддержка в чистом виде в современных системах отсутствует.
1.1 Основная подверсия первой версии библиотеки. По умолчанию входила во все версии Windows до Windows 95 включительно. Ориентирована на 16-разрядные системы с корпоративной многозадачностью.
2.0 В чистом виде никуда не ставилась. Ориентирована на 32-разрадные системы с вытесняющей многозадачностью. Исключены некоторые устаревшие функции.
2.2 Основная подверсия второй версии библиотеки. Ставится по умолчанию в Windows 98/NT 4/2000, а также, видимо, в более поздних версиях. Для Windows 95 существует возможность обновления Windows Sockets до этой версии

В дальнейшем, если не оговорено иное, под WinSock 1 мы будем подразумевать версию 1.1, под WinSock 2 - версию 2.2.

WinSock 1 в 16-разрядных версиях Windows реализуется библиотекой WinSock.dll, в 32-разрядных - WSock32.dll. WinSock 2 реализуется библиотекой WS2_32.dll, и, кроме того, часть функций вынесена в отдельную библиотеку MSWSock.dll. При этом для сохранения совместимости WS2_32.dll содержит даже те устаревшие функции, которые формально исключены из спецификации WinSock 2. В тех системах, в которых установлена библиотека WinSock 2, WSock32.dll не реализует самостоятельно практически ни одной функции, а просто импортирует их из WS2_32.dll и MSWSock.dll. WSock32.dll требуется только для обратной совместимости, в новых программах использовать эту библиотеку нужды нет.

Как это ни удивительно, но в Delphi даже 7-ой версии отсутствует поддержка WinSock 2. Стандартный модуль WinSock импортирует только функции из WSock32.dll, поэтому программисту доступны только функции WinSock 1. Разумеется, импортировать функции WinSock 2 самостоятельно не составит труда. Более того, в интернете можно найти уже готовые модули, импортирующие их (например, на сайте Алекса Коншина). Тем не менее, чтобы избежать разночтений, мы не будем использовать какой-либо готовый модуль для импорта и примем в этой статье следующее соглашение: если прототип функции приведён только на Паскале, значит, эта функция есть в модуле WinSock. Если же прототип приведён и на C/С++, и на Паскале, значит, функция в WinSock не описана. В этом случае прототип функции на С/С++ берётся из MSDN'а, а перевод на Паскаль - мой вариант. В некоторых случаях возможны несколько вариантов перевода, поэтому не стоит рассматривать мой вариант как истину в последней инстанции. Тем, кто будет самостоятельно импортировать функции из WS2_32.dll, следует помнить, что они имеют модель вызова stdcall (при описании прототипов функций я для краткости буду опускать эту директиву).

WinSock 2 предлагает разработчику Service Provider Interface (SPI), с помощью которого можно добавлять в систему поддержку своих протоколов. Устаревшими объявлены функции, имеющие привязку к конкретным протоколам (например, уже знакомая нам функция Inet_Addr, которая имеет смысл только при использовании протокола IP). Добавлены новые функции, которые призваны унифицировать операции с разными протоколами. Фактически, если использовать WinSock 2, программа может быть написана так, что сможет использовать даже те протоколы, которые не существовали на момент её разработки. Кроме того, добавлена возможность связи асинхронных сокетов с событиями вместо оконных сообщений, а также поддержка перекрытого ввода-вывода (в WinSock 1 он поддерживался только в линии NT и не в полном объёме). Добавлена поддержка качества обслуживания (Quality of Service, QoS - резервирование части пропускной способности сети для нужд конкретного соединения), поддержка портов завершения, поддержка многоадресной рассылки, поддержка регистрации имён. Большая часть этих нововведений требуются для пользовательских программ относительно редко (или вообще не требуются), поэтому мы не будем заострять на них внимания. В статье будут рассмотрены асинхронные сокеты (связанные как с сообщениями, так и с событиями), перекрытый ввод-вывод, методы универсализации работы с протоколами и многоадресная рассылка.

[ К содержанию ]

Устаревшие функции WinSock 1

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

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

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

В очереди сообщений могут находиться сообщения и для того приложения, которое выполняет блокирующий вызов. В этом случае будет снова вызвана оконная процедура, инициировавшая блокирующую операцию. Это напоминает рекурсию, при которой процедура вызывает сама себя: в памяти компьютера будут одновременно две активации этой процедуры. Упрощённо это выглядит так: оконная процедура вызывает блокирующую функцию (например, Accept), а та, в свою очередь, снова вызывает ту же самую оконную процедуру. При этом вторая активация не может выполнять никаких операций с сокетами: они будут завершены с ошибкой WSAEInProgress. Эта ошибка не является фатальной, она указывает, что в данный момент выполняется блокирующая операция, и программа должна подождать её завершения и лишь потом пытаться работать с сокетами (т.е. не раньше, чем первая активация оконной процедуры вновь получит управление). Существует специальная функция WSAIsBlocking, которая возвращает True, если в данный момент выполняется блокирующая операция и работа с сокетами невозможна.

Вторая активация процедуры может прервать блокирующий вызов с помощью функции WSACancelBlockingCall. При этом первая активация получит ошибку WSAECancelled.

Программа может устанавливать свою процедуру, которая будет вызываться во время выполнения блокирующей операции. Для этого используются функции WSASetBlockingHook и WSAUnhookBlockingHook.

Данная модель достаточно неудобна, поэтому разработчики WinSock 1 рекомендуют использовать более приспособленную к особенностям Windows модель асинхронных сокетов.

В 32-разрядных версиях WinSock такая модель работы поддерживается в полном объёме, за исключением того, что по умолчанию при блокирующем вызове не вызывается никакая функция. Поэтому, если не использовать WSASetBlockingHook, в 32-разрядном приложении невозможно получить ситуацию, когда операция с сокетом не будет выполнена из-за того, что в этот момент уже выполняется другая операция, и второй активации оконной процедуры из-за блокирующего вызова тоже не будет создано. Отметим, что разные нити могут одновременно выполнять блокирующие операции с сокетами, и это не приведёт к появлению ошибки WSAEInProgress.

Все перечисленные выше функции формально исключены из спецификации WinSock 2, хотя фактически они присутствуют в библиотеке WS2_32.dll и при необходимости могут быть использованы (использование, правда, осложняется тем, что в новых версиях MSDN'а отсутствует их описание). Тем не менее, причин использовать эту неудобную модель в 32-разрядных версиях Windows, видимо, нет.

[ К содержанию ]

Информация о протоколе

В предыдущей статье мы видели, что передача данных через сокет осуществляется одними и теми же функциями независимо от протокола. Но при этом программа должна учитывать, является ли протокол потоковым, дейтаграммным или иным. Кроме того, информация о протоколе требуется для создания сокета и для распределения ролей между клиентом и сервером при установлении соединения. Чтобы работать с любым протоколом, программа должна иметь возможность получить всю эту информацию и выполнить на основе её те или иные действия. Могут также понадобиться такие сведения, как максимальное число сокетов, поддерживаемых провайдером протокола, допустимый диапазон адресов, максимальный размер сообщений для дейтаграммных протоколов и т.д. Для хранения полного описания протокола и его провайдера в WinSock 2 предусмотрена структура WSAPROTOCOL_INFO. Она не описана в модуле WinSock, т.к. в WinSock 1 её нет. Тем, кто захочет использовать эту структуру, придётся самостоятельно добавлять её описание в программу. Как и все структуры Windows API, она реализуется без выравнивания, т.е. для её описания в Delphi требуется конструкция packed record.

В данной статье мы не будем приводить список полей структуры WSAPROTOCOL_INFO - при необходимости эту информацию легко найти в MSDN'е. Здесь мы рассмотрим только функцию WSAEnumProtocols, которая позволяет получить список всех протоколов, провайдеры которых установлены на компьютере. Прототип функции выглядит следующим образом:

int WSAEnumProtocols(
LPINT lpiProtocols,
LPWSAPROTOCOL_INFO lpProtocolBuffer,
	LPDWORD lpdwBufferLength);

function WSAEnumProtocols(
lpiProtocols:PInteger;
	lpProtocolBuffer:PWSAProtocolInfo;
	var BufferLength:DWord):Integer;

В той версии MSDN'а, которой пользуюсь я, в описании этой функции есть небольшая опечатка: тип параметра lpdwBufferLength назван ILPDWORD вместо LPDWORD.

При переводе прототипа функции на Delphi предполагается, что выше объявлена структура TWSAProtocolInfo - аналог WSAPROTOCOL_INFO, а также указатель на эту структуру PWSAProtocolInfo.

Параметр lpiProtocols указывает на первый элемент массива, содержащего список протоколов, информацию о которых нужно получить. Если этот указатель равен nil, возвращается информация обо всех доступных протоколах. Параметр lpProtocolBuffer содержит указатель на начало массива структур типа TWSAProtocolInfo. Программа должна заранее выделить память под этот массив. Параметр BufferLength при вызове должен содержать размер буфера lpProtocolBuffer в байтах (именно размер в байтах, а не количество элементов). После завершения функции сюда помещается минимальный размер буфера, необходимый для размещения информации о всех запрошенных протоколах. Если это значение больше переданного, функция завершается с ошибкой.

Если параметр lpiProtocols не равен нулю, он должен содержать указатель на массив, завершающийся нулём. То есть, если количество протоколов, запрашиваемых программой, равно N, этот массив должен содержать N+1 элемент, и первые N элементов должны содержать номера протоколов, а последний элемент - ноль.

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

К сожалению, полную информацию о том, каким протоколам какие номера соответствуют, в документации найти не удалось. Можно только сказать, что для получения информации о протоколе TCP в массив lpiProtocols необходимо поместить константу IPProto_TCP, о протоколе UDP - константу IPProto_UDP.

Возвращаемое функцией значение равно числу протоколов, информация о которых помещена в массив, если функция выполнена успешно, и Socket_Error, если при её выполнении возникла ошибка. Конкретная ошибка определяется стандартным методом, с помощью WSAGetLastError. Если массив lpProtocolBuffer слишком мал для хранения всей требуемой информации, функция завершается с ошибкой WSAENoBufs.

WinSock 1 содержит аналогичную по возможности функцию EnumProtocols, возвращающую массив структур PROTOCOL_INFO. Эта структура содержит меньше информации о протоколе, чем WSAPROTOCOL_INFO и, в отличие от последней, не используется никакими другими функциями WinSock. Несмотря на то, что функция EnumProtocols и структура PROTOCOL_INFO описаны в первой версии WinSock, модуль WinSock их не импортирует, при необходимости их нужно импортировать самостоятельно. Но функция EnumProtocols считается устаревшей, использовать её в новых приложениях не рекомендуется, поэтому практически всегда, за исключением редких случаев, требующих совместимости с WinSock 1, лучше использовать новую функцию WSAEnumProtocols.

[ К содержанию ]

Новые функции

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

Для создания сокета предназначена функция WSASocket со следующим прототипом:

SOCKET WSASocket(int af,int SockType,int protocol,
LPWSAPROTOCOL_INFO lpProtocolInfo,GROUP g,DWORD dwFlags);

function WSASocket(AF,SockType,Protocol:Integer;
lpProtocolInfo:PWSAProtocolInfo;g:TGroup;dwFlags:DWORD):TSocket;

Первые три параметра совпадают с тремя параметрами функции Socket. Параметр lpProtocolInfo указывает на структуру WSAPROTOCOL_INFO, содержащую информацию о протоколе, для которого создаётся сокет. Если этот указатель равен nil, функция создаёт сокет на основании первых трёх параметров так же, как это делает функция Socket. С другой стороны, если этот параметр не равен nil, то структура, на которую он указывает, содержит всю информацию, необходимую для создания сокета, поэтому первые три параметра должны быть равны константе From_Protocol_Info (-1). Параметр g зарезервирован для использования в будущем и должен быть равен нулю (тип TGroup совпадает с DWORD). Последний параметр dwFlags определяет, какие дополнительные возможности имеет создаваемый сокет. Вызов функции Socket эквивалентен вызову функции WSASocket с флагом WSA_Flag_Overlapped, который показывает, что данный сокет можно использовать для перекрытого ввода-вывода. Остальные флаги используются при многоадресной рассылке (не все из них допустимы при использовании протоколов TCP и UDP). Эти флаги мы рассмотрим в соответствующем разделе.

При использовании TCP и UDP функция WSASocket даёт следующие преимущества по сравнению с функцией Socket. Во-первых, через параметр lpProtocolInfo появляется возможность явно указать провайдера, который будет использован программой. Во-вторых, если программа не использует перекрытый ввод-вывод, можно создавать сокеты без флага WSA_Flag_Overlapped, экономя при этом некоторое незначительное количество ресурсов. Кроме того, как это будет обсуждаться ниже, с помощью WSASocket две разных программы могут использовать один и тот же сокет.

Функция WSAConnect является более мощным аналогом Connect. Её прототип выглядит следующим образом:

int WSAConnect(
	SOCKET s,
	const struct sockaddr FAR *name,
	int namelen,
	LPWSABUF lpCallerData,
	LPWSABUF lpCalleeData,
	LPQOS lpSQOS,
	LPQOS lpGQOS);

function WSAConnect(
	S:TSocket;
	var Name:TSockAddr;
	NameLen:Integer;
	lpCallerData,lpCalleeData:PWSABuf;
	lpSQOS,lpGQOS:PQOS);

typedef struct __WSABUF {
	u_long len;
	char FAR *buf;} WSABUF, FAR * LPWSABUF;

type PWSABuf=^TWSABuf;
     TWSABuf=packed record
              Len:Cardinal;
              Buf:PChar
             end;

Функция WSAConnect устанавливает соединение со стороны клиента. Её первые три параметра совпадают с параметрами функции Connect. Параметры lpCallerData и lpCalleeData служат для передачи данных от клиента серверу и от сервера клиенту при установлении соединения. Они оба являются указателями на структуру TWSABuf, которая содержит размер буфера Len и указатель на буфер Buf. Протоколы стека TCP/IP не поддерживают передачу данных при соединении, поэтому при использовании TCP и UDP lpCallerData и lpCalleeData должны быть равны nil. Параметры lpSQOS и lpGQOS являются указателями на структуры, с помощью которых программа передаёт свои требования к качеству обслуживания, причём параметр lpGQOS связан с не поддерживаемым в настоящий момент групповым качеством и всегда должен быть равен nil. Параметр lpSQOS также должен быть равен nil, если программа не предъявляет требований к качеству обслуживания. Так как рассмотрение качества обслуживания выходит за рамки данной статьи, мы не приводим здесь определение структуры SQOS. При необходимости это определение легко найти в MSDN'е.

Между функциями Connect и WSAConnect существует небольшое различие при работе с сокетами, не поддерживающими соединение. Как вы знаете из предыдущей статьи, функция Connect может использоваться с такими сокетами для задания адреса отправки по умолчанию и автоматической фильтрации входящих пакетов. Для того, чтобы отменить такое "соединение", нужно при вызове функции Connect указать адрес InAddr_Any и нулевой порт. При использовании же WSAConnect для отмены "соединения" требуется, чтобы все без исключения поля структуры Name, включая sin_family, были нулевыми. Это сделано для того, чтобы обеспечить независимость от протокола: в этом случае для разрыва "соединения" должно использоваться одно и то же значение Name при любом протоколе.

Если программа не предъявляет требований к качеству обслуживания, то при использовании протоколов TCP и UDP функция WSAConnect не предоставляет никаких преимуществ по сравнению с функцией Connect.

Функция Accept из стандартной библиотеки сокетов позволяет серверу извлечь из очереди соединений информацию о подключившемся клиенте и создать сокет для его обслуживания. Эти действия выполняются безусловно, для любых подключившихся клиентов. Если сервер допускает подключение не любых клиентов, а только тех, которые отвечают некоторым условиям (для протокола TCP эти условия могут заключаться в том, какие IP-адреса и какие порты допустимо использовать клиентам), сразу после установления соединения его приходится разрывать, если клиент не удовлетворяет этим условиям. Для упрощения этой операции в WinSock 2 предусмотрена функция WSAAccept, имеющая следующий прототип:

SOCKET WSAAccept(
	SOCKET s,
	struct sockaddr FAR *addr,
	LPINT addrlen,
	LPCONDITIONPROC lpfnCondition,
	DWORD dwCallbackData);

function WSAAccept(
	S:TSocket;
	Addr:PSockAddr;
	AddrLen:PInteger;
	lpfnCondition:TConditionProc;
	dwCallbackData:DWORD):TSocket;

По сравнению с уже известной нам функцией Accept функция WSAAccept имеет два новых параметра: lpfnCondition и dwCallbackData. lpfnCondition является указателем на функцию обратного вызова. Эта функция объявляется и реализуется программой. WSAAccept вызывает её внутри себя и в зависимости от её результата принимает или отклоняет соединение. Параметр dwCallbackData не имеет смысла для самой функции WSAAccept и передаётся без изменений в функцию обратного вызова. Тип TConditionProc должен быть объявлен следующим образом:

typedef (int*)(
	LPWSABUF lpCallerId,
	LPWSABUF lpCallerData,
	LPQOS lpSQOS,
	LPQOS lpGQOS,
	LPWSABUF lpCalleeId,
	LPWSABUF lpCalleeData,
	GROUP FAR *g,
	DWORD dwCallbackData) LPCONDITIONPROC;

type TConditionProc=function(
 	lpCallerId,lpCallerData:PWSABuf;
 	lpSQOS,lpGQOS:PQOS;
 	lpCalleeID,lpCalleeData:PWSABuf;
 	g:PGroup;
 	dwCallbackData:DWORD):Integer;stdcall;

Параметр lpCallerId указывает на буфер, в котором хранится адрес подключившегося клиента. В случае использования стека TCP/IP lpCallerId^.Len будет равен SizeOf(TSockAddr), а lpCallerId^.Buf будет указывать на структуру TSockAddr, содержащую адрес клиента. Параметр lpCallerData определяет буфер, в котором хранятся данные, переданные клиентом при соединении. Как уже отмечалось выше, протоколы стека TCP/IP не поддерживают передачу данных при соединении, поэтому для них этот параметр будет равен nil.

Параметры lpSQOS и lpGQOS задают требуемое клиентом качество обслуживания для сокета и для группы соответственно. Т.к. группы сокетов в текущей реализации WinSock не поддерживаются, параметр lpGQOS будет равен nil. Параметр lpSQOS тоже будет равен nil, если клиент не задал качество обслуживания при соединении.

Параметр lpCalleeId содержит адрес интерфейса, принявшего соединение (поля структуры при этом используются так же, как у параметра lpCallerId). В предыдущей статье обсуждалось, что сокет, привязанный к адресу InAddr_Any, прослушивает все сетевые интерфейсы, имеющиеся на компьютере, но каждое подключение, созданное с его помощью, использует конкретный интерфейс. Параметр lpCalleeId содержит адрес, привязанный к конкретному соединению. Параметр lpCalleeData указывает на буфер, в который сервер может положить данные для отправки клиенту. Этот параметр также не имеет смысла при использовании протокола TCP, не поддерживающего отправку данных при соединении.

Описание параметра g в MSDN'е отсутствует (видимо, по ошибке). Но так как имеющаяся версия WinSock не поддерживает группы сокетов, его значение всё равно должно игнорироваться функцией обратного вызова.

И, наконец, через параметр dwCallbackData в функцию обратного вызова передаётся значение параметра dwCallbackData, переданное в функцию WSAAccept. Программист должен сам решить, как ему интерпретировать это значение.

Функция должна вернуть CF_Accept (0), если соединение принимается, CF_Reject (1), если оно отклоняется, и CF_Defer (2), если решение о разрешении или запрете соединения откладывается. Если функция обратного вызова вернула CF_Reject, WSAAccept завершается с ошибкой WSAEConnRefused, если CF_Defer - с ошибкой WSATry_Again (в последнем случае соединение остаётся в очереди, и информация о нём вновь будет передана в функцию обратного вызова при следующем вызове WSAAccept). Обе эти ошибки не являются фатальными, сокет остаётся в режиме ожидания соединения и может принимать подключения от новых клиентов.

В предыдущей статье обсуждалось, что функция Connect на стороне клиента считается успешно завершённой тогда, когда соединение встало в очередь, а не тогда, когда оно реально принято сервером через функцию Accept. По умолчанию для клиента, соединение с которым сервер отклонил, нет разницы, использовал ли сервер функцию WSAAccept и сразу отклонил соединение, или установил его с помощью Accept, а потом разорвал. В обоих случаях клиент сначала получит информацию об успешном соединении с сервером, а потом это соединение будет разорвано. Но при использовании WSAAccept можно установить такой режим работы, когда сначала выполняется функция, заданная параметром lpCondition, и лишь потом клиенту отправляется разрешение или запрет на подключение. Включается этот режим установкой параметра слушающего сокета SO_Conditional_Accept следующим образом:

var Cond:BOOL;
 begin
  Cond:=True;
  SetSockOpt(S,SOL_Socket,SO_Conditional_Accept,PChar(@Cond),SizeOf(Cond));

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

Из вышесказанного следует, что при использовании протокола TCP функция WSAAccept по сравнению с Accept даёт два принципиальных преимущества - позволяет управлять качеством обслуживания и запрещать подключение нежелательных клиентов.

Некоторые протоколы поддерживают передачу информации не только при установлении связи, но и при её завершении. Для таких протоколов в WinSock2 предусмотрены функции WSASendDisconnect и WSARecvDisconnect. Так как протокол TCP не поддерживает передачу данных при закрытии соединения, для него эти функции не дают никаких преимуществ по сравнению с использованием функции Shutdown, поэтому мы не будем их здесь рассматривать.

Далее мы рассмотрим несколько новых функций, унифицирующих работу с различными протоколами.

Функция Inet_Addr, как это уже упоминалось выше, жёстко связана с протоколом IP и не имеет смысла при использовании других протоколов. WinSock 2 предлагает использовать вместо неё функцию WSAStringToAddress, имеющую следующий прототип:

INT WSAStringToAddress(
	LPTSTR AddressString,
	INT AddressFamily,
	LPWSAPROTOCOL_INFO lpProtocolInfo,
	LPSOCKADDR lpAddress,
	LPINT lpAddressLength);

function WSAStringToAddress(
 	AddresString:PChar;
 	AddressFamily:Integer;
 	lpProtocolInfo:PWSAProtocolInfo;
 	var Address:TSockAddr;
 	var AddressLength:Integer):Integer;

Данная функция преобразует строку, задающую адрес сокета, в адрес, хранящийся в структуре TSockAddr. Параметр AddressString указывает на строку, хранящую адрес, параметр AddressFamily - на семейство адресов, для которого осуществляется трансляция. Если есть необходимость указать конкретного провайдера для протокола, в функцию может быть передан параметр lpProtocolInfo, в котором указан идентификатор провайдера. Если же программу устраивает провайдер по умолчанию, параметр lpProtocolInfo должен быть равен nil. Адрес возвращается через параметр Address. Параметр AddressLength при вызове функции должен содержать размер буфера, переданного через Address, а на выходе содержит реально использованное число байт в буфере.

Функция возвращает 0 в случае успешного выполнения и Socket_Error в случае ошибки.

Допустимый формат строки определяется протоколом (некоторые протоколы вообще не поддерживают текстовую запись адреса, и для них функция WSAStringToAddress неприменима). Для семейства AF_Inet, к которому относятся TCP и UDP, адрес может задаваться в виде "IP1.IP2.IP3.IP4:Port" или "IP1.IP2.IP3.IP4", где IPn - n-ая компонента IP-адреса, записанного в виде четырёх байтовых полей, Port - номер порта. Если порт явно не указан, используется нулевой номер порта.

Таким образом, чтобы в структуре TSockAddr оказался, например, адрес 192.168.100.217 и порт с номером 5000, необходимо выполнить следующий код:

var Addr:TSockAddr;
    AddrLen:Integer;
 begin
  AddrLen:=SizeOf(Addr);
  WSAStringToAddress('192.168.100.217:5000',AF_Inet,nil,Addr,AddrLen);

Существует также функция WSAAddressToString, обратная к WSAStringToAddress. Её прототип выглядит следующим образом:

INT WSAAddressToString(
	LPSOCKADDR lpsaAddress,
	DWORD dwAddressLength,
	LPWSAPROTOCOL_INFO lpProtocolInfo,
	LPTSTR lpszAddressString,
	LPDWORD lpdwAddressStringLength);

function WSAAddressToString(
 	var Address:TSockAddr;
 	dwAddressLength:DWORD;
 	lpProtocolInfo:PWSAProtocolInfo;
 	lpszAddressString:PChar;
 	var AddressStringLength:DWORD):Integer;

Как нетрудно догадаться по названию функции, она преобразует адрес, заданный структурой TSockAddr, в строку. Адрес задаётся параметром Address, параметр dwAddressLength определяет длину буфера Address. Необязательный параметр lpProtocolInfo содержит указатель на структуру TWSAProtocolInfo, с помощью которой можно определить, какой именно провайдер должен выполнить преобразование. Параметр lpszAddressString содержит указатель на буфер, заранее выделенный программой, в который будет помещена строка. Параметр AddressStringLength на входе должен содержать размер буфера, заданного параметром lpszAddressString, а на выходе содержит длину получившейся строки.

Функция возвращает ноль в случае успеха и Socket_Error в случае ошибки.

В предыдущей статье мы обсуждали различные форматы представления целых чисел, а также то, что формат, предусмотренный сетевым протоколом, может не совпадать с форматом, используемым узлом. Для преобразования из сетевого формата в формат узла используются функции HToNS, NToHS, HToNL и NToHL. Эти функции привязаны к протоколам стека TCP/IP - другие протоколы могут использовать другой формат представления чисел. WinSock 2 предлагает аналоги этих функций WSAHToNS, WSANToHS, WSAHToNL и WSANToHL, которые учитывают особенности конкретного протокола. Мы здесь рассмотрим только функцию WSANToHL, преобразующую 32-битное целое из сетевого формата в формат узла. Остальные три функции работают аналогично. Прототип функции WSANToHL выглядит так:

int WSANtohl(SOCKET s,u_long netlong,u_long FAR *lphostlong);

function WSANToHL(S:TSocket;NetLong:Cardianl;var HostLong:Cardinal):Integer;

Параметр S задаёт сокет, для которого осуществляется преобразование. Так как сокет всегда связан с конкретным протоколом, этого параметра достаточно, чтобы библиотека могла определить, по какому закону преобразовывать число из сетевого формата в формат хоста. Число в сетевом формате задаётся параметром NetLong, результат преобразования помещается в параметр HostLong. Функция возвращает ноль в случае успешного выполнения операции и Socket_Error в случае ошибки.

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

Последняя функция, которую мы здесь рассмотрим, не имеет прямых аналогов среди старых функций. Называется она WSADuplicateSocket и служит для копирования дескриптора сокета в другой процесс. Прототип функции WSADuplicateSocket выглядит следующим образом:

int WSADuplicateSocket(
	SOCKET s,
	DWORD dwProcessId,
	LPWSAPROTOCOL_INFO lpProtocolInfo);

function WSADuplicateSocket(
	S:TSocket;
	dwProcessID:DWORD;
	var ProtocolInfo:TWSAProtocolInfo):Integer;

Параметр S задаёт сокет, дескриптор которого нужно скопировать, параметр dwProcessID - идентификатор процесса, для которого предназначена копия. Функция помещает в структуру ProtocolInfo информацию, необходимую для создания копии дескриптора другим процессом. Затем эта структура должна быть каким-то образом передана другому процессу, который передаст её в функцию WSASocket и получит свою копию дескриптора для работы с данным сокетом.

Функция WSADuplicateSocket возвращает ноль при успешном завершении и Socket_Error при ошибке.

В предыдущей статье мы обсуждали то, что сокет является объектом, внутренняя структура которого остаётся скрытой от использующей его программы. Программа манипулирует только дескриптором сокета - некоторым уникальным идентификатором этого объекта. Функция WSADuplicateSocket позволяет другой программе получить новый дескриптор для уже существующего сокета. Старый и новый дескриптор становятся равноправными. Чтобы освободить сокет, нужно закрыть все его дескрипторы с помощью функции CloseSocket.

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

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

[ К содержанию ]

Асинхронный режим, основанный на сообщениях

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

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

Асинхронный режим с уведомлением через сообщения устанавливается функцией WSAAsyncSelect, имеющей следующий прототип:

function WSAAsyncSelect(S:TSocket;HWindow:HWND;
                        wMsg:u_int;lEvent:LongInt):Integer;

Параметр S определяет сокет, для которого устанавливается асинхронный режим работы. Параметр HWindow - дескриптор окна, которому будут приходить сообщения, параметр wMsg - сообщение, а параметр lEvent задаёт события, которые вызывают отправку сообщения. Для этого параметра определены константы, комбинация которых задаёт интересующие программу события. Мы не будем рассматривать здесь все возможные события, остановимся только на самых главных. Они показаны в таблице.

Событие Комментарий
FD_Read Сокет готов к чтению
FD_Write Сокет готов к записи
FD_Accept В очереди сокета есть подключения (применимо только для сокетов, находящихся в режиме ожидания подключения)
FD_Connect Соединение установлено (применимо только для сокетов, для которых вызвана функция Connect или аналогичная ей)
FD_Close Соединение закрыто

Каждый последующий вызов WSAAsyncSelect для одного и того же сокета отменяет предыдущий вызов. Таким образом, в результате выполнения следующего кода форма будет получать только сообщения, показывающие готовность сокета к чтению, а готовность к записи не приведёт к отправке сообщения:

WSAAsyncSelect(S,Form1.Handle,WM_User,FD_Write);
// Второй вызов отменит результаты первого
WSAAsyncSelect(S,Form1.Handle,WM_User,FD_Read);

WSAAsyncSelect связывает с сообщением именно сокет, а не его дескриптор. Это означает, что если две программы используют один сокет (копия дескриптора которого была создана с помощью функции WSADuplicateSocket), и первая программа вызывает WSAAsyncSelect со своим дескриптором, а затем вторая - со своим, то вызов WSAAsyncSelect, сделанный во второй программе, отменит вызов, сделанный в первой.

Для того, чтобы получать сообщения при готовности сокета как к чтению, так и к записи, нужно выполнить следующий код:

WSAAsyncSelect(S,Form1.Handle,WM_User,FD_Read or FD_Write);

При необходимости с помощью or можно комбинировать и большее число констант.

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

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

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

При получении сообщения его параметр wParam содержит дескриптор сокета, на котором произошло событие. Младшее слово lParam содержит произошедшее событие (одну из констант FD_XXX), а старшее слово - код ошибки, если она произошла. Для выделения кода события и кода ошибки из lParam в библиотеке WinSock предусмотрены макросы WSAGETSELECTEVENT и WSAGETSELECTERROR соответственно. В модуле WinSock они заменены функциями WSAGetSelectEvent и WSAGetSelectError. Одно сообщение может информировать только об одном событии на сокете. Если произошло несколько событий, в очередь окна будет добавлено несколько сообщений.

Сокет, созданный при вызове функции Accept, наследует режим того сокета, который принял соединения. Таким образом, если сокет, находящийся в режиме ожидания подключения, является асинхронным, то и сокет, порождённый функцией Accept, будет асинхронным, и тот же набор его событий будет связан с тем же сообщением, что и у исходного сокета.

Рассмотрим подробнее каждое из вышеперечисленных событий.

Событие FD_Read возникает, когда во входной буфер сокета поступают данные (если на момент вызова WSAAsyncSelect, разрешающего такие события, в буфере сокета уже есть данные, то событие также возникает). Как только соответствующее сообщение помещается в очередь окна, дальнейшая генерация таких сообщений для этого сокета блокируется, т.е. получение новых данных не будет приводить к появлению новых сообщений (при этом сообщения, связанные с другими событиями этого сокета или с событием FD_Read других сокетов, будут по-прежнему помещаться при необходимости в очередь окна). Генерация сообщений снова разрешается после того, как будет вызвана функция для чтения данных из буфера сокета (это может быть функция Recv, RecvFrom, WSARecv или WSARecvFrom; мы в дальнейшем будем говорить только о функции Recv, потому что остальные ведут себя в этом отношении полностью аналогично).

Если после вызова Recv в буфере асинхронного сокета остались данные, в очередь окна снова помещается это же сообщение. Благодаря этому программа может обрабатывать большие массивы по частям. Действительно, пусть в буфер сокета приходят данные, которые программа хочет забирать оттуда по частям. Приход этих данных вызывает событие FD_Read, сообщение о котором помещается в очередь. Когда программа начинает обрабатывать это сообщение, она вызывает Recv и читает часть данных из буфера. Так как данные в буфере ещё есть, снова генерируется сообщение о событии FD_Read, которое ставится в конец очереди. Через некоторое время программа снова начинает обрабатывать это сообщение. Если и в этот раз данные будут прочитаны не полностью, в очередь снова будет добавлено такое же сообщение. И так будет продолжаться до тех пор, пока не будут прочитаны все полученные данные.

Описанная схема, в принципе, достаточно удобна, но следует учитывать, что в некоторых случаях она может давать ложные срабатывания, т.е. при обработке сообщения о событии FD_Read функция Recv завершится с ошибкой WSAEWouldBlock, показывающей, что входной буфер сокета пуст.

Если программа читает данные из буфера не только при обработке FD_Read, может возникнуть следующая ситуация: в буфер сокета поступают данные. Сообщение о событии FD_Read помещается в очередь. Программа в это время обрабатывает какое-то другое сообщение, при обработке которого также читаются данные. В результате все данные извлекаются из буфера, и он остаётся пустым. Когда очередь доходит до обработки FD_Read, читать из буфера уже нечего.

Другой вариант ложного срабатывания возможен, если программа при обработке FD_Read читает данные из буфера по частям, вызывая Recv несколько раз. Каждый вызов Recv, за исключением последнего, приводит к тому, что в очередь ставится новое сообщение о событии FD_Read. Чтобы избежать появления пустых сообщений в подобных случаях, MSDN рекомендует перед началом чтения отключить для данного сокета реакцию на поступление данных, вызвав для него WSAAsyncSelect без FD_Read, а перед последним вызовом Recv - снова включить.

И, наконец, следует помнить, что сообщение о событии FD_Read можно получить и после того, как с помощью WSAAsyncSelect сокет будет переведён в синхронный режим. Это может случиться в том случае, когда на момент вызова WSAAsyncSelect в очереди ещё остались необработанные сообщения о событиях на данном сокете. Впрочем, это касается не только FD_Read, а вообще любого события.

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

При использовании TCP первый раз сообщение, уведомляющее о событии FD_Write, присылается сразу после успешного завершения операции подключения к серверу с помощью Connect, если речь идёт о клиенте, или сразу после создания сокета функцией Accept или её аналогом в случае сервера. При использовании UDP это событие возникает после привязки сокета к адресу явным или неявным вызовом функции Bind. Если на момент вызова WSAAsyncSelect описанные действия уже выполнены, событие FD_Write также генерируется.

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

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

Событие FD_Connect возникает при установлении соединения для сокетов, поддерживающих соединение. Для клиентских сокетов оно возникает после завершения процедуры установления связи, начатой с помощью функции Connect, для серверных - после создания нового сокета с помощью функции Accept (событие возникает именно на новом сокете, а не на том, который находится в режиме ожидания подключения). В MSDN'е написано, что оно должно возникать также и после выполнения Connect для сокетов, не поддерживающих соединение, однако мне не удалось получить это событие при использовании протокола UDP. Событие FD_Connect также возникает, если при попытке установить соединение произошла ошибка (например, оказался недоступен указанный сетевой адрес). Поэтому при получении этого события необходимо анализировать старшее слово параметра lParam, чтобы понять, удалось ли установить соединение.

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

Рекомендуемая последовательность действий при завершении связи такова. Сначала клиент завершает отправку данных через сокет, вызывая функцию Shutdown с параметром SD_Send. Сервер при этом получает событие FD_Close. Сервер отсылает данные клиенту (при этом клиент получает одно или несколько событий FD_Read), а затем также завершает отправку данных с помощью Shutdown с параметром SD_Send. Клиент при этом получает событие FD_Close, в ответ на которое закрывает сокет с помощью CloseSocket. Сервер, в свою очередь, сразу после вызова Shutdown также вызывает CloseSocket.

Ниже приведён пример кода сервера, использующего асинхронные сокеты. Сервер работает в режиме запрос-ответ, т.е. посылает какие-то данные клиенту только в ответ на его запросы. Константа WM_SocketEvent, определённая в коде для сообщений, связанных с сокетом, может, в принципе, иметь и другие значения.

unit Unit1;

interface

uses
 Windows,Messages,SysUtils,Classes,Graphics,Controls,Forms,Dialogs,WinSock;

const
  WM_SocketEvent=WM_User+1;

type
  TForm1=class(TForm)
    procedure FormCreate(Sender: TObject);
    procedure FormDestroy(Sender: TObject);
   private
    ServSock:TSocket;
    procedure WMSocketEvent(var Msg:TMessage);message WM_SocketEvent;
  end;

var
  Form1: TForm1;

implementation

{$R *.DFM}

procedure TForm1.FormCreate(Sender: TObject);
 var Data:TWSAData;
     Addr:TSockAddr;
  begin
   WSAStartup($101,Data);
   // Обычная последовательность действий по созданию сокета,
   // привязке его к адресу и установлению на прослушивание
   ServSock:=Socket(AF_Inet,Sock_Stream,0);
   Addr.sin_family:=AF_Inet;
   Addr.sin_addr.S_addr:=InAddr_Any;
   Addr.sin_port:=HToNS(3320);
   FillChar(Addr.sin_zero,SizeOf(Addr.sin_zero),0);
   Bind(ServSock,Addr,SizeOf(Addr));
   Listen(ServSock,SOMaxConn);
   // Перевод сокета в асинхронный режим. Кроме события FD_Accept
   // указаны также события FD_Read и FD_Close, которые никогда не
   // возникают на сокете, установленном в режим прослушивания.
   // Это сделано потому, что сокеты, созданные с помощью функции
   // Accept, наследуют асинхронный режим, установленный для
   // слушающего сокета. Таким образом, не придётся вызывать
   // функцию WSAAsyncSelect для этих сокетов - для них сразу
   // будет назначен обработчик событий FD_Read и FD_Close.
   WSAAsyncSelect(ServSock,Handle,
                  WM_SocketEvent,FD_Read or FD_Accept or FD_Close)
  end;

procedure TForm1.FormDestroy(Sender: TObject);
 begin
  CloseSocket(ServSock);
  WSACleanup
 end;

procedure TForm1.WMSocketEvent(var Msg:TMessage);
 var Sock:TSocket;
     SockError:Integer;
  begin
   Sock:=TSocket(Msg.WParam);
   SockError:=WSAGetSelectError(Msg.lParam);
   if SockError<>0 then
    begin
     // Здесь должен быть анализ ошибки
     CloseSocket(Sock);
     Exit
    end;
   case WSAGetSelectEvent(Msg.lParam) of
    FD_Read:
     begin
      // Пришёл запрос от клиента. Необходимо прочитать данные,
      // сформировать ответ и отправить его.
     end;
    FD_Accept:
     begin
      // Просто вызываем функцию Accept. Её результат нигде не
      // сохраняется, потому что вновь созданный сокет автоматически
      // начинает работать в асинхронном режиме, и его дескриптор при
      // необходимости будет передан через Msg.wParam при возникновении
      // события
      Accept(Sock,nil,nil)
     end;
    FD_Close:
     begin
      // Получив от клиента сигнал завершения, сервер, в принципе,
      // может попытаться отправить ему данные. После этого сервер
      // также должен соединение со своей стороны
      Shutdown(Sock,SD_Send);
      CloseSocket(Sock)
     end
   end
  end;

end.

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

[ К содержанию ]

Асинхронный режим, основанный на событиях

Асинхронный режим, основанный на событиях, появился во второй версии Windows Sockets. В его основе лежат события - специальные объекты, служащие для синхронизации работы нитей.

Существуют события, поддерживаемые на уровне системы. Эти события создаются с помощью функции CreateEvent. Каждое событие может находится в сброшенном или взведённом состоянии. Нить с помощью функций WaitForSingleObject и WaitForMultipleObjects может дожидаться, пока одно или несколько событий не окажутся во взведённом состоянии. В режиме ожидания нить не требует процессорного времени. Другая нить может установить событие с помощью функции SetEvent, в результате чего первая нить выйдет из состояния ожидания и продолжит свою работу.

Аналогичные объекты определены и в Windows Sockets. Сокетные события отличаются от стандартных системных событий прежде всего тем, что они могут быть связаны с событиями FD_XXX, происходящими на сокете, и взводиться при наступлении этих событий.

Так как сокетные события поддерживаются только в WinSock 2, модуль WinSock не содержит объявлений типов и функций, требуемых для их поддержки. Поэтому их придётся объявлять самостоятельно. Прежде всего, должен быть объявлен тип дескриптора событий, который в MSDN'е называется WSAEVENT. В Delphi он может быть объявлен следующим образом:

type PWSAEvent=^TWSAEvent;
     TWSAEvent=THandle;

Событие создаётся с помощью функции WSACreateEvent, имеющей следующий прототип:

WSAEVENT WSACreateEvent(void);

function WSACreateEvent:TWSAEvent;

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

Функция создаёт событие и возвращает его дескриптор. Если произошла ошибка, функция возвращает значение WSA_Invalid_Event (0).

Для ручного взведения и сброса события используются функции WSASetEvent и WSAResetEvent соответственно, прототипы которых выглядят следующим образом:

BOOL WSASetEvent(WSAEVENT hEvent);
BOOL WSAResetEvent(WSAEVENT hEvent);

function WSASetEvent(hEvent:TWSAEvent):BOOL;
function WSAResetEvent(hEvent:TWSAEvent):BOOL;

Функции возвращают True, если операция прошла успешно, и False в противном случае.
После завершения работы с событием оно уничтожается с помощью функции WSACloseEvent:

BOOL WSACloseEvent(WSAEVENT hEvent);

function WSACloseEvent(hEvent:TWSAEvent):BOOL;

Функция уничтожает событие и освобождает связанные с ним ресурсы. Дескриптор, переданный в качестве параметра, становится недействительным.

Для ожидания взведения событий используется функция WSAWaitForMultipleEvents, имеющая следующий прототип:

DWORD WSAWaitForMultipleEvents(
DWORD cEvents,
const WSAEVENT FAR *lphEvents,
BOOL fWaitAll,
DWORD dwTimeout,
BOOL fAlertable);

function WSAWaitForMultipleEvents(
	cEvents:DWord;
	lphEvents:PWSAEvent;
	fWaitAll:BOOL;
	dwTimeout:DWord;
	fAlertable:BOOL):DWord;

Дескрипторы событий, взведения которых ожидает нить, должны храниться в массиве, размер которого передаётся через параметр cEvents, а указатель - через параметр lphEvents. Параметр fWaitAll определяет, что является условием окончания ожидания: если этот параметр равен TRUE, ожидание завершается, когда все события из переданного массива оказываются во взведённом состоянии, если FALSE - когда оказывается взведённым хотя бы одно из них. Параметр dwTimeout определяет таймаут ожидания в миллисекундах. В WinSock 2 определена константа WSA_Infinite (совпадающая по значению со стандартной константой Infinite), которая задаёт бесконечное ожидание. Параметр fAlertable используется при перекрытом вводе-выводе; мы рассмотрим его позже в соответствующем разделе. Если перекрытый ввод-вывод не используется, fAlertable должен быть равен FALSE.

Существует ограничение на число событий, которое можно ожидать с помощью данной функции. Максимальное число событий определяется константой WSA_Maximum_Wait_Events, которая в данной реализации равна 64.

Результат, возвращаемый функцией, позволяет определить, по каким причинам закончилось ожидание. Если ожидалось взведение всех событий (fWaitAll=TRUE), и оно произошло, функция возвращает WSA_Wait_Event_0 (0). Если ожидалось взведение хотя бы одного из событий, возвращается WSA_Wait_Event_0 + Index, где Index - индекс взведённого события в массиве lphEvents (отсчёт индексов начинается с нуля). Если ожидание завершилось по таймауту, возвращается значение WSA_Wait_Timeout (258). И, наконец, если произошла какая-либо ошибка, функция возвращает WSA_Wait_Failed ($FFFFFFFF).

Существует ещё одно значение, которое может возвратить функция WSAWaitForMultipleEvents: Wait_IO_Completion (это константа из стандартной части Windows API, она объявлена в модуле Windows). Смысл этого результата и условия, при которых он может быть возвращён, мы рассмотрим при изучении перекрытого ввода-вывода.

Функции, которые мы рассматривали до сих пор рассматривали, являются аналогами системных функций для стандартных событий. Теперь мы переходим к рассмотрению тех функций, которые отличают сокетные события от стандартных. Главная из этих функций - WSAEventSelect, позволяющая привязать события, создаваемые с помощью WSACreateEvent, к тем событиям, которые происходят на сокете. Прототип этой функции выглядит следующим образом:

int WSAEventSelect(SOCKET s,WSAEVENT hEventObject,long lNetworkEvents);

function WSAEventSelect(S:TSocket;hEventObject:TWSAEvent;
                         lNetworkEvents:LongInt):Integer;

Эта функция очень похожа на функцию WSAAsyncSelect, за исключением того, что события FD_XXX привязываются не к оконным сообщениям, а к сокетным событиям. Параметр S определяет сокет, события которого отслеживаются, параметр hEventObject - событие, которое должно взводиться при наступлении отслеживаемых событий, lNetworkEvents - комбинация констант FD_XXX, определяющая, с какими событиями на сокете связывается событие hSocketEvent.

Функция WSAEventSelect возвращает ноль, если операция прошла успешно, и Socket_Error при возникновении ошибки.

Событие, связанное с сокетом функцией WSAEventSelect, взводится при тех же условиях, при которых в очередь окна помещается сообщение при использовании WSAAsyncSelect. Так, например, функция Recv взводит событие, если после её вызова в буфере сокета ещё остаются данные. Но, с другой стороны, функция Recv не сбрасывает событие, если данных в буфере сокета нет. А так как сокетные события не сбрасываются автоматически функцией WSAWaitForMultipleEvents, программа всегда должна сбрасывать события сама. Так, при обработке FD_Read наиболее типичной является ситуация, когда сначала сбрасывается событие, а потом вызывается функция Recv, которая при необходимости снова взводит событие. Здесь мы снова имеем проблему ложных срабатываний в тех случаях, когда данные извлекаются из буфера по частям с помощью нескольких вызовов Recv, но в данном случае проблему решить легче: не надо отменять регистрацию событий, достаточно просто сбросить событие непосредственно перед последним вызовом Recv.

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

Как и в случае с WSAAsyncSelect, при вызове WSAEventSelect сокет переводится в неблокирующий режим. Повторный вызов WSAEventSelect для данного сокета отменяет результаты предыдущего вызова (т.е. невозможно связать разные события FD_XXX одного сокета с разными сокетными событиями). Сокет, созданный в результате вызова Accept или WSAAccept, наследует связь с сокетными событиями, установленную для слушающего сокета.

Существует весьма важное различие между использованием оконных сообщений и сокетных событий для оповещения о том, что происходит на сокете. Предположим, с помощью функции WSAAsyncSelect события FD_Read, FD_Write и FD_Connect связаны с некоторым оконным сообщением. Пусть происходит событие FD_Connect. В очередь окна помещается соответствующее сообщение. Затем, до того, как предыдущее сообщение будет обработано, происходит FD_Write. В очередь окна помещается ещё одно сообщение, которое информирует об этом. И, наконец, при возникновении FD_Read в очередь будет помещено третье сообщение. Затем оконная процедура получит их по очереди и обработает.

Теперь рассмотрим ситуацию, когда те же события связаны с сокетным событием. Когда происходит FD_Connect, сокетное событие взводится. Теперь, если FD_Write и FD_Read произойдут до того, как сокетное событие будет сброшено, оно уже не изменит своего состояния. Таким образом, программа, использующая асинхронные сокеты, основанные на событиях, должна, во-первых, учитывать, что взведённое событие может означать несколько событий FD_XXX, а во-вторых, иметь возможность узнать, какие именно события произошли с момента последней проверки. Для получения этой информации используется функция WSAEnumNetworkEvents, имеющая следующий прототип:

int WSAEnumNetworkEvents(
SOCKET s,
WSAEVENT hEventObject,
	LPWSANETWORKEVENTS lpNetworkEvents);

function WSAEnumNetworkEvents(
	S:TSocket;
	hEventObject:TWSAEvent;
	var NetworkEvents:TWSANetworkEvents):Integer;

Функция WSAEnumNetworkEvents через параметр NetworkEvents возвращает информацию о том, какие события произошли на сокете S с момента последнего вызова этой функции для данного сокета (или с момента запуска программы, если функция вызывается в первый раз). Параметр hEventObject является необязательным. Он определяет сокетное событие, которое нужно сбросить. Использование этого параметра позволяет обойтись без явного вызова функции WSAResetEvent для сброса события. Как и большинство функций WinSock, функция WSAEnumNetworkEvents возвращает ноль в случае успеха и ненулевое значение в случае ошибки.

Структура TWSANetworkEvents содержит информацию о произошедших событиях и об ошибках. Она объявлена следующим образом:

typedef struct _WSANETWORKEVENTS {
long     lNetworkEvents;
int      iErrorCode[FD_MAX_EVENTS];
} WSANETWORKEVENTS, *LPWSANETWORKEVENTS;

type TWSANetworkEvents=packed record
                        lNetworkEvents:LongInt;
                        iErrorCode:array[0..FD_Max_Events-1] of Integer;
                       end;

Константа FD_Max_Events определяет количество разных типов событий и данной реализации равна 10.

Значения констант FD_XXX являются степенями двойки, поэтому их можно объединять операцией or без потери информации. Поле lNetworkEvents является таким объединением всех констант, задающих события, которые происходили на сокете. Другими словами, если результат операции (lNetworkEvents and FD_XXX) не равен нулю, значит, событие FD_XXX происходило на сокете.

Массив iErrorCode содержит информацию об ошибках, которыми сопровождались события FD_XXX. Для каждого события FD_XXX определена соответствующая константа FD_XXX_Bit (т.е. константы FD_Read_Bit, FD_Write_Bit и т.д.). Элемент массива с индексом FD_XXX_Bit содержит информацию об ошибке, связанной с событием FD_XXX. Если операция прошла успешно, этот элемент содержит ноль, в противном случае - код ошибки, которую в аналогичной ситуации вернула бы функция WSAGetLastError после выполнения соответствующей операции на синхронном сокете.

Таким образом, программа, использующая асинхронный режим, основанный на событиях, должна выполнить следующие действия. Во-первых, создать сокет и установить соединение. Во-вторых, привязать события FD_XXX к сокетному событию. В-третьих, организовать цикл, начинающийся с вызова WSAWaitForMultipleEvents, в котором с помощью WSAEnumNetworkEvents определять, какое событие произошло, и обрабатывать его. При возникновении ошибки на сокете цикл должен завершаться.

Сокетные события могут взводиться не только в результате событий на сокете, но и вручную, с помощью функции SetEvent. Это даёт нити, вызвавшей функцию WSAWaitForMultipleEvents, возможность выходить из состояния ожидания не только при возникновении событий на сокете, но и по сигналам от других нитей. Типичная область применения этой возможности - для тех случаев, когда программа может как отвечать на запросы от удалённого партнёра, так и отправлять ему что-то по собственной инициативе. В этом случае могут использоваться два сокетных события: одно связывается с событием FD_Read для оповещения о поступлении данных, а второе не связывается ни с одним из событий FD_XXX, а устанавливается другой нитью тогда, когда необходимо отправить сообщение. Нить, работающая с сокетом, ожидает взведения одного из этих событий и в зависимости от того, какое из них взведено, читает или отправляет данные.

Ниже приведён пример кода такой нити. Она использует три сокетных события: одно для уведомления о событиях на сокете, второе - для уведомления о необходимости отправить данные, третье - для уведомления о необходимости завершиться. В данном примере мы предполагаем, что, во-первых, сокет создан и подключен до создания нити и передаётся ей в качестве параметра, а во-вторых, три сокетных события хранятся в глобальном массиве SockEvents:array[0..2] of TWSAEvent, причём нулевой элемент этого массива содержит событие, связываемое с событиями FD_XXX, первый элемент - событие отправки данных, второй - событие завершения нити. Прототип функции, образующей нить, совместим с функцией BeginThread из модуля SysUtils.

function ProcessSockEvents(Parameter:Pointer):Integer;
 var S:TSocket;
     NetworkEvents:TWSANetworkEvents;
  begin
   // Так как типы TSocket и Pointer занимают по 4 байта, такое
   // приведение типов вполне возможно, хотя и некрасиво
   S:=TSocket(Parameter);
   // Связываем событие SockEvents[0] с FD_Read и FD_Close
   WSAEventSelect(S,SockEvents[0],FD_Read or FD_Close);
   while True do
    begin
     case WSAWaitForMultipleEvents(3,@SockEvents[0],True,
                 WSA_Infinite,False) of
      WSA_Wait_Event_0:
       begin
        WSAEnumNetworkEvents(S,SockEvents[0],NetworkEvents);
        if NetworkEvents.lNetworkEvents and FD_Read>0 then
         if NetworkEvents.iErrorCode[FD_Read_Bit]=0 then
          begin
           // Пришли данные, которые надо прочитать
          end
         else
          begin
           // Произошла ошибка. Надо сообщить о ней и завершить нить
           CloseSocket(S);
           Exit
          end;
        if NetworkEvents.lNetworkEvents and FD_Close>0 then
         begin
          // Связь разорвана
          if NetworkEvents.iErrorCode[FD_Close_Bit]=0 then
           begin
            // Свзяь закрыта корректно
           end
          else
           begin
            // Связь разорвана в результате сбоя сети
           end;
          // В любом случае надо закрыть сокет и завершить нить
          CloseSocket(S);
          Exit
         end
       end;
      WSA_Wait_Event_0+1:
       begin
        // Получен сигнал о необходимости отправить данные
        // Здесь должен быть код отправки данных
        // После отправки событие надо сбросить вручную
        ResetEvent(SockEvents[1])
       end;
      WSA_Wait_Event_0+2:
       begin
        // Получен сигнал о необходимости завершения работы нити
        CloseSocket
        ResetEvents(SockEvents[2]);
        Exit
       end
     end
    end
  end;

Как и во всех предыдущих примерах, здесь для краткости не проверяются результаты, возвращаемые функциями и не отлавливаются возникающие ошибки. Кроме того, не используется процедура завершения связи с вызовом Shutdown.

Данный пример может рассматриваться как фрагмент кода простого сервера. В отдельной нити такого сервера выполняется цикл, состоящий из вызова Accept и создания новой нити для обслуживания полученного таким образом сокета. Затем другие нити при необходимости могут давать таким нитям команды (необходимо только предусмотреть для каждой нити, обслуживающей сокет, свою копию массива SockEvents). Благодаря этому каждый клиент будет обслуживаться независимо.

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

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

[ К содержанию ]

Перекрытый ввод-вывод

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

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

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

Перекрытый ввод-вывод существовал и в спецификации WinSock 1, но реализовывался только для линии NT. Специальных функций для перекрытого ввода-вывода в WinSock 1 не было, надо было использовать функции ReadFile и WriteFile, в которые вместо дескриптора файла подставлялся дескриптор сокета. В WinSock 2 появилась полноценная поддержка перекрытого ввода-вывода для всех версий Windows, а в спецификацию добавились новые функции для его реализации, избавившие от необходимости использования функций файлового ввода-вывода. В данной статье мы будем рассматривать перекрытый ввод-вывод только в спецификации WinSock 2, т.к. старый вариант из-за своих ограничений уже не имеет практического смысла.

Существуют два варианта уведомления о завершении операции перекрытого ввода-вывода: через событие и через процедуру завершения. Кроме того, программа может не дожидаться уведомления, а проверять состояние запроса перекрытого ввода-вывода с помощью функции WSAGetOverlappedResult (её мы рассмотрим позже).

Чтобы сокет мог использоваться в операциях перекрытого ввода-вывода, при его создании должен быть установлен флаг WSA_Flag_Overlapped (функция Socket неявно устанавливает этот флаг). Для выполнения операций перекрытого ввода-вывода сокет не нужно переводить в какой-либо особый режим, достаточно вместо обычных функций Send и Recv использовать WSARecv и WSASend. Сначала мы рассмотрим функцию WSARecv, которая имеет следующий прототип:

int WSARecv(
	SOCKET s,
	LPWSABUF lpBuffers,
	DWORD dwBufferCount,
	LPDWORD lpNumberOfBytesRecvd,
	LPDWORD lpFlags,
	LPWSAOVERLAPPED lpOverlapped,
	LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine);

function WSARecv(
	S:TSocket;
	lpBuffers:PWSABuf;
	dwBufferCount:DWORD;
	var NumberOfBytesRecvd:DWORD;
	var Flags:DWORD;
	lpOverlapped:PWSAOverlapped;
	lpCompletionRoutine:TWSAOverlappedCompletionRoutine):Integer;

Перекрытым вводом-выводом управляют два последних параметра функции, но функция WSARecv обладает и другими дополнительными по сравнению с функцией Recv возможностями, не связанными с перекрытым вводом-выводом. Если оба этих параметра равны nil, или сокет создан без указания флага WSA_Flag_Overlapped, функция работает в обычном блокирующем или неблокирующем режиме, который установлен для сокета. При этом её поведение отличается от поведения функции Recv только тремя незначительными аспектами: во-первых, вместо одного буфера ей можно передать несколько буферов, которые заполняются последовательно. Во-вторых, флаги передаются ей не как значение, а как параметр-переменная, и при некоторых условиях функция WSARecv может их изменять (при использовании TCP и UDP флаги никогда не меняются, поэтому мы не будем рассматривать здесь эту возможность). В-третьих, при успешном завершении функция WSARecv возвращает не количество прочитанных байт, а ноль, а количество прочитанных байт возвращается через параметр lpNumberOfBytesRecvd.

Буферы, в которые нужно поместить данные, передаются функции WSARecv через параметр lpBuffers. Он содержит указатель на начало массива структур TWSABuf, а параметр dwBufferCount - количество элементов в этом массиве. Выше мы знакомились со структурой TWSABuf: она содержит указатель на начало буфера и его размер. Соответственно, массив таких структур определяет набор буферов. При чтении данных заполнение буферов начинается с первого буфера в массиве lpBuffers, затем, если в нём не хватает места, используется второй буфер и т.д. Функция не переходит к следующему буферу, пока не заполнит предыдущий до последнего байта. Таким образом, данные, получаемые с помощью функции WSARecv, могут быть помещены в несколько несвязных областей памяти, что иногда бывает удобно, если принимаемые сообщения имеют строго определённый формат с фиксированными размерами компонентов пакета: в этом случае можно каждый компонент поместить в свой независимый буфер.

Теперь переходим непосредственно к рассмотрению перекрытого ввода-вывода с использованием событий. Для использования этого режима при вызове функции WSARecv параметр lpCompletionRoutine должен быть равен nil, а через параметр lpOverlapped передаётся указатель на структуру TWSAOverlapped, которая определена следующим образом:

typedef struct _WSAOVERLAPPED {
	DWORD        Internal;
	DWORD        InternalHigh;
	DWORD        Offset;
	DWORD        OffsetHigh;
	WSAEVENT     hEvent;
} WSAOVERLAPPED, *LPWSAOVERLAPPED;

type PWSAOverlapped=^TWSAOverlapped;
     TWSAOverlapped=packed record
                     Internal,InternalHigh,Offet,OffsetHigh:DWORD;
                     hEvent:TWSAEvent;
                    end;

Поля Internal, InternalHigh, Offset и OffsetHigh предназначены для внутреннего использования системой, программа не должна выполнять никаких действий с ними. Поле hEvent задаёт событие, которое будет взведено при завершении операции перекрытого ввода-вывода. Если на момент вызова функции WSARecv данные в буфере сокета отсутствуют, она вернёт значение Socket_Error, а функция WSAGetLastError - WSA_IO_Pending (997). Это значит, что операция начала выполняться в фоновом режиме. В этом случае функция WSARecv не изменяет значения параметров NumberOfBytesRecvd и Flag. Поля структуры TWSAOverlapped при этом также модифицируются, и эта структура должна быть сохранена программой в неприкосновенности до окончания операции перекрытого ввода-вывода без изменений. После окончания операции будет взведено событие, указанное в поле hEvent параметра lpOverlapped. При необходимости программа может дождаться этого взведения с помощью функции WSAWaitForMultipleEvents.

Как только запрос будет выполнен, в буферах, переданных через параметр lpBuffers, оказываются принятые данные. Но знания одного только факта, что запрос выполнен, недостаточно, чтобы этими данными воспользоваться, потому что, во-первых, неизвестен размер этих данных, а во-вторых, неизвестно, успешно ли завершена операция перекрытого ввода-вывода. Для получения недостающей информации служит функция WSAGetOverlappedResult, имеющая следующий прототип:

BOOL WSAGetOverlappedResult(
	SOCKET s,
	LPWSAOVERLAPPED lpOverlapped,
	LPDWORD lpcbTransfer,
	BOOL fWait,
	LPDWORD lpdwFlags);

function WSAGetOverlappedResult(
 	S:TSocket;
 	lpOverlapped:PWSAOverlapped;
 	var cbTransfer:DWORD;
 	fWait:BOOL;
 	var Flags:DWORD):BOOL;

Параметры S и lpOverlapped функции WSAGetOverlappedResult определяют сокет и операцию перекрытого ввода-вывода, информацию о которой требуется получить. Их значения должны совпадать со значениями соответствующих параметров, переданных функции WSARecv. Через параметр cbTransfer возвращается количество полученных байт, а через параметр Flags возвращаются флаги (напомню, что при использовании TCP и UDP флаги не модифицируются, и выходное значение параметра Flags будет равно входному значению параметра Flags функции WSARecv).

Допускается вызов функции WSAGetOverlappedResult до того, как операция перекрытого ввода-вывода будет завершена. В этом случае поведение функции зависит от параметра fWait. Если он равен True, функция переводит нить в состояние ожидания до тех пор, пока операция не будет завершена. Если он равен False, функция завершается немедленно с ошибкой WSA_IO_Incomplete (996).

Функция WSAGetOverlappedResult возвращает True, если операция перекрытого ввода-вывода успешно завершена, и False, если произошли какие-то ошибки. Ошибка может возникнуть в одном из трёх случаев:

  1. Операция перекрытого ввода-вывода ещё не завершена, а параметр fWait равен False.
  2. Операция перекрытого ввода-вывода завершилась с ошибкой (например, из-за разрыва связи).
  3. Параметры, переданные функции WSAGetOverlappedResult, имеют некорректные значения.

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

В принципе, программа может вообще не использовать события для отслеживания завершения операции ввода-вывода, а вызывать вместо этого время от времени функцию WSAGetOverlappedResul в удобные для себя моменты. При этом при вызове функции WSARecv можно указать нулевое значение события hEvent. Но следует иметь ввиду, что при вызове функции WSAGetOverlappedResult с параметром fWait, равным True, указанное событие используется для ожидания завершения операции, и если событие не задано, возникнет ошибка. Таким образом, если событие не используется, функция WSAGetOverlappedResult не может вызываться в режиме ожидания.

Отдельно рассмотрим ситуацию, когда на момент вызова функции WSARecv с ненулевым параметром lpOverlapped во входном буфере сокета есть данные. В этом случае функция отработает так же, как и в неперекрытом режиме, т.е. изменит значения параметров NumberOfBytesRecvd и Flags и вернёт ноль, свидетельствующий об успешном выполнении функции. Но при этом событие будет взведено, а в структуру lpOverlapped будет внесена вся необходимая информация. Благодаря этому последующие вызовы функций WSAWaitForMultipleEvents и WSAGetOverlappedResult будут выполняться корректно, т.е. таким образом, как если бы функция WSARecv завершилась с ошибкой WSA_IO_Pending, и сразу после этого в буфер сокета поступили данные. Это позволяет использовать один и тот же код для обработки результатов операций перекрытого ввода-вывода независимо от того, были ли в буфере сокета данные на момент начала операции или нет.

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

В MSDN'е мне не удалось найти информацию о том, что будет, если вызвать для сокета WSARecv повторно, до того как будет завершена предыдущая операция перекрытого чтения. Мои эксперименты показали, что в этом случае операции перекрытого чтения встают в очередь, т.е. первый полученный сокетом пакет приводит к завершению операции, начатой первой, второй пакет - операции, начатой второй, и т.д. Но я не знаю, документировано ли такое поведение операций перекрытого ввода-вывода где-либо, и поэтому рекомендую не предполагать, что они всегда будут выполняться таким образом.

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

var S:TSocket;
    Overlapped:TWSAOverlapped;
    BufPtr:TWSABuf;
    RecvBuf:array[1..100] of Char;
    Cnt,Flags:Cardinal;
 begin
  // Инициализация WinSock, создание сокета S, привязка его к адресу
  ......
  // Подготовка структуры, задающей буфер
  BufPtr.Buf:=@RBuf;
  BufPtr.Len:=SizeOf(RBuf);
  // Подготовка структуры TWSAOverlapped
  // Поля Internal, InternalHigh, Offset, OffsetHigh программа не устанавливает
  Overlapped.hEvent:=0;
  Flags:=0;
  // Начало операции перекрытого получения данных
  WSARecv(S,@BufPtr,1,Cnt,Flags,@Overlapped,nil);
  while True do
   begin
    if WSAGetOverlappedResult(S,@Overlapped,Cnt,False,Flags) then
     begin
      // Данные получены, находятся в RecvBuf, обрабатываем
      ......
      // Выходим из цикла
      Break
     end
    else if WSAGetLastError<>WSA_IO_Incomplete then
     begin
      // Произошла ошибка, анализируем её
      ......
      // Выходим из цикла
      Break
     end
    else
     begin
      // Операция чтения не завершена
      // Занимаемся другими действиями
     end
   end;

Теперь перейдём к рассмотрению перекрытого ввода-вывода с использованием процедур завершения. Для этого при вызове функции WSARecv нужно задать указатель на процедуру завершения, описанную в программе. Процедура завершения должна иметь следующий прототип:

void CALLBACK CompletionROUTINE(
	DWORD dwError,
	DWORD cbTransferred,
	LPWSAOVERLAPPED lpOverlapped,
DWORD dwFlags);

type TWSAOverlappedCompletionRoutine=procedure(
	dwError:DWORD;
	cdTransferred:DWORD;
	lpOverlapped:PWSAOverlapped;
	dwFlags:DWORD);stdcall;

При использовании процедур завершения в функцию WSARecv также нужно передавать указатель на структуру TWSAOverlapped через параметр lpOverlapped, но значение поля hEvent этой структуры игнорируется. Вместо взведения события при завершении операции будет вызвана процедура, указанная в качестве параметра функции WSARecv. Указатель структуру, заданный при вызове WSARecv, передаётся в процедуру завершения через параметр lpOverlapped. Смысл остальных параметров очевиден: dwError - это код ошибки (или ноль, если операция завершена успешно), cbTransferred - количество полученных байт (само полученное сообщение копируется в буфер, указанный функции WSARecv), а dwFlags - флаги.

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

function SleepEx(dwMilliseconds:DWORD;bAlertable:BOOL):DWORD;

Функция SleepEx является частью стандартного API системы и импортируется модулем Windows. Она переводит нить в состояние ожидания. Параметр dwMilliseconds задаёт время ожидания в миллисекундах (или значение Infinite для бесконечного ожидания). Параметр bAlertable указывает, допустимо ли прерывание состояния ожидания для выполнения процедуры завершения. Если bAlertable равен False, функция SleepEx ведёт себя так же, как функция Sleep, т.е. просто приостанавливает работу нити на заданное время. Если bAlertable равен True, нить может быть выедена системой из состояния ожидания раньше, чем истечёт заданное время, если возникнет необходимость выполнить процедуру завершения. О причине завершения ожидания программа может судить по результату, возвращаемому функцией SleepEx: ноль в случае завершения по таймауту и Wait_IO_Completion в случае завершения из-за выполнения процедуры завершения (в последнем случае сначала выполняется процедура завершения, а потом только происходит возврат из функции SleepEx). Если завершилось несколько операций перекрытого ввода-вывода, в результате выполнения SleepEx будут вызваны процедуры завершения для всех этих операций.

Существует также возможность ожидать выполнения процедуры завершения одновременно с ожиданием взведения событий с помощью функции WSAWaitForMultuipleEvents. Напомню, что у этой функции также есть параметр fAlertable. Если задать его равным True, то при необходимости выполнения процедуры завершения функция WSAWaitForMultipleEvents, подобно функции SleepEx, выполняет эту процедуру и возвращает Wait_IO_Completion.

Если программа выполняет одновременно несколько операций перекрытого ввода-вывода, встаёт вопрос, как при вызове процедуры завершения определить, какая из них завершилась. Для каждой такой операции должен быть создан уникальный экземпляр структуры TWSAOverlapped. Процедура завершения получает указатель на тот экземпляр, который использовался для начала завершившейся операции. Можно сравнить указатель с теми, которые использовались для запуска операций перекрытого ввода-вывода, и определить, какая из них завершилась. Это не всегда бывает удобно из-за необходимости где-то хранить список указателей, использовавшихся для операций перекрытого ввода-вывода. Существуют ещё два варианта решения этой проблемы. Первый вариант заключается в создании своей процедуры завершения для каждой из выполняющихся параллельно операций. Этот вариант приводит к получению громоздкого кода и может быть неудобен, если число одновременно выполняющихся операций заранее неизвестно. Его следует использовать только при одновременном выполнении разнородных опреаций, требующих разных алгоритмов при обработке их завершения. Другой вариант предлагается в MSDN'e. Так как при использовании процедур завершения значение поля hEvent структуры TWSAOverlapped игнорируется системой, программа может записать туда любое 32-битное значение и с его помощью определить, какая из операций завершена. В строго типизированном языке, каким является Паскаль, подобное смешение типа дескриптора и целого выглядит весьма непривлекательно, но, к сожалению, это лучшее из того, что нам предлагают разработчики WinSock API.

При использовании процедур завершения допустимо использование функции WSAGetOverlappedResult для определения статуса операции, но её параметр fWait обязательно должен быть равен False, потому что события, необходимые для выполнения ожидания, не взводятся, и попытка дождаться окончания операции может привести к блокировке работы нити.

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

var S:TSocket;
    Overlapped:TWSAOverlapped;
    BufPtr:TWSABuf;
    RecvBuf:array[1..100] of Char;
    Cnt,Flags:Cardinal;
    Connected:Boolean;

procedure GetData(Err,Cnt:DWORD;OvPtr:PWSAOverlapped;Flags:DWORD);stdcall;
 begin
  if Err<>0 then
   begin
    // Произошла ошибка. Соединение надо устанавливать заново
    CloseSocket(S);
    Connected:=False
   end
  else
   begin
    // Получены данные, обрабатываем
    ......
    // Запускаем новую операцию перекрытого чтения
    Flags:=0;
    WSARecv(S,@BufPtr,1,Cnt,Flags,OvPtr,GetData)
   end
 end;

procedure ProcessConnection;
 begin
  // Устанавливаем начальное состояние - сокет не соединён
  Connected:=False;
  // Задаём буфер
  BufPtr.Buf:=@RecvBuf;
  BufPtr.Len:=SizeOf(RecvBuf);
  while True do
   begin
    if not Connected then
     begin
      Connected:=True;
      // Создаём и подключаем сокет
      S:=Socket(AF_Inet,Sock_Stream,0);
      Connect(S,...);
      // Запускаем первую для данного сокета операцию чтения
      Flags:=0;
      WSARecv(S,@BufPtr,1,Cnt,Flags,@Overlapped,GetData)
     end;
    // Позволяем системе выполнить процедуру завершения,
    // если это необходимо
    SleepEx(0,True);
    // Выполняем какие-либо дополнительные действия
    ......
   end
 end;

Основной процедурой здесь является ProcessConnection. Эта процедура в бесконечном цикле устанавливает соединение, если оно не установлено, даёт системе выполнить процедуру завершения, если это требуется, и выполняет какие-либо иные действия, не связанные с получением данных от сокета. Процедура завершения GetData получает и обрабатывает данные, а если произошла ошибка, закрывает сокет и сбрасывает флаг Connected, что служит для процедуры ProcessConnection сигналом о необходимости установить соединение заново.

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

Для протоколов, не поддерживающих соединение, существует другая функция для перекрытого получения данных - WSARecvFrom. Из названия очевидно, что она позволяет узнать адрес отправителя. Прототип функции WSARecvFrom таков:

int WSARecvFrom(
	SOCKET s,
	LPWSABUF lpBuffers,
	DWORD dwBufferCount,
	LPDWORD lpNumberOfBytesRecvd,
	LPDWORD lpFlags,
	struct sockaddr FAR *lpFrom,
	LPINT lpFromlen,
	LPWSAOVERLAPPED lpOverlapped,
	LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine);

function WSARecvFrom(
	S:TSocket;
	lpBuffers:PWSABuf;
	dwBufferCount:DWORD;
	var NumberOfBytesRecvd:DWORD;
	var Flags:DWORD;
	lpFrom:PSockAddr;
	lpFromLen:PInteger;
	lpOverlapped:PWSAOverlapped;
	lpCompletionRoutine:TWSAOverlappedCompletionRoutine):Integer;

Параметры lpFrom и lpFromLen этой функции, служащие для получения адреса отправителя, эквиваленты соответствующим параметрам функции RecvFrom, которую мы разбирали в предыдущей статье. В остальном WSARecvFrom ведёт себя так же, как WSARecv, поэтому мы не будем останавливаться на ней.

Для отправки данных в режиме перекрытого ввода-вывода существуют функции WSASend и WSASendTo, имеющие следующие прототипы:

int WSASend(
	SOCKET s,
	LPWSABUF lpBuffers,
	DWORD dwBufferCount,
	LPDWORD lpNumberOfBytesSent,
	DWORD dwFlags,
	LPWSAOVERLAPPED lpOverlapped,
	LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine);

function WSASend(
	S:TSocket;
	lpBuffers:PWSABuf;
	dwBufferCount:DWORD;
	var NumberOfBytesRecvd:DWORD;
	Flags:DWORD;
	lpOverlapped:PWSAOverlapped;
	lpCompletionRoutine:TWSAOverlappedCompletionRoutine):Integer;

int WSASend(
	SOCKET s,
	LPWSABUF lpBuffers,
	DWORD dwBufferCount,
	LPDWORD lpNumberOfBytesSent,
	DWORD dwFlags,
	const struct sockaddr FAR *lpTo,
	int iToLen,
	LPWSAOVERLAPPED lpOverlapped,
	LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine);

function WSASendTo(
	S:TSocket;
	lpBuffers:PWSABuf;
	dwBufferCount:DWORD;
	var NumberOfBytesRecvd:DWORD;
	Flags:DWORD;
	var AddrTo:TSockAddr;
	ToLen:Integer;
	lpOverlapped:PWSAOverlapped;
	lpCompletionRoutine:TWSAOverlappedCompletionRoutine):Integer;

Если вы разобрались с функциями WSARecv, Send и SendTo, то смысл параметров функций WSASend и WSASendTo должен быть вам очевиден, поэтому подробно разбирать мы их не будем. Но отметим, что флаги передаются по значению, и функции не могут изменять их.

Потребность в использовании перекрытого ввода-вывода при отправке данных возникает достаточно редко. Но функции WSASend/WSASendTo могут оказаться удобны при подготовке многокомпонентных пакетов, которые, например, имеют фиксированный заголовок и финальную часть. Для таких пакетов можно один раз подготовить буферы с заголовком и с финальной частью и, пользуясь возможностью отправки данных из несвязных буферов, при отправке каждого пакета менять только его среднюю часть.

[ К содержанию ]

Многоадресная рассылка

В предыдущей статье мы упоминали протокол IGMP - дополнение к протоколу IP, позволяющее назначать нескольким узлам групповые адреса. С помощью этого протокола можно группе сокетов назначить один IP-адрес, и тогда все пакеты, отправленные на этот адрес, будут получать все сокеты, входящие в группу. Заметим, что не следует путать группы сокетов в терминах IGMP, и группы сокетов в терминах WinSock (поддержка групп сокетов в WinSock пока отсутствует, существуют только зарезервированные для этого параметры в некоторых функциях).

В предыдущей статье мы говорили, что сетевая карта получает все IP-пакеты, которые проходят через её подсеть, но выбирает из них только те, которые соответствуют назначенному её MAC- и IP-адресу. Существуют два режима работы сетевых карт. В первом выборка пакетов осуществляется аппаратными средствами карты, во втором - программными средствами драйвера. Аппаратная выборка осуществляется быстрее и не загружает центральный процессор, но её возможности ограничены. В частности, у некоторых старых карт отсутствует аппаратная поддержка IGMP, поэтому они не могут получать пакеты, отправленные на групповой адрес, без переключения в режим программной выборки. Более современные сетевые карты способны запоминать несколько (обычно 16 или 32) групповых адресов, и, пока количество групповых адресов не превышает этот предел, могут осуществлять аппаратную выборку пакетов с учётом групповых адресов.

Windows 95 и NT 4 используют сетевые карты в режиме программной выборки пакетов. Windows 98 и 2000 по умолчанию используют сетевые карты в режиме аппаратной выборки пакетов. При этом Windows 2000 может переключать карту в режим программной выборки, если число групповых адресов, с которых компьютер должен принимать пакеты, превышает её аппаратные возможности. Windows 98 такой возможностью не обладает, поэтому программа, выполняемая в этой среде, может столкнуться с ситуацией, когда сокет не сможет присоединиться к групповому адресу из-за нехватки аппаратных ресурсов сетевой карты (программа при этом получит ошибку WSAENoBufs).

WinSock предоставляет достаточно широкие возможности по управлению многоадресной рассылкой, но для их использования необходимо, чтобы выбранный сетевой протокол поддерживал все эти возможности. Поддержка многоадресной рассылки протоколом IP достаточно скудна по сравнению, например, с протоколами, применяющимися в сетях ATM. Здесь мы будем рассматривать только те возможности WinSock по поддержке многоадресной рассылки, которые могут быть использованы с протоколом IP.

Протокол IP не поддерживает многоадресную рассылку при использовании TCP, поэтому всё, что будет сказано ниже, относится только к протоколу UDP. Отметим также, что при использовании многоадресной рассылки через границы подсетей роутеры должны поддерживать передачу многоадресных пакетов.

Глава "Многоадресная рассылка" в рекомендованной выше книге Джонса и Оланда, к сожалению, содержит множество неточностей. Далее я буду обращать внимание на эти неточности, чтобы облегчить чтение этой книги.

Многоадресная рассылка в IP является одноранговой и в плоскости управления, и в плоскости данных (в книге Джонса и Оланда вместо "одноранговая" используется слово "немаршрутизируемая" - видимо, переводчик просто перепутал слова nonrooted и nonrouted). Это значит, что все сокеты, участвующие в ней, равноправны. Каждый сокет без каких-либо ограничений может подключиться к многоадресной группе и получать все сообщения, отправленные на групповой адрес. При этом послать сообщение на групповой адрес может любой сокет, в т.ч. и не входящий в группу. Для групповых адресов протокол IP использует диапазон от 224.0.0.0 до 239.255.255.255. Часть из этих адресов зарезервирована для стандартных служб, поэтому своим группам лучше назначать адреса, начиная с 225.0.0.0. Кроме того, весь диапазон от 224.0.0.0 до 224.0.0.255 зарезервирован для групповых сообщений, управляющих роутерами, поэтому сообщения, отправленные на эти адреса, никогда не передаются в соседние подсети.

Есть два варианта осуществления многоадресной рассылки с использованием IP средствами WinSock. Первый вариант связан с использованием WinSock 1 и жёстко привязан к протоколу IP. Второй вариант подразумевает использование WinSock 2 и осуществляется универсальными, не привязанными к конкретному протоколу средствами.

Если рассылка будет осуществляться средствами WinSock 1, то сокет, участвующий в ней, создаётся обычным образом - с помощью функции WSASocket со стандартным набором флагов или с помощью функции Socket с обычными параметрами, задаваемыми при создании UDP-сокета. Если же используется WinSock 2, то сокет должен быть создан с указанием его роли в плоскостях управления и данных. Так как многоадресная рассылка в IP является одноранговой, все сокеты, участвующие в ней, могут быть только "листьями", поэтому сокет для рассылки должен создаваться функцией WSASocket с указанием флагов WSA_Flag_Multipoint_C_Leaf (4) и WSA_Flag_Multipoint_D_Leaf (16). В книге Джонса и Оланда на странице 313 написано, что для рассылки средствами WinSock 2 можно создавать сокет функцией Socket - это неверно. Впрочем, на странице 328 всё-таки написано, что указанные флаги использовать обязательно.

Далее сокет, который планируется добавить в группу, привязывается к любому локальному порту обычным способом - с помощью функции Bind. Этот шаг ничем не отличается от привязки к адресу обычного сокета, не использующего групповой адрес.

Затем выполняется собственно добавление сокета в группу. В WinSock 1 для этого надо использовать функцию SetSockOpt с параметром IP_Add_Membership, указав в качестве уровня IPProto_IP. При этом через параметр OptVal передаётся указатель на структуру ip_mreq, описанную следующим образом:

struct ip_mreq {
	struct in_addr imr_multiaddr;
	struct in_addr imr_interface;
}

type TIPMreq=packed record
              IMR_MultiAddr:TSockAddr;
              IMR_Interface:TSockAddr
             end;

Поле IMR_MultiAddr задаёт групповой адрес, к которому присоединяется сокет. У этой структуры должны быть заполнены поля sin_family (значением AF_Inet) и sin_addr. Номер порта здесь указывать не надо, значение этого поля игнорируется. Поле IMR_Interface определяет адрес сетевого интерфейса, через который будет вестись приём многоадресной рассылки. Если программу устраивает интерфейс, выбираемый системой по умолчанию, значение поля IMR_Interface.sin_addr должно быть InAddr_Any (на компьютерах с одним сетевым интерфейсом обычно используется именно это значение). Но если у компьютера несколько сетевых интерфейсов, которые связывают его с разными сетями, интерфейс для получения групповых пакетов, выбираемый системой по умолчанию, может быть связан не с той сетью, из которой они реально ожидаются. В этом случае программа может явно указать IP-адрес того интерфейса, через который данный сокет должен принимать групповые пакеты. Как и в поле IMR_MultiAddr, в поле IMR_Interface используются только поля sin_family и sin_addr, а остальные поля игнорируются.

Для прекращения членства сокета в группе используется та же функция SetSockOpt, но с параметром IP_Drop_Membership. Через параметр OptVal при этом также передаётся структура ip_mreq, значимые поля которой должны быть заполнены так же, как они были заполнены при добавлении данного сокета в данную группу.

Несмотря на то, что структура ip_mreq относится к WinSock 1, в модуле WinSock её описание отсутствует. Константы IP_Add_Membership и IP_Drop_Membership в этом модуле объявлены, но использовать их следует с осторожностью, потому что они должны иметь разные значения в WinSock 1 и WinSock 2. В WinSock 1 они должны иметь значения 5 и 6 соответственно, а в WinSock 2 - 12 и 13. Из-за этого нужно внимательно следить, чтобы использовались значения, соответствующие той библиотеке, из которой импортируется функция SetSockOpt: 5 и 6 при использовании WSock32.dll и 12 и 13 при использовании WS2_32.dll.

В WinSock 2 для присоединения сокета к группе объявлена функция WSAJoinLeaf, имеющая следующий прототип:

SOCKET WSAJoinLeaf(
	SOCKET s,
	const struct sockaddr FAR *name,
	int namelen,
	LPWSABUF lpCallerData,
	LPWSABUF lpCalleeData,
	LPQOS lpSQOS,
	LPQOS lpGQOS,
	DWORD dwFlags);

function WSAJoinLeaf(
	S:TSocket;
	var Name:TSockAddr;
	NameLen:Integer;
	lpCallerData,lpCalleeData:PWSABuf;
	lpSQOS,lpGQOS:PQOS;
	dwFlags:DWORD):TSocket;

Параметры lpCallerData и lpCalleeData задают буферы, в которые помещаются данные, передаваемые и получаемые при присоединении к группе. Протокол IP не поддерживает передачу таких данных, поэтому при его использовании эти параметры должны быть равны nil. Параметры lpSQOS и lpGQOS относятся к качеству обслуживания, которое мы здесь не рассматриваем, поэтому их мы тоже полагаем равными nil.

Параметр S определяет сокет, который присоединяется к группе, параметр Name - адрес группы, NameLen - размер буфера с адресом. Параметр dfFlags определяет, будет ли сокет использоваться для отправки данных (JL_Sender_Only, 1), для получения данных (JL_Receiver_Only, 2) или и для отправки, и для получения (JL_Both, 4).

Функция возвращает сокет, который создан для взаимодействия с группой. В протоколах типа ATM подключение к группе похоже на установление связи в TCP, и функция WSAJoinLeaf, подобно функции Accept, создаёт новый сокет, подключенный к группе. При использовании UDP новый сокет не создаётся, и функция WSAJoinLeaf возвращает значение переданного ей параметра S.

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

Чтобы прекратить членство сокета в группе, в которую он был добавлен с помощью WSAJoinLeaf, нужно закрыть его с помощью функции CloseSocket.

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

UPD-сокет, присоединившийся к многоадресной группе, не должен "подключаться" к какому-либо адресу с помощью функции Connect или WSAConnect. Соответственно, для отправки данных такой сокет может использовать только SendTo и WSASendTo. Сокет, присоединившийся к группе, может отправлять данные на любой адрес, но если используется поддержка качества обслуживания, она работает только при отправке данных на групповой адрес сокета.

Отправка данных на групповой адрес не требует присоединения к группе, причём для сокета, отправляющего данные, нет никакой разницы между отправкой данных на обычный адрес и на групповой. И в том, и в другом случае используется функция SendTo или WSASendTo (или Send/WSASend с предварительным вызовом Connect). Никаких дополнительных действий для отправки данных на групповой адрес выполнять не надо. Порт при этом также указывается. Как мы видели выше, номер порта при добавлении сокета в группу не указывается, но сам сокет перед этим должен быть привязан к какому-либо порту. При отправке группового сообщения его получат только те сокеты, входящие в группу, чей порт привязки совпадает с портом, указанным в адресе назначения сообщения.

Если сокет, отправляющий сообщение на групповой адрес, сам является членом этой группы, он, в зависимости от настроек, может получать или не получать своё сообщение. Это определяется его параметром IP_Mulitcast_Loop, имеющим тип Bool. По умолчанию этот параметр равен True - это значит, что сокет будет получать свои собственные сообщения. С помощью функции SetSockOpt можно изменить значение этого параметра на False, и тогда сокет не будет принимать свои сообщения.

Параметром IP_Multicast_Loop следует пользоваться осторожно, т.к. он не поддерживается в Windows NT 4 и требует Windows 2000 или выше. В Windows 9x/ME он тоже не поддерживается (хотя упоминания об этом в MSDN'е нет).

В предыдущей статье мы говорили, что каждый IP-пакет в своём заголовке имеет целочисленный параметр TTL (Time To Live). Его значение определяет, сколько роутеров может пройти данный пакет. По умолчанию групповые пакеты имеют TTL, равный 1, т.е. могут распространятся только в пределах непосредственно примыкающих подсетей. Целочисленный параметр сокета IP_Milticast_TTL позволяет программе изменить это значение.

У функции WSAJoinLeaf не предусмотрены параметры для задания адреса сетевого интерфейса, через который следует получать групповые сообщения, поэтому всегда используется интерфейс, выбираемый системой для этих целей по умолчанию. Выбрать интерфейс, который система будет использовать по умолчанию, можно с помощью параметра сокета IP_Multicast_If. Этот параметр имеет тип TSockAddr, причём значимыми полями структуры в данном случае являются Sin_Family и Sin_Addr, а значение поля Sin_Port игнорируется.

Значения констант IP_Multicast_If, IP_Multicast_TTL и IP_Multicast_Loop также зависят от версии WinSock. В WinSock 1 они должны быть равны 2, 3 и 4, а в WinSock 2 - 9, 10 и 11 соответственно.

[ К содержанию ]

Дополнительные функции

В этом разделе мы рассмотрим некоторые функции, относящиеся в WinSock к дополнительным. В WinSock 1 эти функции вместе со всеми остальными экспортируются библиотекой WSock32.dll, а в WinSock 2 они вынесены в отдельную библиотеку MSWSock.dll (в эту же библиотеку вынесены некоторые устаревшие функции типа EnumProtocols).

Начнём мы знакомство с этими функциями с функции WSARecvEx (которая, кстати, является расширенной версией функции Recv, а отнюдь не WSARecv, как это можно заключить из её названия), имеющей следующий прототип

function WSARecvEx(S:TSocket;var Buf;Len:Integer;var Flags:Integer):Integer;

Видно, что эта функция отличается от обычной функции Recv только тем, что флаги передаются через параметр-переменную вместо значения. В функции WSARecvEx этот параметр является не только входным, но и выходным; функция может модифицировать его. Ранее мы познакомились с функцией WSARecv, которая также может модифицировать переданные ей флаги, но условия, при которых эти две функции модифицируют флаги, различаются.

При использовании TCP (а также любого другого потокового протокола) флаги не изменяются функцией, и результат работы WSARecvEx эквивалентен результату работы Recv.

В предыдущей статье мы обсуждали, что дейтаграмма UDP должна быть прочитана из буфера сокета целиком. Если в буфере, переданном функции Recv или RecvFrom недостаточно места для получения дейтаграммы, эти функции завершаются с ошибкой. При этом в буфер помещается та часть дейтаграммы, которая может в нём поместиться, а оставшаяся часть дейтаграммы теряется. Функция WSARecvEx отличается от Recv только тем, что в случае, когда размер буфера меньше размера дейтаграммы, она завершается без ошибки (возвращая при этом размер прочитанной части дейтаграммы, т.е. размер буфера) и добавляет флаг Msg_Partial к параметру Flags. Остаток дейтаграммы при этом также теряется. Таким образом, WSARecvEx даёт альтернативный способ проверки того, что дейтаграмма не поместилась в буфер, и в некоторых случаях этот способ может оказаться удобным.

Если при вызове функции WSARecvEx флаг Msg_Partial установлен программой, но дейтаграмма поместилась в буфер целиком, функция сбрасывает этот флаг.

В описании функции WSARecvEx в MSDN'е можно прочитать, что если дейтаграмма прочитана частично, то следующий вызов функции позволит прочитать оставшуюся часть дейтаграммы. Это не относится к протоколу UDP и имеет место быть только при использовании протоколов типа SPX, в которых одна дейтаграмма может разбиваться на несколько сетевых пакетов, и поэтому возможна такая ситуация, когда в буфере сокета окажется только часть дейтаграммы. В UDP, напомню, дейтаграмма всегда посылается одним IP-пакетом и помещается в буфер сразу целиком.

WSARecvEx не позволяет программе определить, с какого адреса прислана дейтаграмма, а аналога функции RecvFrom с такими же возможностями в WinSock нет.

Мы уже упоминали о том, что в WinSock 1 существует перекрытый ввод-вывод, но только для систем линии NT. Также в WinSock 1 определена функция AcceptEx, которая является более мощным эквивалентом функции Accept, и позволяет принимать входящие соединения в режиме перекрытого ввода-вывода. В WinSock 1 эта функция не поддерживается в Windows 95, в WinSock 2 она доступна во всех системах. Её прототип выглядит следующим образом:

function AcceptEx(
	sListenSocket,sAcceptSocket:TSocket;
	lpOutputBuffer:Pointer;
 	ReceiveDataLength,LocalAddressLength,RemoteAddressLength:DWORD;
	var lpdwBytesReceived:DWORD;
	lpOverlapped:POverlapped):BOOL;

Функция AcceptEx позволяет принять новое подключение со стороны клиента и сразу же получить от него первую порцию данных. Функция работает только в режиме перекрытого ввода-вывода.

Параметр sListenSocket определяет сокет, который должен находиться в режиме ожидания подключения. Параметр sAcceptSocket - сокет, через который будет осуществляться связь с подключившимся клиентом. Напомню, что функции Accept и WSAAccept сами создают новый сокет. При использовании же AcceptEx программа должна заранее создать сокет и, не привязывая его к адресу, передать в качестве параметра sAcceptSocket.

Параметр lpOutputBufer задаёт указатель на буфер, в который будут помещены, во-первых, данные, присланные клиентом, а во-вторых, адреса подключившегося клиента и адрес, к которому привязывается сокет sAcceptSocket. Параметр ReceiveDataLength задаёт количество байт в буфере, зарезервированных для данных, присланных клиентом, LocalAddressLength - для адреса привязки сокета sAcceptSocket, RemoteAddressLength - адреса подключившегося клиента. Если параметр ReceiveDataLength равен нулю, функция не ждёт, пока клиент пришлёт данные, и считает операцию завершившейся сразу после подключения клиента, как функция Accept. Для адресов требуется резервировать как минимум на 16 байт больше места, чем реально требуется. Так как размер структуры TSockAddr составляет 16 байт, на каждый из адресов требуется зарезервировать как минимум 32 байта.

Параметр lpdwBytesReceived используется функцией, чтобы вернуть количество байт, присланных клиентом.

Параметр lpOverlapped указывает на структуру TOverlapped, определённую в модуле Windows следующим образом:

type POverlapped=^TOverlapped;
     _OVERLAPPED=record
                  Internal:DWORD;
                  InternalHigh:DWORD;
                  Offset:DWORD;
                  OffsetHigh:DWORD;
                  hEvent:THandle;
                 end;
    TOverlapped=_OVERLAPPED;

Структура TOverlapped используется, в основном, для перекрытого ввода-вывода в файловых операциях. Видно, что она отличается от уже знакомой нам структуры TWSAOverlapped только типом параметра hEvent - THandle вместо TWSAEvent. Впрочем, выше мы уже обсуждали, что TWSAEvent - это синоним THandle, так что можно сказать, что эти структуры идентичны (но компилятор подходит к этому вопросу формально и считает их разными).

Параметр lpOverlapped функции AcceptEx не может быть равным nil, а его поле hEvent должно указывать на корректное событие. Использование процедур завершения не предусмотрено. Если на момент вызова функции клиент уже подключился и прислал первую порцию данных (или место для данных в буфере не зарезервировано), AcceptEx возвращает True. Если же клиент ещё не подключился, или подключился, но не прислал данные, функция AcceptEx возвращает False, а WSAGetLastError - Error_IO_Pending. Параметр lpdwBytesReceived в этом случае остаётся без изменений.

Для контроля состояния операции можно использовать функцию GetOverlappedResult, которая является аналогом известной нам функции WSAGetOverlappedResult, за исключением того, что использует структуру TOverlapped вместо TWSAOverlapped и не предусматривает передачу флагов. С помощью этой функции можно узнать, завершилась ли операция, а также дождаться её завершения и узнать, сколько байт прислано клиентом (функция AcceptEx не ждёт, пока клиент заполнит весь буфер, предназначенный для него - для завершения операции подключения достаточно первого пакета).

Если к серверу подключаются некорректно работающие клиенты, которые не присылают данные после подключения, операция может не завершаться очень долго, что будет мешать подключению новых клиентов. MSDN рекомендует при ожидании время от времени с помощью функции GetSockOpt для сокета sAcceptSocket узнавать значение целочисленного параметра SO_Connect_Time уровня SOL_Socket. Этот параметр показывает время в секундах, прошедшее с момента подключения клиента к данному сокету (или -1, если подключения не было). Если подключившийся клиент слишком долго не присылает данных, сокет sAcceptSocket следует закрыть, что приведёт к завершению операции, начатой AcceptEx, с ошибкой. После этого можно снова вызывать AcceptEx для приёма новых клиентов.

Функция AcceptEx реализована таким образом, чтобы обеспечивать максимальную скорость подключения. Выше мы говорили, что сокеты, созданные функциями Accept и WSAAccept, наследуют параметры слушающего сокета (например, свойства асинхронного режима). Для повышения производительности сокет sAcceptSocket по умолчанию не получает свойств сокета sListenSocket. Но он может унаследовать их после завершения операции с помощью следующей команды:

SetSockOpt(sAcceptSocket,SOL_Socket,SO_Update_Accept_Context,
           PChar(@sListenSocket),SizeOf(sListenSocket));

На сокет sAcceptedSocket после его подключения к клиенту накладываются ограничения: он может использоваться не во всех функциях WinSock, а только в следцющих: Send, WSASend, Recv, WSARecv, ReadFile, WriteFile, TransmitFile, CloseSocket и SetSockOpt, причём в последней - только для установки параметра So_Update_Accept_Context.

В WinSock не документируется, в какую именно часть буфера помещаются адрес клиента и принявшего его сокета. Вместо этого предоставляется функция GetAcceptExSockAddrs. В модуле WinSock она объявлена следующим образом:

procedure GetAcceptExSockaddrs(
	lpOutputBuffer:Pointer;
	dwReceiveDataLength,dwLocalAddressLength,dwRemoteAddressLength:DWORD;
	var LocalSockaddr:TSockAddr;
	var LocalSockaddrLength:Integer;
	var RemoteSockaddr:TSockAddr;
	var RemoteSockaddrLength:Integer);

В объявлении этой функции разработчики модуля WinSock допустили ошибку. Сравним это объявление с прототипом из MSDN'а:

VOID GetAcceptExSockaddrs(
	PVOID lpOutputBuffer,
	DWORD dwReceiveDataLength,
	DWORD dwLocalAddressLength,
	DWORD dwRemoteAddressLength,
	LPSOCKADDR *LocalSockaddr,
	LPINT LocalSockaddrLength,
	LPSOCKADDR *RemoteSockaddr,
	LPINT RemoteSockaddrLength);

Из этого объявления видно, что параметры LocalSockaddr и RemoteSockaddr являются двойными указателями на структуру SOCKADDR, поэтому, чтобы объявление функции в Delphi соответствовало этому прототипу, параметры-переменные LocalSockaddr и RemoteSockaddr должны иметь тип PSockAddr, а не TSockAddr. Из-за этой ошибки функцию GetAcceptExSockAddrs необходимо самостоятельно импортировать. Следует заметить, что во многих модулях для WinSock 2 от независимых разработчиков объявление этой функции скопировано из стандартного модуля вместе с ошибкой.

Первые четыре параметра функции GetAcceptExSockaddrs определяют буфер, в котором в результате вызова AcceptEx оказались данные от клиента и адреса, и размеры частей буфера, зарезервированных для данных и для адресов. Значения этих параметров должны совпадать со значениями аналогичных параметров в соответствующем вызове AcceptEx. Через параметр LocalSockaddrs возвращается указатель на то место в буфере, в котором хранится адрес привязки сокета sAcceptSocket, а через параметр LocalSockaddrsLength - длина адреса (16 в случае TCP). Адрес клиента и его длина возвращаются через параметры RemoteSockaddrs и RemoteSockaddrsLength. Хочу особенно подчеркнуть, что указатели LocalSockaddrs и RemoteSockaddrs указывают именно на соответствующие части буфера: память для них специально не выделяется и, следовательно, не должна освобождаться, а свою актуальность они теряют при освобождении буфера.

Последняя из дополнительных функций, TransmitFile, служит для передачи файлов по сети. Она имеет следующий прототип:

function TransmitFile(
	hSocket:TSocket;
	hFile:THandle;
	nNumberOfBytesToWrite,nNumberOfBytesPerSend:DWORD;
	lpOverlapped:POverlapped;
	lpTransmitBuffers:PTransmitFileBuffers;
	dwReserved:DWORD):BOOL;

Функция TransmitFile оправляет содержимое указанного файла через указанный сокет. При этом допускается использовать только протоколы, поддерживающие соединение, т.е. использовать данную функцию с UDP-сокетом нельзя. Сокет задаётся параметром hSocket, файл - параметром hFile. Дескриптор файла обычно получается с помощью функции стандартного API CreateFile. Файл рекомендуется открывать с флагом File_Flag_Sequential_Scan, т.к. это повышает производительность.

Параметр nNumberOfBytesToWrite определяет, сколько байт должно быть передано (позволяя, тем самым, передавать не весь файл, а только его часть). Если этот параметр равен нулю, передаётся весь файл.

Функция TransmitFile кладёт данные из файла в буфер сокета по частям. Параметр nNumberOfBytesPerSend определяет размер одной порции данных. Он может быть равен нулю - в этом случае система сама определяет размер порции. Этот параметр критичен только при использовании дейтаграммных протоколов, потому что в этом случае размер порции определяет размер дейтаграммы. В случае использования TCP данные, хранящиеся в буфере, передаются в сеть целиком или по частям в зависимости от загрузки сети, готовности принимающей стороны и т.п., а то, какими порциями они попали в буфер, на размер пакета почти не влияет. Поэтому для TCP-сокета параметр nNumberOfBytesPerSend лучше установить равным нулю.

Параметр lpOverlapped указывает на структуру TOverlapped, использующуюся для перекрытого ввода-вывода. Эту структуру мы обсуждали чуть выше, при описании функции AcceptEx. В отличие от AcceptEx, в TarnsmitFile этот параметр может быть равным nil, и тогда операция передачи файла не будет перекрытой.

Если параметр lpOverlapped равен nil, передача файла начинается с той позиции, на которую указывает файловый указатель (для только что открытого файла этот указатель указывает на его начало, а переместить его можно, например, с помощью функции SetFilePointer; также он перемещается при чтении файла с помощью ReadFile). Если же параметр lpOverlapped задан, то передача файла начинается с того байта, который определяется значениями полей Offset и OffsetHigh, которые должны содержать соответственно младшую и старшую часть 64-битного смещения стартовой позиции от начала файла.

Параметр lpTransmitBuffers является указателем на структуру TTransmitFileBuffers, объявленную следующим образом:

type PTransmitFileBuffers=^TTransmitFileBuffers;
     _TRANSMIT_FILE_BUFFERS=record
                             Head:Pointer;
                             HeadLength:DWORD;
                             Tail:Pointer;
                             TailLength:DWORD;
                            end;
    TTransmitFileBuffers=_TRANSMIT_FILE_BUFFERS;

С её помощью можно указывать буферы, содержащие данные, которые должны быть отправлены перед передачей самого файла и после него. Поле Head содержит указатель на буфер, содержащий данные, предназначенные для отправки перед файлом, HeadLength - размер этих данных. Аналогично Tail и TailLength определяют начало и длину буфера с данными, которые передаются после передачи файла. Если передача дополнительных данных не нужна, параметр lpTransmitBuffer может быть равен nil.

Допускается и обратная ситуация: параметр hFile может быть равен нулю, и тогда передаются только данные, определяемые параметром lpTransmitBuffer.

Последний параметр функции TransmitFile в модуле WinSock имеет имя Reserverd. В WinSock 1 он и в самом деле был зарезервирован и не имел смысла, но в WinSock 2 через него передаются флаги, управляющие операцией передачи файла. Мы не будем приводить здесь полный список возможных флагов (он есть в MSDN'е), а ограничимся лишь самыми важными. Указание флага TF_Use_Default_Worker или TF_Use_System_Thread позволяет повысить производительность при передаче больших файлов, а TF_Use_Kernel_APC - при передаче маленьких файлов. Вообще, при использовании функции TransmitFile чтение файла и передачу данных в сеть осуществляет ядро операционной системы, что приводит к повышению быстродействия по сравнению с использованием ReadFile и Send самой программой.

Функция TransmitFile реализована по-разному в серверных версиях NT/2000 и в остальных системах: в серверных версиях она оптимизирована по быстродействию, а в остальных - по количеству используемых ресурсов.

Данные, переданные функцией TransmitFile, удалённая сторона должна принимать обычным образом, с помощью функций Recv/WSARecv.

[ К содержанию ]

Заключение

На этом мы заканчиваем рассмотрение WinSock. Многие возможности этого стандарта остались нерассмотренными и даже неупомянутыми. Но если описывать их все, получится не статья, а большая книга. При выборе разделов, которые должны войти в данную статью, я руководствовался следующими соображениями. Во-первых, должны быть описаны все те средства, которые используются стандартными классами VCL Delphi, реализующими сокеты. Мы будем рассматривать их в следующих статьях, и нужно будет знать те возможности системы, обёрткой для которых они являются. Во-вторых, я описал здесь также те темы, которые лично мне показались наиболее интересными с точки зрения практического применения. Второй критерий, конечно же, весьма субъективен, но я надеюсь, что всё-таки многим из читателей статья окажется полезной.

Ещё раз хочу напомнить, что желательно не ограничиваться тем описанием функций, которые приведены в этой статье, но и самостоятельно читать MSDN. Также ещё раз рекомендую прочитать книгу Э. Джонса и Д. Оланда "Программирование в сетях Microsoft Widows" (или хотя бы посмотреть её содержание в Интернете, чтобы представлять, сколько аспектов использования WinSock остались вам неизвестными после прочтения данной статьи).

Сама идея этого цикла статей возникла у меня под влиянием впечатлений, полученных при самостоятельном освоении сокетов по MSDN'у и по литературе, относящейся к сокетам Беркли (книга Джонса и Оланда тогда ещё не вышла). Самой большой проблемой для меня оказалось собрать разрозненные сведения в единую картину, а также понять, что в статьях MSDN'а имеет отношение к протоколам TCP и UDP, а что - только к протоколам из других семейств. В своих статьях я старался изложить материал последовательно, чтобы неподготовленный человек мог постигать его постепенно, а не получать мозаику, из которой надо мучительно складывать картинку. Надеюсь, что у меня это получилось и что тем, кто начнёт изучение сокетов с моих статей, будет немного легче, чем мне в своё время.

Напоследок хочу заметить, что компьютер - это строго детерминированная система, в поведении которой нет ничего непредсказуемого, если знать его внутренние механизмы. Именно поэтому я по возможности рассказывал не только о том, что надо сделать, чтобы получить результат, но и о том, как и почему такой результат получается. Чтобы человек, столкнувшийся с нестандартной ситуацией, мог разобраться, что и как он должен делать. Вполне допускаю, что большинство моих читателей будет использовать сокеты только стандартными способами, и информация о внутренней работе WinSock не будет иметь для них практического смысла. Но я глубоко убеждён, что программист, в отличие от ламера, должен понимать, что стоит за тем кодом, который он написал. И если избыточная информация отпугнёт ламеров от моих статей, я буду только рад этому.

В поиске информации для этой статьи мне помогали участники форумов сайта "Мастера Delphi":
Rouse_ , Digitman, Verg, wisekaa и Bless.

Огромное всем спасибо!

[ К содержанию ]

Антон Григорьев,
Специально для Королевства Delphi