2.1.13. Определение готовности сокета

2.1.13. Определение готовности сокета

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

function select(nfds: Integer; readfds, writefds, exceptfds: PFDSet; timeout: PTimeVal): LongInt;

Первый параметр этой функции оставлен только для совместимости со старыми версиями библиотеки сокетов: в существующих версиях он игнорируется. Три следующих параметра содержат указатели на множества сокетов (эти множества описываются типом TFDSet), состояние которых должно проверяться. В данном случае понятие множества не имеет ничего общего с типом множество в Delphi. В оригинальной версии библиотеки сокетов, написанной на C, определены макросы, позволяющие очищать такие множества, добавлять и удалять сокеты и определять, входит ли тот или иной сокет в множество. В модуле WinSock эти макросы заменены одноименными процедурами и функциями (листинг 2.22).

Листинг 2.22. Функции для работы с типом TFDSet

// Удаляет сокет Socket из множества FDSet.

procedure FD_CLR(Socket: TSocket; var FDSet: TFDSet);

// Определяет, входит ли сокет Socket в множество FDSet.

function FD_ISSET(Socket: TSocket; var FDSet: TFDSet): Boolean;

// Добавляет сокет Socket в множество FDSet.

procedure FD_SET(Socket: TSocket; var FDSet: TFDSet);

// Инициализирует множество FDSet.

procedure FD_ZERO(var FDSet: TFDSet);

При создании переменной типа TFDSet в той области памяти, которую она занимает, могут находиться произвольные данные, являющиеся, по сути дела, "мусором". Из-за этого мусора функции FD_CLR, FD_ISSET, и FD_SET не смогут работать корректно. Процедура FD_ZERO очищает мусор, создавая пустое множество. Вызов остальных функций FD_XXX до вызова FD_ZERO приведёт к непредсказуемым результатам.

Мы намеренно не приводим здесь описание внутренней структуры типа TFDSet. С помощью функций FD_XXX можно выполнить все необходимые операции с множеством, не зная этой структуры. Отметим, что в Windows и в Unix внутреннее устройство этого типа существенно различается, но благодаря использованию этих функций код остается переносимым.

В Windows максимальное количество сокетов, которое может содержать в себе множество TFDSet, определяется значением константы FD_SETSIZE. По умолчанию ее значение равно 64. В C/C++ отсутствует раздельная компиляция модулей в том смысле, в котором она существует в Delphi, поэтому модуль в этих языках может поменять значение константы FD_SETSIZE перед включением заголовочного файла библиотеки сокетов, и это изменение приведёт к изменению внутренней структуры типа TFDSet (точнее, типа FDSet — в C/C++ он называется так). К счастью, в Delphi модули надежно защищены от подобного влияния друг на друга, поэтому как бы мы ни переопределяли константу FD_SETSIZE в своем модуле, на модуле WinSock это никак не отразится. В Delphi приходится прибегать к другому способу изменения количества сокетов в множестве: для этого следует определить свой тип, эквивалентный по структуре TFDSet, но резервирующий иное количество памяти для хранения сокетов (структуру TFDSet можно узнать из исходного кода модуля WinSock). В функцию select можно передавать указатели на структуры нового типа, необходимо только приведение типов указателей. А вот существующие функции FD_XXX, к сожалению, не смогут работать с новой структурой, потому что компилятор требует строгого соответствия типов для параметров-переменных. Но, опять же, при необходимости очень легко создать аналоги этих функций для своей структуры.

Примечание

На первый взгляд может показаться, что Delphi в данном случае хуже, чем C/C++. Но достаточно хотя бы раз столкнуться с ошибкой, вызванной взаимным влиянием макроопределений в модулях C/C++, чтобы понять, что уж лучше написать несколько лишних строк кода, лишь бы никогда больше не иметь таких проблем.

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

TTimeVal = record

 tv_sec: LongInt;

 tv_usec: LongInt;

end;

Эта структура служит для задания времени ожидания. Поле tv_sec содержит число полных секунд в этом интервале, поле tv_usec — число микросекунд. Так, чтобы задать интервал ожидания, равный 1,5 с, нужно присвоить полю tv_sec значение 1, а полю tv_usec — значение 500 000. Параметр timeout функции select должен содержать указатель на заполненную подобным образом структуру, определяющую, сколько времени функция будет ожидать, пока хотя бы один из сокетов не будет готов к требуемой операции. Если этот указатель равен nil, ожидание будет бесконечным.

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

Функция select позволяет дождаться, когда хотя бы один из сокетов, переданный в одном из множеств, будет готов к выполнению той или иной операции. Какой именно операции, определяется тем, в какое из трех множеств входит сокет. Для сокетов, входящих в множество readfds, готовность означает, что функции recv или recvfrom будут выполнены без блокирования. В случае UDP это означает, что во входном буфере сокета есть данные, которые можно прочитать. При использовании TCP функции recv и recvfrom могут быть выполнены без задержки еще в двух случаях: когда партнер закрыл соединение (в этом случае функции вернут 0), а также когда соединение некорректно разорвано (в этом случае функции вернут SOCKET_ERROR). Кроме того, если сокет, включенный в множество readfds, находится в состоянии ожидания соединения (в которое он переведен с помощью функции listen), то для него состояние готовности означает, что очередь соединений не пуста и функция accept будет выполнена без задержек.

Для сокетов, входящих в множество writefds, готовность означает, что сокет соединен, а в его выходном буфере есть свободное место. (До сих пор мы обсуждали только блокирующие сокеты, для которых успешное завершение функции connect автоматически означает, что сокет соединен. Далее мы познакомимся с неблокирующими сокетами, для которых нужно вызвать функцию select, чтобы понять, установлено ли соединение.) Наличие свободного места в буфере не гарантирует того, что функции send или sendto не будут блокировать вызвавшую их нить, т. к. программа может попытаться передать больший объем информации, чем размер свободного места в буфере на момент вызова функции. В этом случае функции send и sendto вернут управление вызвавшей их нити только после того, как часть данных будет отправлена, и в буфере сокета освободится достаточно места.

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

И наконец, последнее множество exceptfds. Для сокетов, входящих в это множество, состояние готовности означает либо неудачу попытки соединения для неблокирующего сокета, либо получение высокоприоритетных данных (out-of-band data). В этой книге мы не будем детально рассматривать отправку и получение высокоприоритетных данных. Те, кому это понадобится, легко разберутся с этим вопросом по MSDN.

Функция select возвращает общее количество сокетов, находящихся в состоянии готовности. Если функция завершила работу по тайм-ауту, возвращается 0. Множества readfds, writefds и exceptfds модифицируются функцией: в них остаются только те сокеты, которые находятся в состоянии готовности. При вызове функции любые два из этих трех указателей могут быть равны nil, если программу не интересует готовность сокетов по соответствующим критериям. Один и тот же сокет может входить в несколько множеств.

В листинге 2.23 приведен пример кода TCP-сервера, взаимодействующего с несколькими клиентами в рамках одной нити и работающего по простой схеме "запрос-ответ".

Листинг 2.23. Пример сервера, использующего select

var

 Sockets: array of TSocket;

 Addr: TSockAddr;

 Data: TWSAData;

 Len, I, J: Integer;

 FDSet: TFDSet;

begin

 WSAStartup($101, Data);

 SetLength(Sockets, 1);

 Sockets[0]:= socket(AF_INET, SOCK_STREAM, 0);

 Addr.sin_family:= AF_INET;

 Addr.sin_port:= htons(5514);

 Addr.sin_addr.S_addr:= INADDR_ANY;

 FillChar(Addr.sin_zero, SizeOf(Addr.sin_zero), 0);

 bind(Sockets[0], Addr, SizeOf(TSockAddr));

 listen(Sockets[0], SCMAXCONN);

 while True do

 begin

// 1. Формирование множества сокетов

 FD_ZERO(FDSet);

for I:= 0 to High(Sockets) do FDSET(Sockets[1], FDSet);

 // 2. Проверка готовности сокетов

 select(0, @FDSet, nil, nil, nil);

// 3. Чтение запросов клиентов тех сокетов, которые готовы к этому

 I:= 1;

while I <= High(Sockets) do

begin

if FD_ISSET(Sockets[I], FDSet) then if recv(Sockets[I]…) <= 0 then

 begin

// Связь разорвана, нужно закрыть сокет

 // и удалить его из массива

closesocket(Sockets[I]);

 for J:= I to High(Sockets) — 1 do Sockets[J]:= Sockets[J + 1];

 Dec(I);

SetLength(Sockets, Length(Sockets) -1);

 end

 else

 begin

// Получены данные от клиента, нужно ответить

 send(Sockets[I]…);

 end;

Inc(I);

 end;

 // 4. Проверка подключения нового клиента

 if FD_ISSET(Sockets[0], FDSet) then

 begin

 // Подключился новый клиент

SetLength(Sockets, Length(Sockets) + 1);

Len:= SizeOf(TSockAddr);

Sockets[High(Sockets)]:= accept(Sockets[0], @Addr, @Len)

 end;

 end;

end;

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

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

На первом шаге цикла создаётся множество сокетов, в которое добавляются все сокеты, содержащиеся в массиве. В этом месте в примере пропущена важная проверка того, что сокетов в массиве не больше 64-х. Если их будет больше, то попытки добавить лишние сокеты в множество будут проигнорированы функцией FD_SET и, соответственно, эти сокеты выпадут из дальнейшего рассмотрения, т. е. даже если клиент что-то пришлет, сервер этого не увидит. Решить проблему можно тремя способами. Самый простой — это отказывать в подключении лишним клиентам. Для этого сразу после вызова accept нужно вызывать для нового сокета closesocket. Второй способ — это увеличение количества сокетов в множестве, как это было описано ранее. В этом случае все равно остается та же проблема, хотя если сделать число сокетов в множестве достаточно большим, она практически исчезает. И наконец, можно разделить сокеты на несколько порций, для каждой из которых вызывать select отдельно. Это потребует усложнения примера, потому что сейчас в функции select мы используем бесконечное ожидание. При разбиении сокетов на порции это может привести к тому, что из-за отсутствия готовых сокетов в первой порции программа не сможет перейти к проверке второй порции, в которой готовые сокеты, может быть, есть. Пример разделения сокетов на порции будет рассмотрен в следующем разделе.

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

Move((PChar(Sockets) — 4)^, FDSet, Length(Sockets) * SizeOf(TSocket) + SizeOf(Integer));

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

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

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

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

Напомним, что функция select модифицирует переданные ей множества таким образом, что в них остаются лишь сокеты, находящиеся в состоянии готовности. Поэтому чтобы проверить, готов ли конкретный сокет, достаточно с помощью функции FD_ISSET проверить, входит ли он в множество FDSet. Если входит, то вызываем для него функцию recv. Если эта функция возвращает положительное значение, значит, данные в буфере есть, программа их читает и отвечает. Если функция возвращает 0 или -1 (SOCKET_ERROR) значит, соединение закрыто или разорвано, и данный сокет больше не может быть использован. Поэтому мы должны освободить связанные с ним ресурсы (closesocket) и убрать его из массива сокетов (как раз на этом шаге размер массива уменьшается). При удалении оставшиеся сокеты смещаются на одну позицию влево, поэтому переменную цикла необходимо уменьшить на единицу, иначе следующий сокет будет пропущен.

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

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

Данный текст является ознакомительным фрагментом.