Использование сокетов в Delphi . Часть первая: стандартные сокеты
Многочисленные вопросы на Круглом столе, посвящённые передаче
данных по сети с помощью сокетов, показывают, что эта тема достаточно
актуальна, но вызывает некоторые сложности у начинающих программистов. Данная
статья является первой в цикле из трёх статей, призванных дать ответы на
подобные вопросы. Она посвящена стандартным сокетам. Вторая статья будет
посвящена сокетам Windows, а третья - внутреннему устройству классов VCL,
предназначенных для передачи данных с помощью сокетов. Статьи не претендуют на
исчерпывающее освещение проблемы (в частности, будут обсуждаться только
протоколы TCP и UDP), однако они должны дать сведения, достаточные для
понимания основных механизмов работы сокетов и дальнейшего самостоятельного их
изучения. В статьях много дополнительной информации, которая не является
необходимой непосредственно для написания программы, но расширяет кругозор в
данной области знаний. Строго говоря, если писать только то, что необходимо
для простейшей организации связи, каждая из трёх статей цикла уместилась бы на
странице. Но в таком виде это было бы полезно только ламерам, которым лишь бы
содрать откуда-нибудь готовое решение и абы как вставить его в свою
"программу". А моя цель - написать что-то полезное для тех, кто пока ещё не
знаком близко с сокетами, но хочет в первую очередь понять, как они устроены,
а не получить что-то готовенькое. Таким людям, на мой взгляд, будет полезно
знать то, что находится вокруг, потому что это знание помогает искать решения
в нестандартных ситуациях.
Термин "стандартные сокеты", который будет встречаться на
протяжении всей статьи, достаточно условен и нуждается в дополнительном
пояснении. Строго говоря, стандартными сокетами называются сокеты Беркли
(Berkley sockets), разработанные в университете Беркли для системы Unix. Как
это ни парадоксально звучит, но сокеты Беркли появились до появления
компьютерных сетей. Изначально они предназначались для взаимодействия между
процессами в системе и только позже были приспособлены для TCP/IP. Работа с
сокетами Беркли сделана максимально похожей на работу с файлами в Unix. В
частности, для отправки и получения данных используются те же функции, что и
для файлового ввода/вывода.
Сокеты в Windows не полностью совместимы с сокетами Беркли
(например, для них предусмотрены специальные функции отправки и получения
данных, переопределены некоторые типы данных и т.п.). Но возможности работы с
сокетами в Windows можно разделить на две части: то, что укладывается в
идеологию сокетов Беркли, хотя и реализовано несколько иначе, и то, что
является специфичным для Windows. Ту часть реализации сокетов Windows, которая
по функциональности соответствует сокетам Беркли, мы будем называть
стандартными сокетами, а сокетами Windows (Windows sockets) - специфичные для
Windows расширения.
Соглашения об именах
Первые библиотеки сокетов писались на языке С. В этом языке
идентификаторы чувствительны к регистру символов, т.е., например, SOCKET,
Socket и socket - это разные идентификаторы. Исторически сложилось, что имена
встроенных в С типов данных пишутся в нижнем регистре, имена определённых в
программе типов, макроопределений и констант - в верхнем, имена функций - в
смешанном (большие буквы выделяют начала слов, например, GetWindowText).
Разработчики библиотеки сокетов несколько отошли от этих правил: имена всех
стандартных сокетных функций пишутся в нижнем регистре. К счастью, мы
программируем не на С, а на Паскале, нечувствительном к регистру символов,
поэтому будем писать все идентификаторы в виде, наиболее удобном для чтения,
т.е. в смешанном регистре.
Чувствительность C к регистру символов создаёт некоторые
проблемы при переносе библиотек, написанных на этом языке, в Delphi. Эти
проблемы связаны с тем, что разные объекты могут иметь имена, различающиеся
только регистром символов. В частности, есть тип SOCKET и функция socket.
Сохранить эти имена в Delphi возможности нет. Чтобы избежать эту проблему,
разработчики Delphi при переносе библиотек к имени типа добавляют букву "T",
причём независимо от того, существуют ли у этого типа одноимённые функции или
нет. Так, тип SOCKET в С в Delphi называется TSocket. Имена функций остаются
без изменений.
Выше был упомянут термин "макроопределение". Он может быть
непонятен тем, кто не работал с языками C и C++, потому что в Delphi
макроопределения отсутствуют. Нормальная последовательность трансляции
программы в Delphi следующая: сначала компилятор создаёт объектный код, в
котором вместо реальных адресов функций, переменных и т.п. стоят ссылки на них
(на этапе компиляции эти адреса ещё неизвестны). Затем компоновщик размещает
объекты в памяти и заменяет ссылки реальными адресами. Так получается готовая
к исполнению программа. В C/C++ трансляция включает в себя ещё один этап:
перед компилятором текст программы модифицируется препроцессором, а компилятор
получат уже изменённый текст. Макроопределения, или просто макросы, - это
директивы препроцессору, предписывающие ему, как именно нужно менять текст
программы. Макрос задаёт подмену: везде, где в программе встречается имя
макроса, препроцессор изменяет его на тот текст, который задан при определении
этого макроса. Определяются макросы с помощью директивы препроцессору
"#define".
В простейшем случае макросы используются для определения
констант. Например, директива "#define SOMECONST 10" вынуждает препроцессор
заменять SOMECONST на 10. Для компилятора эта директива ничего не значит,
идентификатора SOMECONST для него не существует. Он получит уже изменённый
препроцессором текст, в котором на местах упоминания SOMECONST будет стоять
10. Допускается также создавать параметризованные макросы, которые изменяют
текст программы по более сложным правилам.
Макросы позволяют в некоторых случаях существенно сократить
программу и повысить её читабельность. Тем не менее, они считаются устаревшим
средством, т.к. их использование может привести к существенным проблемам
(обсуждение этих проблем выходит за рамки данной статьи; если кому это
интересно - задавайте вопросы ниже, в обсуждении статьи, и я отвечу). В
современных языках от использования макросов отказываются. В частности, в C++
макросы поддерживаются в полном объёме, но использовать их не рекомендуется,
т.к. есть более безопасные инструменты, решающие типичные для макросов задачи.
В C# и Java макросы отсутствуют. Тем не менее, в заголовочных файлах для
системных библиотек Windows (в т.ч. и библиотеки сокетов) макросы широко
используются, т.к. требуется обеспечить совместимость с языком C. При портации
таких файлов в Delphi макросы без параметров заменяются константами, а макросы
с параметрами - функциями (иногда один макрос приходится заменять несколькими
функциями для разных типов данных). Общие
сведения о сокетах
Сокетом (от англ. socket - гнездо, розетка) называется
специальный объект, создаваемый для отправки и получения данных через сеть.
Отметим, что под термином "объект" в данном случае подразумевается не объект в
терминах объектно-ориентированного программирования, а некоторая сущность,
внутренняя структура которой скрыта от нас, поэтому с этой сущностью мы можем
оперировать только как с единым и неделимым (атомарным) объектом. Этот объект
создаётся внутри библиотеки сокетов, а программист, использующий эту
библиотеку, получает уникальный номер (дескриптор) этого сокета. Конкретное
значение этого дескриптора не несёт для программиста никакой полезной
информации и может быть использовано только для того, чтобы при вызове функции
из библиотеки сокетов указать, с каким сокетом требуется выполнить
операцию.
Чтобы две программы могли общаться друг с другом через сеть,
каждая из них должна создать сокет. Каждый сокет обладает двумя основными
характеристиками: протоколом и адресом, к которым он привязан. Протокол
задаётся при создании сокета и не может быть изменён впоследствии. Адрес
сокета задаётся позже, но обязательно до того, как через сокет пойдут данные.
В некоторых случаях привязка сокета к адресу может быть неявной.
Формат адреса сокета определяется конкретным протоколом. В
частности, для протоколов TCP и UDP адрес состоит из IP-адреса сетевого
интерфейса и номера порта.
Каждый сокет имеет два буфера: для входящих и для исходящих
данных. При отправке данных они сначала кладутся в буфер исходящих, и лишь
затем отправляются в фоновом режиме. Программа в это время продолжает свою
работу. При получении данных сокет кладёт их в буфер для входящих, откуда они
затем могут извлекаться программой.
Сеть может связывать разные аппаратные платформы, поэтому
требуется согласование форматов передаваемых данных, в частности - форматов
целых чисел. Двухбайтные целые числа хранятся в памяти в двух последовательно
расположенных байтах. При этом возможны два варианта: в первом байте хранится
младший байт числа, а во втором - старший, и наоборот. Способ хранения
определяется аппаратной частью платформы. Процессоры Intel используют первый
вариант, т.е. первым хранится младший байт, а другие процессоры (например,
Motorola) - второй вариант. То же касается и четырёхбайтных чисел: процессоры
Intel хранят их, начиная с младшего байта, а некоторые другие процессоры -
начиная со старшего. Сетевой формат представления таких чисел совпадает с
форматом процессора Motorola, т.е. на платформах с процессором Intel
необходимо переставлять байты при конвертации чисел в сетевой формат.
Библиотека сокетов разрабатывалась для ОС Unix, в которой
традиционно высоко ценилась переносимость между платформами, поэтому она
содержит функции, позволяющие не задумываться о порядке байт в числах. Эти
функции называются NtoHS, NtoHL, HtoNS и HtoNL. Первая буква в названии этих
функций показывает, в каком формате дано исходное число (N - Network - сетевой
формат, H - Host - формат платформы), четвёртая буква - формат результата,
последняя буква - разрядность (S - Short - двухбайтное число, L - Long -
четырёхбайтное число). Например, функция HtoNS принимает в качестве параметра
число типа u_short (Word) в формате платформы и возвращает то же число в
сетевом формате. Реализация этих функций для каждой платформы своя: где-то они
переставляют байты, где-то они возвращают в точности то число, которое было им
передано. Использование этих функций делает программы переносимыми. Хотя для
программиста на Delphi вопросы переносимости не столь актуальны, приходится
использовать эти функции хотя бы потому, что байты переставлять надо, а
никакого более удобного способа для этого не существует. Сетевые протоколы. Семиуровневая модель
OSI
Сетевым протоколом называется набор соглашений, следование
которым позволяет обеим сторонам одинаково интерпретировать принимаемые и
отправляемые данные. Сетевой протокол можно сравнить с языком: два человека
понимают друг друга тогда, когда говорят на одном языке. Причём если два
человека, говорящих на похожих, но немного разных языках, всё же могут
понимать друг друга, то два компьютера для нормального обмена данными должны
поддерживать в точности одинаковый протокол.
Для установления взаимодействия между компьютерами должен быть
согласован целый ряд вопросов, начиная от напряжения в проводах и заканчивая
форматом пакетов. Реализуются эти соглашения на разных уровнях, поэтому
логичнее иметь не один протокол, описывающий всё и вся, а набор протоколов,
каждый из которых охватывает только вопросы одного уровня. Организация Open
Software Interconnection (OSI) предложила разделить все вопросы, требующие
согласования, на семь уровней. Это разделение известно как семиуровневая
модель OSI.
Семейство протоколов, реализующих различные уровни, называется
стеком протоколов. Стеки протоколов не всегда точно следуют модели OSI,
некоторые протоколы решают вопросы, связанные сразу с несколькими
уровнями.
Первый уровень в модели OSI
называется физическим. На нём согласовываются физические, электрические и
оптические параметры сети: напряжение и форма импульсов, кодирующих 0 и 1,
какой штекер используется и т.п.
Второй уровень носит название
канального. На этом уровне решаются вопросы конфигурации сети (шина, звезда,
кольцо и т.п.), вопросы приёма и передачи кадров и допустимости и методов
разрешения коллизий (ситуаций, когда сразу два компьютера пытаются передать
данные).
Третий уровень - сетевой. На этом
уровне определяется, как адресуются компьютеры. Большинство сетей используют
широковещательный способ передачи: пакет, переданный одним компьютером,
получают все остальные. Протокол сетевого уровня описывает критерии, на
основании которых каждый компьютер может выбирать из сети только те пакеты,
которые предназначены ему, и игнорировать все остальные. На этом же уровне
определяется, как пакеты проходят через роутер.
Четвёртый уровень называется
транспортным. На этом уровне единый физический поток данных разбивается на
независимые логические потоки. Это позволяет нескольким программам независимо
друг от друга использовать сеть, не опасаясь, что их данные смешаются. Кроме
того, на транспортном уровне решаются вопросы, связанные с подтверждением
доставки пакета и упорядочиванием пакетов.
Пятый уровень известен как уровень
сессии. Уровень сессии определяет процедуру установления, завершения связи и
её восстановления после разрыва. Расписывается последовательность действий
каждой стороны и пакеты, которые они должны друг другу отправить для
инициализации и завершения связи. Определяются соглашения о том, как единый
поток разбивается на логические пакеты.
Шестой уровень называется уровнем
представлений. На этом уровне определяется то, в каком формате данные
передаются по сети. Под этим подразумевается, в первую очередь, внутренняя
структура пакета, а также способ представления данных разных типов. Например,
для двух- и четырёхбайтных целых чисел должен быть согласован порядок байт,
для логических величин - какие значения соответствуют True, какие - False, для
строк - кодировка и способ задания конца строки и т.п.
Седьмой уровень называется уровнем
приложений. Соглашения этого уровня позволяют работать с ресурсами (файлами,
принтерами и т.д.) удалённого компьютера как с локальными, осуществлять
удалённый вызов процедур и т.п.
Чтобы получить данные через сеть, должны быть реализованы все
уровни, за исключением, может быть, седьмого. Для каждого уровня должен быть
определён свой протокол. В идеале механизмы взаимодействия между протоколами
разных уровней должны предоставлять столь высокий уровень абстракции, чтобы
один протокол на любом из уровней можно было заменить любым другим протоколом
того же уровня без необходимости вносить какие-либо изменения в выше- и
нижележащие уровни. Стек TCP/IP
Физический и канальный уровень полностью реализуются сетевой
картой (или модемом, или другим устройством, выполняющим ту же функцию) и её
драйвером. Здесь действительно достигнута настолько полная абстракция, что
программист обычно не задумывается о том, какая используется сеть. Поэтому мы
также не будем в данной работе останавливаться на этих двух уровнях.
В реальной жизни не все протоколы соответствуют модели OSI.
Особенно это касается старых протоколов. Существует такое понятие, как стек
протоколов - набор протоколов разных уровней, которые совместимы друг с
другом. Эти уровни не всегда точно соответствуют тем, которые предлагает
модель OSI, но определённое разделение задач на уровни в них присутствует. В
данной работе мы сосредоточимся на стеке протоколов, который называется TCP/IP
(нередко можно услышать словосочетание "протокол TCP/IP" - это не совсем
корректно: TCP/IP не протокол, а стек протоколов). Название этот стек получил
по названию двух самых известных своих протоколов - TCP и IP.
IP расшифровывается как Internet Protocol. Это название иногда
ошибочно переводят как "протокол интернета" или "протокол для интернета". На
самом деле когда разрабатывался этот протокол, никакого интернета ещё и в
помине не было, поэтому правильный перевод - межсетевой протокол. История
появления этого протокола связана с особенностями работы сети Ethernet. Эта
сеть строится по принципу шины, когда все компьютеры подключены, грубо говоря,
к одному проводу. Если хотя бы два компьютера попытаются одновременно
передавать данные по общей шине, возникнет неразбериха, поэтому все шинные
сети строятся по принципу "один говорит - все слушают". Очевидно, что
требуется какая-то защита от т.н. коллизий - ситуаций, когда два узла
одновременно пытаются передавать данные.
Разные сети решают проблему коллизий по-разному. В промышленных
сетях, например, обычно используется маркер - специальный индикатор, который
показывает, какому узлу разрешено сейчас передавать данные. Узел, называемый
мастером, следит за тем, чтобы маркер вовремя передавался от одного узла к
другому. Маркер исключает возможность коллизий. Ethernet же является
одноранговой сетью, в которой нет мастера, поэтому в ней используется другой
подход: коллизии допускаются, но существует механизм их разрешения,
заключающийся в том, что, во-первых, узел не начинает передачу данных, если
видит, что другой узел уже что-то передаёт, а во-вторых, если два узла
одновременно пытаются начать передачу, то оба прекращают попытку и повторяют
её через случайный промежуток времени. У кого этот промежуток окажется меньше,
тот и захватит сеть (или за этот промежуток времени сеть будет захвачена
кем-то ещё).
При большом числе компьютеров, сидящих на одной шине, коллизии
становятся слишком частыми, и производительность сети резко падает. Для борьбы
с этим используются специальные устройства - роутеры. Роутер является узлом,
подключенным одновременно к нескольким сетям. Пока остальные узлы каждой из
этих сетей взаимодействуют только между собой, роутер никак себя не проявляет,
и эти сети существуют независимо друг от друга. Но если компьютер из одной
сети посылает пакет компьютеру другой сети, этот пакет получает роутер и
переправляет его в ту сеть, в которой находится адресат, или в которой
находится другой роутер, способный передать этот пакет адресату.
На канальном уровне существует адресация узлов, основанная на
т.н. MAC-адресе сетевой карты (MAC - это сокращение Media Access Control).
Этот адрес является уникальным номером карты, присвоенной ей производителем.
Очевидно неудобство такого способа адресации, т.к. по MAC-адресу невозможно
определить положение компьютера в сети, т.е. куда направлять пакет. Кроме
того, при замене сетевой карты меняется адрес компьютера, что также не всегда
удобно. Поэтому на сетевом уровне определяется собственный способ адресации,
не связанный с аппаратными особенностями узла. Отсюда следует, что роутер
должен понимать протокол сетевого уровня, чтобы принимать решение о передаче
пакета из одной сети в другую, а протокол, в свою очередь, должен учитывать
наличие роутеров в сети и предоставлять им необходимую информацию. Протокол IP
был одним из первых протоколов сетевого уровня, который решал такую задачу, и
с его помощью стала возможной передача пакетов между сетями. Поэтому он и
получил название межсетевого протокола. Впрочем, название прижилось: в
некоторых статьях MSDN'а сетевой уровень (network layer) называется межсетевым
уровнем (internet layer). В протоколе IP, в частности, вводится важный
параметр для каждого пакета: максимальное число роутеров, которое он может
пройти, прежде чем попадёт к адресату. Это позволяет защититься от
бесконечного блуждания пакетов по сети.
Здесь следует заметить, что сеть Ethernet ушла далеко вперёд по
сравнению с моментом создания протокола IP и теперь организована сложнее,
поэтому не следует думать, что в предыдущих абзацах изложены все принципы
работы этой сети (их изложение выходит за рамки данной статьи). Тем не менее,
протокол IP по-прежнему используется, а компьютеры по-прежнему видят в сети не
только свои, но и чужие пакеты. На этом основана работа т.н. снифферов -
программ, позволяющих одному компьютеру читать пакеты, пересылаемые между
двумя другими компьютерами.
Для адресации компьютера протокол IP использует уникальное
четырёхбайтное число, называемое IP-адресом. Впрочем, более распространена
форма записи этого числа в виде четырёх однобайтных значений. Система
назначения этих адресов довольно сложна и призвана оптимизировать работу
роутеров, обеспечить прохождение широковещательных пакетов только внутри
определённой части сети и т.п. Мы здесь не будем разбирать эту систему, потому
что в правильно настроенной сети программисту не нужно знать эти тонкости:
достаточно знать, что каждый узел имеет уникальный IP-адрес, для которого
принята запись в виде четырёх цифровых полей, разделённых точками, например,
192.168.200.217. Также следует знать, что адреса из диапазона
127.0.0.1-127.255.255.255 задают т.н. локальный узел: через эти адреса могут
связываться программы, работающие на одном компьютере. Таким образом,
обеспечивается прозрачность местонахождения адресата. Кроме того, один
компьютер может иметь несколько IP-адресов, которые могут использоваться для
одного и того же или разных сетевых интерфейсов.
Кроме IP, в стеке TCP/IP существует ещё несколько протоколов,
решающих задачи сетевого уровня. Эти протоколы не являются полноценными
протоколами и не могут заменить IP. Они используются только для решения
некоторых частных задач. Это протоколы ICMP, IGMP и ARP.
Протокол ICMP (Internet Control Message Protocol - протокол
межсетевых управляющих сообщений) обеспечивает диагностику связи на сетевом
уровне. Многим знакома утилита ping, позволяющая проверить связь с удалённым
узлом. В основе её работы лежат специальные запросы и ответы, определяемые в
рамках протокола ICMP. Кроме того, этот же протокол определяет сообщения,
которые получает узел, отправивший IP-пакет, если этот пакет по каким-то
причинам не доставлен.
Протокол называется надёжным (reliable), если он гарантирует,
что пакет будет либо доставлен, либо отправивший его узел получит уведомление
о том, что доставка невозможна. Кроме того, надёжный протокол должен
гарантировать, что пакеты доставляются в том же порядке, в каком они
отправлены, и дублирования сообщений не происходит. Протокол IP в чистом виде
не является надёжным протоколом, т.к. в нём вообще не предусмотрены средства
уведомления узла о проблемах с доставкой пакета. Использование ICMP также не
делает IP надёжным, т.к. ICMP-пакет является частным случаем IP-пакета, и
также может не дойти до адресата, поэтому возможны ситуации, когда пакет не
доставлен, а отправитель об этом не подозревает.
Протокол IGMP (Internet Group Management Protocol - протокол
управления межсетевыми группами) предназначен для управления группами узлов,
которые имеют один групповой IP-адрес. Отправка пакета по такому адресу можно
рассматривать как нечто среднее между адресной и широковещательной рассылкой,
т.к. такой пакет будет получен сразу всеми узлами, входящими в группу.
Протокол ARP (Address Resolution Protocol - протокол разрешения
адресов) используется для установления соответствия между IP- и MAC-адресами.
Каждый узел имеет таблицу соответствия. Исходящий пакет содержит два адреса
узла: MAC-адрес для канального уровня и IP-адрес для сетевого. Отправляя
пакет, узел находит в своей таблице MAC-адрес, соответствующий IP-адресу
получателя, и добавляет его к пакету. Если в таблице такой адрес не найден,
отправляется широковещательное сообщение, формат которого определяется
протоколом ARP. Получив такое сообщение, узел, чей IP-адрес соответствует
искомому, отправляет ответ, в котором указывает свой MAC-адрес. Этот ответ
также является широковещательным, поэтому его получают все узлы, а не только
отправивший запрос, и все узлы обновляют свои таблицы соответствия.
Строго говоря, правильное функционирование протоколов сетевого
уровня - задача администратора системы, а не программиста. В своей работе
программист чаще всего использует более высокоуровневыми протоколами и не
интересуется деталями реализации сетевого уровня.
Протоколами транспортного уровня в стеке TCP/IP являются
протоколы TCP и UDP. Строго говоря, они решают не только задачи транспортного
уровня, но и небольшую часть задач уровня сессии. Тем не менее, они
традиционно называются транспортными. Эти протоколы рассматриваются в
следующих разделах данной статьи.
Уровни сессии, представлений и приложения в стеке TCP/IP не
разделены: протоколы HTTP, FTP, SMTP и т.д., входящие в этот стек, решают
задачи всех трёх уровней. Мы здесь не будем рассматривать эти протоколы,
потому что при использовании сокетов они в общем случае не нужны: программист
сам определяет формат пакетов, отправляемых с помощью TCP или UDP.
Новички нередко думают, что фраза "программа поддерживает
соединение через TCP/IP" полностью описывает то, как можно связаться с
программой и получить данные. На самом деле необходимо знать формат пакетов,
которые эта программа может принимать и отправлять, т.е. должны быть
согласованы протоколы уровня сессии и представлений. Гибкость сокетов даёт
программисту возможность самостоятельно определить этот формат, т.е., по сути
дела, придумать и реализовать собственный протокол поверх TCP или UDP. И без
описания этого протокола организовать обмен данными с программой
невозможно.
Всё, что было написано в этом разделе статьи, а также в
предыдущем, можно и не знать, но, тем не менее, успешно использовать сокеты в
своих программах. Но для программиста, на мой взгляд, очень важно понимание
картины в целом, поэтому я и вставил эти два раздела.
Протокол UDP
Протокол UDP (User Datagram Protocol - протокол пользовательских
дейтаграмм) используется реже, чем его "одноклассник" TCP, но он проще для
понимания, поэтому мы начнём изучение транспортных протоколов с него. Коротко
UDP можно описать как ненадёжный протокол без соединения, основанный на
дейтаграммах. Теперь рассмотрим каждую из этих характеристик подробнее.
UDP не имеет никаких дополнительных средств управления пакетами
по сравнению с IP. Это значит, что пакеты, отправленные с помощью UDP, могут
теряться, дублироваться и менять порядок следования. В сети без роутеров
ничего этого с пакетами почти никогда не происходит, и UDP может условно
считаться надёжным протоколом. Сети с роутерами строятся, конечно же, таким
образом, чтобы подобные случаи происходили как можно реже, но полностью
исключить их, тем не менее, нельзя. Происходит это из-за того, что передача
данных может идти несколькими путями через разные роутеры. Например, пакет
может пропасть, если короткий путь к удалённому узлу временно недоступен, а в
длинном приходится пройти больше роутеров, чем это разрешено. Дублироваться
пакеты могут, если они ошибочно передаются двумя путями, а порядок следования
может изменяться, если пакет, посланный первым, идёт по более длинному пути,
чем пакет, посланный вторым.
Всё вышесказанное отнюдь не означает, что на основе UDP нельзя
построить надёжный обмен данными, просто заботу об этом должно взять на себя
само приложение. Каждый исходящий пакет должен содержать порядковый номер, и в
ответ на него должен приходить специальный пакет - квитанция, которая
уведомляет отправителя, что пакет доставлен. При отсутствии квитанции пакет
высылается повторно (для этого необходимо ввести таймауты на получение
квитанции). Принимающая сторона по номерам пакетов восстанавливает их исходный
порядок.
UDP не поддерживает соединение. Это означает, что при
использовании этого протокола можно в любой момент отправить данные по любому
адресу без необходимости каких-либо предварительных действий, направленных на
установление связи с адресатом. Это напоминает процесс отправки обычного
письма: на нём пишется адрес, и оно опускается в почтовый ящик без каких-либо
предварительных действий. Такой подход обеспечивает большую гибкость, но
лишает систему возможности автоматической проверки исправности канала
связи.
Дейтаграммами называются пакеты, которые передаются как единое
целое. Каждый пакет, отправленный с помощью UDP, составляет одну дейтаграмму.
Полученные дейтаграммы складываются в буфер принимающего сокета и могут быть
получены только раздельно: за одну операцию чтения из буфера программа,
использующая сокет, может получить только одну дейтаграмму. Если в буфере
лежит несколько дейтаграмм, потребуется несколько операций чтения, чтобы
прочитать все. Кроме того, одну дейтаграмму нельзя получить из буфера по
частям: она должна быть прочитана целиком за одну операцию.
Чтобы данные, передаваемые разным сокетам, не перемешивались,
каждый сокет должен получить уникальный в пределах узла номер от 0 до 65535,
называемый номером порта. При отправке дейтаграммы отправитель указывает
IP-адрес и порт получателя, и система принимающей стороны находит сокет,
привязанный к указанному порту, и помещает данные в его буфер. По сути дела,
UDP является очень простой надстройкой над IP, все функции которой заключаются
в том, что физический поток разделяется на несколько логических с помощью
портов, и добавляется проверка целостности данных с помощью контрольной суммы
(сам по себе протокол IP не гарантирует отсутствия искажений данных при
передаче).
Максимальный размер одной дейтаграммы IP равен 65535 байтам. Из
них не менее 20 байт занимает заголовок IP. Заголовок UDP имеет размер 8 байт.
Таким образом, максимальный размер одной дейтаграммы UDP составляет 65507
байт.
Типичная область применения UDP - программы, для которых потеря
пакетов некритична. Например, некоторые сетевые 3D-стрелялки в локальной сети
используют UDP, т.к. очень часто посылают пакеты, информирующие о действиях
игрока, и потеря одного пакета не приведёт к существенным проблемам: следующий
пакет доставит необходимые данные. Достоинствами UDP являются простота
установления связи, возможность использования одного сокета для обмена данными
с несколькими адресами и отсутствие необходимости возобновлять соединение
после разрыва связи. В некоторых задачах также очень удобно то, что
дейтаграммы не смешиваются, и получатель всегда знает, какие данные были
отправлены одной дейтаграммой, а какие - разными.
Ещё одним достоинством UDP является возможность отправки
широковещательных дейтаграмм. Для этого нужно указать широковещательный
IP-адрес (обычно это 255.255.255.255, но в некоторых случаях могут
использоваться адреса типа 192.168.100.225 для вещания в пределах сети
192.168.100.ХХ и т.п.), и такую дейтаграмму получат все сокеты в локальной
сети, привязанные к заданному порту. Эту возможность нередко используют
программы, которые заранее не знают, с какими компьютерами они должны
связываться. Они посылают широковещательное сообщение и связываются со всеми
узлами, которые распознали это сообщение и прислали на него соответствующий
ответ. По умолчанию для широковещательных пакетов число роутеров, через
которые они могут пройти, устанавливается равным нулю, поэтому такие пакеты не
выходят за пределы подсети. Протокол
TCP
Протокол TCP (Transmission Control Protocol - протокол
управления передачей) является надёжным потоковым протоколом с соединением,
т.е. полной противоположностью UDP. Единственное, что у этих протоколов общее
- это способ адресации: в TCP каждому сокету также назначается уникальный
номер порта. Уникальность номера порта требуется только в пределах протокола:
два сокета могут использовать одинаковые номера портов, если один из них
работает через TCP, а другой - через UDP.
В TCP предусмотрены т.н. хорошо известные (well-known) порты,
которые зарезервированы для нужд системы и не должны использоваться
программами. Стандарт TCP определяет диапазон хорошо известных портов от 0 до
255, в Windows и в некоторых других системах этот диапазон расширен до 0-1023.
Часть портов UDP тоже используется для системных нужд, но зарезервированного
диапазона в UDP нет. Кроме того, некоторые системные утилиты используют порты
за пределами диапазона 0-1023. Полный список системных портов для TCP и UDP
содержится в MSDN'е, в разделе Resource Kits/Windows 2000 Server Resource
Kit/TCP/IP Core Networking/Appendixes/TCP and UDP Port Assignment.
Для отправки пакета с помощью TCP отправителю необходимо сначала
установить соединение с получателем. После выполнения этого действия
соединённые таким образом сокеты могут использоваться только для отправки
сообщений друг другу. Если соединение разрывается (самой программой или из-за
проблем в сети), эти сокеты уже не могут быть использованы для установления
нового соединения: они должны быть уничтожены, а вместо них созданы новые
сокеты.
Механизм соединения, принятый в TCP, подразумевает разделение
ролей соединяемых сторон: одна из них пассивно ждёт, когда кто-то установит с
ней соединение, и называется сервером, другая самостоятельно устанавливает
соединение и называется клиентом. Действия клиента по установлению связи
заключаются в следующем: создать сокет, привязать его к адресу и порту,
вызвать функцию для установления соединения, передав ей адрес сервера. Если
все эти операции выполнены успешно, то связь установлена, и можно начинать
обмен данными. Действия сервера выглядят следующим образом: создать сокет,
привязать его к адресу и порту, перевести в режим ожидания соединения и
дождаться соединения. При соединении система создаст на стороне сервера
специальный сокет, который будет связан с соединившимся клиентом, и
обмениваться данными с подключившимся клиентом сервер будет через этот новый
сокет. Старый сокет останется в режиме ожидания соединения, и другой клиент
сможет к нему подключиться. Для каждого нового подключения будет создаваться
новый сокет, обслуживающий только данное соединение, а исходный будет
по-прежнему ожидать соединения. Это позволяет нескольким клиентам одновременно
соединяться с одним сервером, а серверу - не путаться в своих клиентах. Точное
число клиентов, которые могут одновременно работать с сервером, мне найти не
удалось, но оно достаточно большое.
Установление такого соединения позволяет осуществлять
дополнительный контроль прохождения пакетов. В рамках протокола TCP
выполняется проверка доставки пакета, соблюдения очерёдности и отсутствия
дублей. Механизмы обеспечения надёжности достаточно сложны, и мы их здесь
рассматривать не будем. Программисту для начала достаточно знать, что данные,
переданные с помощью TCP, не теряются, не дублируются и доставляются в том
порядке, в каком были отправлены. В противном случае отправитель получает
сообщение об ошибке. Соединённые сокеты время от времени обмениваются между
собой специальными пакетами, чтобы проверить наличие соединения.
Если из-за неполадок в сети произошёл разрыв связи, при попытке
отправить данные или прочитать их клиент получит отказ, а соединение будет
разорвано. После этого клиент должен уничтожить сокет, создать новый и
повторить подключение. Сервер также получает ошибку на сокете, обслуживающем
данное соединение, но существенно позже (эта задержка может достигать часа).
При обнаружении ошибки сервер просто уничтожает сокет и ждёт нового
подключения от клиента. Возможна ситуация, когда клиент уже подключился заново
и для него создан новый сокет, а старый сокет ещё не закрыт. Это не является
существенной проблемой - на старом сокете рано или поздно будет получена
ошибка, и он будет закрыт. Тем не менее, сервер может учитывать такую ситуацию
и уничтожать старый сокет, не дожидаясь, пока на нём будет получена ошибка,
если новое соединение с клиентом уже установлено. На исходный сокет,
находящийся в режиме ожидания подключения, физические разрывы связи никак не
влияют, после восстановления связи никаких действий с ним проводить не
нужно.
Если на клиентской стороне не удалось для нового сокета
установить соединение с сервером с первого раза (из-за отсутствия связи или
неработоспособности сервера), этот сокет не обязательно уничтожать: он может
использоваться при последующих попытках установления связи неограниченное
число раз, пока связь не будет установлена.
Протокол TCP называется потоковым потому, что он собирает
входящие пакеты в один поток. В частности, если в буфере сокета лежат 30 байт,
принятые по сети, не существует возможности определить, были ли эти 30 байт
отправлены одним пакетом, 30-ю пакетами по 1 байту или ещё как-либо.
Гарантируется только то, что порядок байт в буфере совпадает с тем порядком, в
котором они были отправлены. Принимающая сторона также не ограничена в том,
как она будет читать информацию из буфера: всё сразу или по частям. Это
существенно отличает TCP от UDP, в котором дейтаграммы не объединяются и не
разбиваются на части.
TCP используется там, где программа не хочет заботиться о
проверке целостности данных. За отсутствие этой проверки приходится
расплачиваться более сложной процедурой установления и восстановления связи.
Если при использовании UDP сообщение не будет отправлено из-за проблем в сети
или на удалённой стороне, никаких действий перед отправкой следующего
сообщения выполнять не нужно и можно использовать тот же сокет. В случае же
TCP, как это было сказано выше, необходимо сначала уничтожать старый сокет,
затем создать новый и подключить его к серверу, и только потом можно будет
снова отправлять сообщения. Другим недостатком TCP по сравнению с UDP является
то, что один сокет может использоваться только для отправки пакетов по одному
адресу, в то время как UDP позволяет с одного сокета отправлять разные пакеты
по разным адресам. И, наконец, TCP не позволяет рассылать широковещательные
сообщения. Но несмотря на эти неудобства, TCP используется существенно чаще
UDP, потому что автоматическая проверка целостности данных и гарантия из
доставки является очень важным преимуществом. Кроме того, по причинам, которые
не будут обсуждаться в данной статье, TCP обеспечивает большую безопасность в
интернете.
То, что TCP склеивает данные в один поток, не всегда удобно. Во
многих случаях пакеты, приходящие по сети, обрабатываются отдельно, поэтому и
читать их из буфера желательно тоже по одному. Это просто сделать, если все
пакеты имеют одинаковую длину. Но если пакеты имеют разную длину, принимающая
сторона заранее не знает, сколько байт нужно прочитать из буфера, чтобы
получить ровно один пакет и ни байта больше. Чтобы обойти эту ситуацию, в
пакете можно предусмотреть обязательный заголовок фиксированной длины, одно из
полей которого хранит длину пакета. В этом случае принимающая сторона может
читать пакет по частям: сначала заголовок известной длины, а потом и тело
пакета, размер которого стал известен благодаря заголовку.
В отличие от UDP, при использовании TCP данные, которые
программа отправляет одной командой, могут разбиваться на части и отправляться
несколькими IP-пакетами. Поэтому ограничение на длину данных, отправляемых за
один раз, в TCP отсутствует (точнее, определяется доступными ресурсами
системы). Количество данных, получаемое отправителем за одну операцию чтения,
ограничено размером низкоуровнего буфера сокета и может быть разным в разных
реализациях. Следует иметь ввиду, что при переполнении буфера принимающей
стороны протокол TCP предусматривает передачу отправляющей стороне сигнала, по
которому она приостанавливает отправку, причём этот сигнал приостанавливает
всю передачу данных между этими двумя компьютерами с помощью TCP, т.е. это
может повлиять и на другие программы. Поэтому желательно не допускать таких
ситуаций, когда у принимающей стороны в буфере накапливается много
данных. Создание сокета
До сих пор мы обсуждали только теоретические аспекты
использования сокетов. С этого раздела начинается практическая часть статьи:
будут рассматриваться конкретные функции, позволяющие осуществлять те или иные
операции с сокетами. Эти функции экспортируются системной библиотекой
wsock32.dll (а также библиотекой ws2_32.dll; взаимоотношение этих библиотек
будет обсуждаться в следующей статье цикла), для их использования в Delphi в
раздел uses нужно добавить стандартный модуль WinSock. Я не буду приводить
здесь исчерпывающего описания функций, так как лучше, чем это сделано в
MSDN'е, мне не написать, но буду давать описание, достаточно полное для
понимания назначения функции, а также обращать внимание на некоторые моменты,
которые в MSDN'е найти трудно. Поэтому я настоятельно рекомендую в дополнение
к этой статье внимательно прочитать то, что написано в MSDN'е о каждой из
упомянутых мною функций.
В самом начале статьи говорилось, что здесь мы будем обсуждать
только функции стандартной библиотеки сокетов, а функции, специфичные для
Windows, оставим для следующей статьи. Тем не менее, есть три функции,
отсутствующие в стандартной библиотеке, знать которые необходимо. Это функции
WSAStartup, WSACleanup и WSAGetLastError (префикс WSA означает Windows Sockets
API и используется для именования большинства функций, относящихся к
Windows-расширению библиотеки сокетов).
Функция WSAStartup используется для инициализации
библиотеки сокетов. Эту функцию необходимо вызвать до вызова любой другой
функции из этой библиотеки. Её прототип имеет вид: function WSAStartup(wVersionRequired:Word;var WSData:TWSAData): Integer;
Параметр wVersionRequired задаёт требуемую версию
библиотеки сокетов. Младший байт задаёт основную версию, старший -
дополнительную. Допустимы версии 1.0 ($0001), 1.1 ($0101), 2.0 ($0002) и 2.2
($0202). Пока мы используем стандартные сокеты, принципиальной разницы между
этими версиями нет, но версии 2.0 и выше лучше не использовать, т.к. модуль
WinSock не рассчитан на их поддержку. Вопросы взаимоотношения библиотек и
версий будут рассматриваться в следующей статье данного цикла, а пока
остановимся на том, что будем всегда использовать версию 1.1.
Параметр WSData является выходным параметром, т.е.
значение, которое имела переменная до вызова функции, игнорируется, а имеет
смысл только то значение, которая эта переменная получит после вызова функции.
Через этот параметр передаётся дополнительная информация о библиотеке сокетов.
В большинстве случаев эта информация не представляет никакого интереса,
поэтому её можно игнорировать.
Нулевое значение, возвращаемое функцией, говорит об успешном
завершении, в противном случае возвращается код ошибки.
Функция WSACleanup завершает работу с библиотекой
сокетов. Эта функция не имеет параметров и возвращает ноль в случае успешного
завершения или код ошибки в противном случае.
Функцию WSAStartup достаточно вызвать один раз, даже в
многонитевом приложении. В этом её отличие от таких функций, как, например,
CoInitialize, которая должна быть вызвана в каждой нити, использующей
COM. Функцию можно вызывать повторно - в этом случае её вызов не даёт никакого
эффекта, но для завершения работы с библиотекой сокетов функция WSACleanup
должна быть вызвана столько же раз, сколько была вызвана WSAStartup.
Большинство функций библиотеки сокетов возвращают значение,
позволяющее судить только об успешном или неуспешном завершении операции, но
не дающее информации о том, какая именно ошибка произошла (если она
произошла). Для получения информации об ошибке служит функция
WSAGetLastError, не имеющая параметров и возвращающая целочисленный
код последней ошибки, произошедшей в библиотеке сокетов в данной нити. После
неудачного завершения функции из библиотеки сокетов следует вызывать функцию
WSAGetLastError, чтобы выяснить причину неудачи.
Забегая чуть вперёд, отмечу, что библиотека сокетов содержит
стандартную функцию GetSockOpt, которая, кроме всего прочего, также
позволяет получить информацию об ошибке. Однако она менее удобна в
использовании, поэтому в тех случаях, когда не требуется совместимость с
другими платформами, лучше использовать WSAGetLastError. К тому же,
GetSockOpt возвращает ошибку, связанную с указанным сокетом, поэтому
с её помощью нельзя получить код ошибки, не связанной с конкретным
сокетом.
Для создания сокета используется стандартная функция Socket со
следующим прототипом: Function Socket(AF,SocketType,Protocol:Integer):TSocket;
Параметр AF задаёт семейство адресов (address family).
Этот параметр определяет, какой способ адресации (т.е., по сути дела, какой
стек протоколов) будет использоваться для данного сокета. При использовании
TCP/IP этот параметр должен быть равен AF_Inet, для других стеков также есть
соответствующие константы, которые можно посмотреть в файле WinSock.pas.
Параметр SocketType указывает на тип сокета и может
принимать одно из двух значений: Sock_Stream (сокет используется для
потоковых протоколов) и Sock_Dgram (сокет используется для
дейтаграммных протоколов).
Параметр Protocol позволяет указать, какой именно
протокол будет использоваться сокетом. Этот параметр можно оставить равным
нулю - тогда будет выбран протокол по умолчанию, отвечающий заданным первыми
двумя параметрами. Для стека TCP/IP потоковым протоколом по умолчанию является
TCP, дейтаграммным - UDP. В некоторых примерах можно увидеть, что значение
третьего параметра равно IPProto_IP. Значение этой константы равно 0,
и её использование только повышает читабельность кода, но приводит к тому же
результату: будет выбран протокол по умолчанию. Если нужно использовать
протокол, отличный от протокола по умолчанию (например, на базе IP существует
протокол RDP - Reliable Datagram Protocol, надёжный дейтаграммный протокол),
следует указать здесь соответствующую константу (для RDP это будет
IPProto_RDP). Можно также явно указать на использование TCP или UDP с
помощью констант IPProto_TCP и IPProto_UDP
соответственно.
Тип TSocket предназначен для хранения дескриптора
сокета. Формально он совпадает с 32-битным беззнаковым целым типом, но об этом
лучше не вспоминать, т.к. любые операции над значениями типа TSocket
бессмысленны. Значение, возвращаемое функцией Socket, следует сохранить в
переменной соответствующего типа и затем использовать для идентификации сокета
при вызове других функций. Если по каким-то причинам создание сокета
невозможно, функция вернёт значение Invalid_Socket. Причину ошибки
можно узнать с помощью функции WSAGetLastError.
Сокет, созданный с помощью функции Socket, не привязан ни к
какому адресу. Привязка осуществляется с помощью функции Bind, имеющей
следующий прототип: function Bind(S:TSocket;var Addr:TSockAddr;NameLen:Integer):Integer;
Первый параметр этой функции - дескриптор сокета, который
привязывается к адресу. Здесь, как и в остальных подобных случаях, требуется
передать значение, которое вернула функция Socket. Второй параметр содержит
адрес, к которому требуется привязать сокет, а третий - длину структуры,
содержащей адрес.
Функция Bind предназначена для сокетов, реализующих
разные протоколы из разных стеков, поэтому кодирование адреса в ней сделано
достаточно универсальным. Впрочем, надо отметить, что разработчики модуля
WinSock для Delphi выбрали не лучший способ перевода прототипа этой функции на
Паскаль, поэтому универсальность в значительной мере утрачена. В оригинале
прототип функции Bind имеет следующий вид: int bind(SOCKET s,const struct sockaddr FAR* name,int namelen);
Видно, что второй параметр - это указатель на структуру
sockaddr. Однако компилятор C/C++ позволяет при вызове функции в качестве
параметра использовать указатель на любую другую структуру, если будет
выполнено явное приведение типов. Для каждого семейства адресов используется
своя структура, и в качестве фактического параметра передаётся указатель на
эту структуру. Если бы авторы модуля WinSock описали второй параметр как
параметр-значение типа указатель, можно было бы поступать точно так же. Однако
они описали этот параметр как параметр-переменную. В результате на двоичном
уровне ничего не изменилось: и там, и там в стек помещается указатель. Однако
компилятор при вызове функции Bind не допустит использование никакой другой
структуры, кроме TSockAddr, а эта структура не универсальна и удобна,
по сути дела, только при использовании стека TCP/IP. В других случаях
наилучшим решением будет самостоятельно импортировать функцию Bind из
wsock32.dll с нужным прототипом. При этом придётся импортировать и некоторые
другие функции, работающие с адресами. Впрочем, мы в данной статье
ограничиваемся только протоколами TCP и UDP, поэтому больше останавливаться на
этом вопросе не будем.
В стандартной библиотеке сокетов (т.е. в заголовочных файлах для
этой библиотеки, входящих в SDK) полагается, что адрес кодируется структурой
sockaddr длиной 16 байт, причём первые два байта этой структуры
кодируют семейство протоколов, а смысл остальных зависит от этого семейства. В
частности, для стека TCP/IP семейство протоколов задаётся константой PF_Inet.
(Выше мы уже встречались с термином "семейство адресов" и константой AF_Inet.
В ранних версиях библиотеки сокетов семейства протоколов и семейства адресов
были разными понятиями, но затем эти понятия слились в одно, и константы
AF_XXX и PF_XXX стали взаимозаменяемыми.) Остальные 14 байт структуры
sockaddr занимает массив типа char (тип char в C/C++ соответствует
одновременно двум типам Delphi: Char и ShortInt). В принципе, в стандартной
библиотеке сокетов предполагается, что структура, задающая адрес, всегда имеет
длину 16 байт, но на всякий случай предусмотрен третий параметр функции Bind,
который хранит длину структуры. В Windows Sockets длина структуры может быть
любой (это зависит от протокола), так что этот параметр, в принципе, может
пригодится.
Выше мы уже касались вопроса о том, что неструктурированное
представление адреса в виде массива из 14 байт бывает неудобно, и поэтому для
каждого семейства протоколов предусмотрена своя структура, учитывающая
особенности адреса. В частности, для протоколов стека TCP/IP используется
структура sockaddr_in, размер которой также составляет 16 байт. Из них
используется только восемь: два для кодирования семейства протоколов, четыре
для IP-адреса и два - для порта. Оставшиеся 8 байт не используются и должны
содержать нули.
Можно было бы предположить, что типы TSockAddr и
TSockAddrIn, описанные в модуле WinSock, соответствуют структурам
sockaddr и sockaddr_in, однако это не так. На самом деле эти
типы описаны следующим образом: type SunB=packed record
s_b1,s_b2,s_b3,s_b4:u_char;
end;
SunW=packed record
s_w1,s_w2:u_short;
end;
in_addr=record
case Integer of
0:(S_un_b:SunB);
1:(S_un_w:SunW);
2:(S_addr:u_long);
end;
TInAddr=in_addr;
sockaddr_in=record
case Integer of
0:(sin_family:u_short;
sin_port:u_short;
sin_addr:TInAddr;
sin_zero:array[0..7] of Char);
1:(sa_family:u_short;
sa_data: array[0..13] of Char)
end;
TSockAddrIn=sockaddr_in;
TSockAddr=sockaddr_in;
Таким образом, типы TSockAddr и TSockAddrIn
являются синонимами типа sockaddr_in, но не того sockaddr_in, который имеется
в стандартной библиотеке сокетов, а типа sockaddr_in, описанного в модуле
WinSock. А sockaddr_in из WinSock является вариантной записью, и в случае 0
соответствует типу sockaddr_in из стандартной библиотеки сокетов, а в случае 1
- типу sockaddr из этой же библиотеки. Вот такая несколько запутанная
ситуация, хотя на практике всё выглядит не так страшно.
Перейдём, наконец, к более жизненному вопросу: какими значениями
нужно заполнять переменную типа TSockAddr, чтобы при передаче её в функцию
Bind сокет был привязан к нужному адресу. Так как мы ограничиваемся
рассмотрением протоколов TCP и UDP, нас не интересует та часть вариантной
записи sockaddr_in, которая соответствует случаю 1, т.е. мы будем
рассматривать только те поля этой структуры, которые имеют префикс sin.
Поле sin_zero, очевидно, должно содержать массив нулей. Это то
самое поле, которое не несёт никакой смысловой нагрузки и служит только для
увеличения размера структуры до стандартных 16 байт. Поле sin_family должно
иметь значение PF_Inet. В поле sin_port записывается номер порта, к которому
привязывается сокет. Номер порта должен быть записан в сетевом формате, т.е.
здесь нужно использовать функцию HtoNS, чтобы из привычной нам записи номера
порта получить число в нужном формате. Номер порта можно оставить нулевым -
тогда система выберет для сокета свободный порт с номером от 1024 до 5000.
IP-адрес для привязки сокета задаётся полем sin_addr, которое
имеет тип TInAddr. Этот тип сам является вариантной записью, которая отражает
три способа задания IP-адреса: в виде 32-битного числа, в виде двух 16-битных
чисел или в виде четырёх 8-битных чисел. На практике чаще всего используется
формат в виде четырёх 8-битных чисел, реже - в виде 32-битного числа. Случаи
использования формата из двух 16-битных чисел мне неизвестны.
Пусть у нас есть переменная Addr типа TSockAddr, и нам
требуется в её поле sin_addr записать адрес 192.168.200.217. Это можно сделать
следующим образом: Addr.sin_addr.S_un_b.s_b1:=192;
Addr.sin_addr.S_un_b.s_b2:=168;
Addr.sin_addr.S_un_b.s_b3:=200;
Addr.sin_addr.S_un_b.s_b4:=217;
Существует альтернатива такому присвоению четырёх полей по
отдельности - функция Inet_Addr. Эта функция в качестве входного
параметра принимает строку, в которой записан IP-адрес, и возвращает этот
IP-адрес в формате 32-битного числа. С использованием функции
Inet_Addr вышеприведённый код можно переписать так:
Addr.sin_addr.S_addr:=Inet_Addr('192.168.200.217');
Функция Inet_Addr выполняет простой парсинг строки и не
проверяет, существует ли такой адрес на самом деле. Поля адреса можно задавать
в десятичном, в восьмеричном и в шестнадцатеричном форматах. Восьмеричное поле
должно начинаться с нуля, шестнадцатеричное - с "0x". Приведённый выше адрес
можно записать в виде "0300.0250.0310.0331" (восьмеричный) или
"0xC0.0xA8.0xC8.0xD9" (шестнадцатеричный). Допускается также смешанный формат
записи, в котором разные поля заданы в разных системах исчисления. Функция
Inet_Addr поддерживает также менее распространённые форматы записи IP-адреса в
виде трёх полей. Подробнее об этом можно прочитать в MSDN'е.
В библиотеке сокетов предусмотрена константа
InAddr_Any, позволяющая не указывать явно адрес в программе, а
оставить его выбор на усмотрение системы. Для этого надо полю sin_addr.S_addr
присвоить значение InAddr_Any. Если IP-адрес компьютеру не назначен,
при использовании этой константы сокет будет привязан к локальному адресу
127.0.0.1. Если компьютеру назначен один IP-адрес, сокет будет привязан к
этому адресу. Если компьютеру назначено несколько IP-адресов, то будет выбран
один из них, причем сама привязка при этом отложится до установления
соединения (в случае TCP) или до первой отправки данных через сокет (в случае
UDP). Выбор конкретного адреса при этом зависит от того, какой адрес имеет
удалённая сторона.
Итак, резюмируем вышесказанное. Пусть у нас есть сокет S,
который надо привязать к адресу 192.168.200.217 и порту 3320. Для этого нужно
выполнить следующий код: Addr.sin_family:=PF_Inet;
Addr.sin_addr.S_addr:=Inet_Addr('192.168.200.217');
Addr.sin_port:=HtoNS(3320);
FillChar(Addr.sin_zero,SizeOf(Addr.sin_zero),0);
if Bind(S,Addr,SizeOf(Addr))=Socket_Error then
begin
// какая-то ошибка, анализируем с помощью WSAGetLastError
end;
Процедура FillChar - это стандартная процедура Паскаля,
заполняющая некоторую область памяти заданным значением. В данном случае мы
используем её для заполнения нулями поля sin_zero. Для этой же цели можно было
бы использовать функцию WinAPI ZeroMemory. В примерах на C/C++ для
этой же цели нередко используется функция memset.
Теперь рассмотрим другой случай: пусть выбор адреса и порта
можно оставить на усмотрение системы. Тогда код будет выглядеть следующим
образом: Addr.sin_family:=PF_Inet;
Addr.sin_addr.S_addr:=InAddr_Any;
Addr.sin_port:=0;
FillChar(Addr.sin_zero,SizeOf(Addr.sin_zero),0);
if Bind(S,Addr,SizeOf(Addr))=Socket_Error then
begin
// какая-то ошибка, анализируем с помощью WSAGetLastError
end;
При использовании TCP сервер сам не является инициатором
подключения, но может работать с любым подключившимся клиентом, какой бы у
него ни был адрес. Для сервера принципиально, какой порт он будет использовать
- если порт не определён заранее, клиент не будет знать, куда подключаться.
Поэтому номер порта является важным признаком для сервера. (Иногда, впрочем,
встречаются серверы, порт которых заранее неизвестен, но в таких случаях
всегда существует другой канал передачи данных, позволяющий клиенту до
подключения узнать, какой порт используется в данный момент сервером.) С
другой стороны, клиенту обычно непринципиально, какой порт будет у его сокета,
поэтому чаще всего сервер использует фиксированный порт, а клиент оставляет
выбор системе.
Протокол UDP не поддерживает соединение, но при его
использовании часто одно приложение тоже можно условно назвать сервером, а
другое - клиентом. Сервер создаёт сокет и ждёт, когда кто-нибудь что-нибудь
пришлёт и высылает что-то в ответ, а клиент сам отправляет что-то куда-то.
Поэтому, как и в случае TCP, сервер должен использовать фиксированный порт, а
клиент может выбирать любой свободный.
Если у компьютера только один IP-адрес, то выбор адреса для
сокета и клиент, и сервер могут доверить системе. Если компьютер имеет
несколько интерфейсов к одной сети, и каждый имеет свой IP-адрес, выбор
конкретного адреса в большинстве случаев также непринципиален и может быть
оставлен на усмотрение системы. Проблемы возникают, когда у компьютера
несколько сетевых интерфейсов, каждый из которых включен в свою сеть. В этом
случае выбор того или иного IP-адреса для сокета привязывает его к одной из
сетей, и только к одной. Поэтому нужно принять меры для того, чтобы сокет
оказался привязан к той сети, в которой находится его адресат.
Выше я уже говорил, что в системах с несколькими сетевыми
картами привязка сокета к адресу в том случае, когда его выбор доверен
системе, может осуществляться не во время выполнения функции Bind, а позже,
когда системе станет понятно, зачем используется этот сокет. Например, когда
TCP-клиент осуществляет подключение к серверу, система по адресу этого сервера
определяет, через какую карту должен идти обмен, и выбирает соответствующий
адрес. То же самое происходит с UDP-клиентом: когда он отправляет первую
дейтаграмму, система по адресу получателя определяет, к какой карте следует
привязать сокет. Поэтому клиент и в данном случае может оставить выбор адреса
на усмотрение системы. С серверами всё несколько сложнее. Система привязывает
сокет UDP-сервера к адресу, он ожидает получения пакета. В этот момент система
не имеет никакой информации о том, с какими узлами будет вестись обмен через
данный сокет, и может выбрать не тот адрес, который нужен. Поэтому сокеты
UDP-серверов, работающих в подобных системах, должны явно привязываться к
нужному адресу. Сокеты TCP-серверов, находящиеся в режиме ожидания и имеющие
адрес InAddr_Any, допускают подключение к ним по любому сетевому интерфейсу,
который имеется в системе. Сокет, который создаётся таким сервером при
подключении клиента, будет автоматически привязан к IP-адресу того сетевого
интерфейса, через который осуществляется взаимодействие с подключившимся
клиентом. Таким образом, сокеты, созданные для взаимодействия с разными
клиентами, могут оказаться привязанными к разным адресам.
После успешного завершения функций Socket и
Bind сокет создан и готов к работе. Дальнейшие действия с ним зависят
от того, какой протокол он реализует и для какой роли предназначен. Мы
разберём эти операции в разделах, посвящённых соответствующим протоколам. Там
же мы увидим, что в некоторых случаях можно обойтись без вызова функции Bind -
она будет неявно вызвана при вызове других функций библиотеки сокетов.
Когда сокет больше не нужен, необходимо освободить связанные с
ним ресурсы. Это выполняется в два этапа: сначала сокет "выключается", а потом
закрывается.
Для выключения сокета используется функция Shutdown,
имеющая следующий прототип: function Shutdown(S:TSocket;How:Integer):Integer;
Параметр S определяет сокет, который необходимо выключить,
параметр How может принимать значения SD_Receive, SD_Send
или SD_Both. Функция возвращает ноль в случае успешного выполнения и
Socket_Error в случае ошибки.
Вызов функции с параметром SD_Receive запрещает чтение
данных из входного буфера сокета. Однако на уровне протокола вызов этой
функции игнорируется: дейтаграммы UDP и пакеты TCP, посланные данному сокету,
продолжают помещаться в буфер, хотя программа уже не может их оттуда
забрать.
При использовании параметра SD_Send функция запрещает
отправку данных через сокет. При использовании протокола TCP при этом
удалённый сокет получает специальный сигнал, предусмотренный данным
протоколом, уведомляющий о том, что больше данные посылаться не будут. Если на
момент вызова Shutdown в буфере для исходящих остаются данные,
сначала посылаются они, а потом только сигнал о завершении. Протокол UDP
подобных сигналов не предусматривает, поэтому при использовании этого
протокола Shutdown просто запрещает библиотеке сокетов использовать указанный
сокет для отправки данных.
Параметр SD_Both позволяет одновременно запретить и
приём, и передачу данных через сокет.
Примечание: модуль WinSock до пятой версии
Delphi включительно содержит ошибку - в нём не определены константы SD_XXX.
Чтобы использовать их в своей программе, надо объявить их следующим
образом: const SD_Receive=0;
SD_Send=1;
SD_Both=2;
Для освобождения ресурсов, связанных с сокетом, используется
функция CloseSocket. Эта функция освобождает память, выделенную для
буферов, и порт. Её единственный параметр задаёт сокет, который требуется
закрыть, а возвращаемое значение - ноль или Socket_Error. После вызова этой
функции соответствующий дескриптор сокета перестаёт иметь смысл, и
использовать его больше нельзя.
По умолчанию функция CloseSocket немедленно возвращает
управление вызвавшей её программе, а процесс закрытия сокета начинает
выполняться на заднем плане. Под закрытием подразумевается не только
освобождение ресурсов, но и отправка данных, которые остались в выходном
буфере сокета. Вопрос о том, как изменить поведение функции
CloseSocket, будет обсуждаться в разделе "Параметры сокета". Если
сокет закрывается одной нитью в тот момент, когда другая нить пытается
выполнить какую-либо операцию с этим сокетом, то эта операция завершается с
ошибкой.
Функция Shutdown нужна в первую очередь для того, чтобы
заранее сообщить партнёру по связи о намерении завершить связь, причём это
имеет смысл только для протоколов, поддерживающих соединение. При
использовании UDP функцию Shutdown вызывать практически бессмысленно,
можно сразу вызывать CloseSocket. При использовании TCP удалённая сторона
получает сигнал о выключении партнёра, но стандартная библиотека сокетов не
позволяет программе обнаружить его получение (такие функции есть в Windows
Sockets, о чём мы будем говорить в следующей статье). Но этот сигнал может
быть важен для внутрисистемных функций, реализующих сокеты. Windows-версия
библиотеки сокетов относится к отсутствию данного сигнала достаточно
либерально, поэтому вызов Shutdown в том случае, когда и клиент, и сервер
работают под управлением Windows, не обязателен. Но реализации TCP в других
системах не всегда столь же снисходительно относятся к подобной небрежности.
Результатом может стать долгое (до двух часов) "подвешенное" состояние сокета
в той системе, когда с ним и работать уже нельзя, и информации об ошибке
программа не получает. Поэтому при использовании TCP лучше не пренебрегать
вызовом Shutdown, чтобы сокет на другой стороне не имел проблем.
MSDN рекомендует следующий порядок закрытия TCP-сокета.
Во-первых, сервер не должен закрывать свой сокет по собственной инициативе, он
может это делать только после того, как был закрыт связанный с ним клиентский
сокет. Клиент начинает закрытие сокета с вызова Shutdown с параметром
SD_Send. Сервер после этого сначала получает все данные, которые
оставались в буфере сокета клиента, а затем получает от клиента сигнал о
завершении передачи. Тем не менее, сокет клиента продолжает работать на приём,
поэтому сервер при необходимости может на этом этапе послать клиенту
какие-либо данные, если это необходимо. Затем сервер вызывает
Shutdown с параметром SD_Send, и сразу после этого -
CloseSocket. Клиент продолжает читать данные из входящего буфера
сокета до тех пор, пока не будет получен сигнал о завершении передачи
сервером. После этого клиент также вызывает CloseSocket. Такая
последовательность гарантирует, что данные не будут потеряны, но, как мы уже
обсуждали выше, она не может быть реализована в рамках стандартных сокетов
из-за невозможности получить сигнал о завершении передачи, посланный удалённой
стороной. Поэтому следует использовать упрощённый способ завершения связи:
клиент вызывает Shutdown с параметром SD_Send или
SD_Both, и сразу после этого - CloseSocket. Сервер при
попытке выполнить операцию с сокетом получает ошибку, после которой также
вызывает CloseSocket. Вызов Shutdown на стороне сервера при
этом не нужен, т.к. в этот момент соединение уже потеряно, и высылать данные
из буфера вместе с сигналом завершения уже некуда.
Передача данных при использовании UDP
Мы наконец-то добрались до изучения того, ради чего сокеты и
создавались: как передавать и получать с их помощью данные. По традиции начнём
рассмотрение с более простого протокола UDP. Функции, которые рассматриваются
в этом разделе, могут быть использованы и с другими протоколами, и от этого их
поведение может меняться. Мы здесь описываем только их поведение при
использовании UDP.
Для передачи данных удалённому сокету используется функция
SendTo, описанная следующим образом: function SendTo(S:TSocket;var Buf;Len,Flags:Integer;
var AddrTo:TSockAddr;ToLen:Integer):Integer;
Первый параметр данной функции задаёт сокет, который
используется для передачи данных. Здесь нужно указать значение, полученное
ранее от функции Socket. Параметр Buf задаёт буфер, в
котором хранятся данные для отправки, а параметр len - размер этих
данных в байтах. Параметр Flags позволяет указать некоторые
дополнительные опции, которых мы здесь касаться не будем, т.к. в большинстве
случаев они не нужны. Пока следует запомнить, что параметр Flags в
функции SendTo, а также в других функциях, где он встречается, должен
быть равен нулю. Параметр AddrTo задаёт адрес (состоящий из IP-адреса
и порта) удалённого сокета, который должен получить эти данные. Значение
параметра AddrTo должно формироваться по тем же правилам, что и
значение аналогичного параметра функции Bind, за исключением того, что
IP-адрес и порт должны быть заданы явно (т.е. не допускается использование
значения InAddr_Any и нулевого номера порта). Параметр ToLen задаёт
длину буфера, отведённого для адреса, и должен быть равен
SizeOf(TSockAddr).
Один вызов функции SendTo приводит к отправке одной
дейтаграммы. Данные, переданные в SendTo, никогда не разбиваются на
несколько дейтаграмм, и данные, переданные последовательными вызовами
SendTo, никогда не объединяются в одну дейтаграмму.
Функцию SendTo можно использовать с сокетами, не
привязанными к адресу. В этом случае внутри библиотеки сокетов будет неявно
вызвана функция Bind для привязки сокета к адресу InAddr_Any и
нулевому порту (т.е. адрес и порт будут выбраны системой).
Если выходной буфер сокета имеет ненулевой размер,
SendTo кладёт данные в этот буфер и сразу возвращает управление
программе, а собственно отправка данных осуществляется библиотекой сокетов в
фоновом режиме. Поэтому успешное завершение SendTo гарантирует только то, что
данные скопированы в буфер и что на момент их копирования не обнаружено
никаких проблем, которые делали бы невозможной их отправку. Но такие проблемы
могут возникнуть позже, поэтому даже в случае успешного завершения SendTo
отправитель не получает гарантии, что данные посланы.
Если в выходном буфере сокета не хватает места для новой порции
данных, SendTo не возвращает управление программе до тех пор, пока в
буфере за счёт фоновой отправки не появится достаточно места или не будет
обнаружена ошибка.
Если размер выходного буфера сокета равен нулю, функция SendTo
копирует данные сразу в сеть, без промежуточной буферизации. Когда функция
вернёт управление программе, программа может быть уверена, что информация уже
успешно передана в сеть. Однако даже в этом случае успешное завершение SendTo
не гарантирует того, что информация доставлена: дейтаграмма может потеряться
по дороге.
В случае успешного завершения функция SendTo возвращает
количество байт, скопированных в буфер (или переданных напрямую в сеть, если
буфера нет). Для протокола UDP это значение может быть равно только значению
параметра Len, хотя для некоторых других протоколов (например, TCP),
возможны ситуации, когда в буфер сокета копируется только часть данных,
переданных программой, и тогда SendTo возвращает значение в диапазоне
от 1 до Len. Если при выполнении SendTo возникает ошибка, она
возвращает значение Socket_Error (эта константа имеет отрицательное
значение).
Для получения данных, присланных сокету, используется функция
RecvFrom, имеющая следующий прототип: function RecvFrom(S:TSocket;var Buf;Len,Flags:Integer;
var From:TSockAddr;var FromLen:Integer):Integer;
Параметр S задаёт сокет, из входного буфера которого будут
извлекаться данные, параметр Buf - буфер, в который эти данные будут
копироваться, а параметр Len - размер этого буфера. Параметр Flags задаёт
дополнительные опции и в большинстве случаев должен быть равен нулю. Параметр
From является выходным параметром: в него помещается адрес, с которого была
послана дейтаграмма. Параметр FromLen задаёт размер в байтах буфера
для адреса отправителя. При вызове функции значение переменной, подставляемой
в качестве фактического параметра, должно быть равно SizeOf(TSockAddr).
Функция меняет это значение на ту длину, которая реально потребовалось для
хранения адреса отправителя (в случае UDP это значение также будет равно
SizeOf(TSockAddr)).
В оригинале параметры From и FromLen
передаются как указатели, и программа может использовать вместо них нулевые
указатели, если её не интересует адрес отправителя. Разработчики модуля
WinSock заменили указатели параметрами-переменными, что в большинстве случаев
удобнее. Однако возможность передавать нулевые указатели при этом оказалась
потерянной.
Функция RecvFrom всегда читает только одну дейтаграмму,
даже если размер переданного ей буфера достаточен для чтения нескольких
дейтаграмм. Если на момент вызова RecvFrom дейтаграммы во входном
буфере сокета отсутствуют, функция будет ждать, пока они там появятся, и до
этого момента не вернёт управление вызвавшей её программе. Если в буфере
находится несколько дейтаграмм, то они читаются в порядке очерёдности
поступления в буфер. Напомним, что дейтаграммы могут поступать в буфер не в
том порядке, в котором они были отправлены. Кроме того, буфер может содержать
Значение, возвращаемое функцией RecvFrom, равно длине
прочитанной дейтаграммы. Это значение может быть равно нулю, т.к. UDP
позволяет отправлять дейтаграммы нулевой длины (для этого при вызове
SendTo надо задать параметр Len равным нулю). Если обнаружена
какая-то ошибка, возвращается значение Socket_Error.
Если размер буфера, определяемого параметром Buf,
меньше, чем первая лежащая во входном буфере сокета дейтаграмма, то копируется
только часть дейтаграммы, помещающаяся в буфере, а RecvFrom
завершается с ошибкой (WSAGetLastError при этом вернёт ошибку
WSAEMsgSize). Оставшаяся часть дейтаграммы при этом безвозвратно
теряется, при следующем вызове RecvFrom будет прочитана следующая
дейтаграмма. Этой проблемы легко избежать, т.к. длина дейтаграммы в UDP не
может превышать 65507 байт. Достаточно подготовить буфер соответствующей
длины, и в него гарантированно поместится любая дейтаграмма.
Другой способ избежать подобной проблемы - использовать флаг
Msg_Peek. В этом случае дейтаграмма не удаляется из входного буфера
сокета, а значение, возвращаемое функцией RecvFrom, равно длине
дейтаграммы. При этом в буфер, заданный параметром Buf, копируется та часть
дейтаграммы, которая в нём помещается. Программа может действовать следующим
образом: вызвать RecvFrom с флагом Msg_Peek, выделить
память, требуемую для хранения дейтаграммы, вызвать RecvFrom без
флага Msg_Peek, чтобы удалить прочитать дейтаграмму целиком и удалить
её из входного буфера сокета. Этот метод сложнее, а 65507 байт - не очень
большая по нынешним меркам память, поэтому легче всё-таки использовать буфер
фиксированной длины.
Функцию RecvFrom нельзя использовать с теми сокетами,
которые ещё не привязаны к адресу, поэтому перед вызовом этой функции должна
быть вызвана либо функция Bind, либо функция, которая осуществляет неявную
привязку сокета к адресу (например, SendTo).
Протокол UDP не поддерживает соединения в том смысле, в котором
их поддерживает TCP, но библиотека сокетов позволяет частично имитировать
такое соединения. Для этого служит функция Connect, имеющая следующий
прототип: function Сonnect(S:TSocket;var Name:TSockAddr;NameLen:Integer):Integer;
Параметр S задаёт сокет, который должен быть "соединён" с
удалённым адресом. Адрес задаётся параметром Name аналогично тому, как он
задаётся в параметре Addr функции SendTo. Параметр
NameLen содержит длину структуры, описывающей адрес, и должен быть
равен SizeOf(NameLen). Функция возвращает ноль в случае успешного завершения и
Socket_Error в случае ошибки.
Вызов функции Connect в случае использования UDP
устанавливает фильтр для входящих дейтаграмм. Дейтаграммы, адрес отправителя
которых не совпадает с адресом, заданным в функции Connect, игнорируются:
новые дейтаграммы не помещаются во входной буфер сокета, а те, которые лежали
там на момент вызова Connect, удаляются из него. Connect не
проверяет, существует ли адрес, с которым сокет "соединяется", и может успешно
завершиться, даже если узла с таким IP-адресом не существует.
Программа может вызывать Connect неограниченное число
раз с разными адресами. Если параметр Name задаёт IP-адрес InAddr_Any
и нулевой порт, то сокет "отсоединяется", т.е. все фильтры для него снимаются,
и он ведёт себя так же, как сокет, для которого не была вызвана функция
Connect. Для сокетов, не привязанных к адресу, Connect неявно
вызывает Bind.
После вызова Connect для отправки данных можно
использовать функцию Send со следующим прототипом: function Send(S:TSocket;var Buf;Len,Flags:Integer):Integer;
От функции SendTo она отличается отсутствием параметров
AddrTo и ToLen. При использовании Send дейтаграмма
отправляется по адресу, заданному при вызове Connect. В остальном эти
функции ведут себя одинаково. Функция SendTo при использовании с
"соединённым" сокетом ведёт себя так же, как с несоединённым, т.е. отправляет
дейтаграмму по адресу, определяемому параметром AddrLen, а не по
адресу, заданному при вызове Connect.
Для получения данных через "соединённые" сокеты можно
использовать функцию Recv, имеющую следующий прототип: function Recv(S:TSocket;var Buf;Len,Flags:Integer):Integer;
От своего аналога RecvFrom она отличается только
отсутствием параметров From и FromLen, через которые
передаётся адрес отправителя дейтаграммы. Строго говоря, функцию Recv можно
использовать и для несоединённых сокетов, но при этом программе остаётся
неизвестным адрес отправителя. В случае же "соединённых" сокетов адрес
отправителя заранее известен - это адрес, заданный в функции Connect,
а дейтаграммы всех других отправителей будут отбрасываться. Функцию
RecvFrom также можно использовать для "соединённых" сокетов, но адрес
отравителя, который она возвращает, в данном случае может быть только тот,
который определён в функции Connect.
Таким образом, функция Connect при использовании
протокола UDP позволяет, во-первых, выполнить фильтрацию входящих дейтаграмм
по адресу средствами самой библиотеки сокетов, а во-вторых, использовать более
лаконичные альтернативы RecvFrom и SendTo - Recv и
Send. Передача данных при
использовании TCP
При программировании TCP используются те же функции, что и при
программировании UDP, но их поведение при этом иное. Для передачи данных с
помощью TCP необходимо сначала установить соединение, и после этого возможен
обмен данными только с тем адресом, с которым это соединение установлено.
Функция SendTo может использоваться для TCP-сокетов, но её параметры, задающие
адрес получателя, игнорируются, а данные отправляются на тот адрес, с которым
соединён сокет. Поэтому при отправке данных через TCP обычно используют
функцию Send, которая даёт тот же результат. По тем же причинам обычно
используется Recv, а не RecvFrom.
В TCP существует разделение ролей взаимодействующих сторон на
клиент и сервер. Мы начнём изучение передачи данных в TCP с изучения действий
клиента.
Для начала взаимодействия клиент должен соединится с сервером с
помощью функции Connect. Мы уже знакомы с этой функцией, но в случае TCP она
выполняет несколько иные действия. В данном случае она устанавливает реальное
соединение, поэтому её действия начинаются с проверки того, существует ли по
указанному адресу серверный сокет, находящийся в режиме ожидания подключения.
Функция Connect завершается успешно только в том случае, если соединение
установлено, и серверная сторона выполнила все необходимые для этого действия.
При использовании Connect в TCP предварительный явный вызов функции Bind также
не обязателен.
В отличие от UDP, сокет в TCP нельзя отсоединить или соединить с
другим адресом, если он уже соединён. Для нового соединения необходимо
использовать новый сокет.
Выше мы говорили, что TCP является надёжным протоколом, т.е. в
том случае, если пакет не доставлен, отправляющая сторона уведомляется об
этом. Тем не менее, успешное завершение Send, как и в случае UDP, не является
гарантией того, что пакет был отослан и дошёл до получателя, а говорит только
о том, что данные скопированы в выходной буфер сокета, и на момент копирования
сокет был соединён. Если в дальнейшем библиотека сокетов не сможет отправить
эти данные или не получит подтверждения об их доставке, соединение будет
закрыто, и следующая операция с этим сокетом завершится с ошибкой.
Если выходной буфер сокета равен нулю, данные сразу копируются в
сеть, но успешное завершение функции и в этом случае не гарантирует успешную
доставку. Использовать нулевой выходной буфер для TCP-сокетов не
рекомендуется, т.к. это снижает производительность при последовательной
отправке данных небольшими порциями. При буферизации эти порции накапливаются
в буфере, а потом отправляются одним большим пакетом, требующим одного
подтверждения от клиента. Если же буферизация не используется, будет
отправлено несколько мелких пакетов, каждый со своим заголовком и своим
подтверждением от клиента, что приведёт к снижению производительности.
Функция Recv копирует пришедшие данные из входного буфера сокета
в буфер, заданный параметром Buf, но не более BufLen байт. Скопированные
данные удаляются из буфера сокета. При этом все полученные данные сливаются в
один поток, поэтому получатель может самостоятельно выбирать, какой объём
данных считывать за один раз. Если за один раз была скопирована только часть
пришедшего пакета, оставшаяся часть не пропадает, а будет скопирована при
следующем вызове Recv. Функция Recv возвращает количество байт, скопированных
в буфер. Если на момент её вызова входной буфер сокета пуст, она ждёт, когда
там что-то появится, затем копирует полученные данные и лишь после этого
возвращает управление вызвавшей её программе. Если Recv возвращает 0, это
значит, что удалённый сокет корректно завершил соединение. Если соединение
завершено некорректно (например, из-за обрыва кабеля или сбоя удалённого
компьютера), функция завершается с ошибкой (т.е. возвращает Socket_Error).
Теперь рассмотрим, какие действия должен выполнить сервер при
использовании TCP. Как мы уже говорили выше, сервер должен перевести сокет в
режим ожидания соединения. Это делается с помощью функции Listen, имеющей
следующий прототип: function Listen(S:TSocket;BackLog:Integer):Integer;
Параметр S задаёт сокет, который переводится в режим
ожидания подключения. Этот сокет должен быть привязан к адресу, т.е. функция
Bind должна быть вызвана для него явно. Для сокета, находящегося в
режиме ожидания, создаётся очередь подключений. Размер этой очереди
определяется параметром BackLog. Если этот параметр равен
SoMaxConn, очередь будет иметь максимально возможный размер. В MSDN'е
отмечается, что узнать максимально допустимый размер очереди стандартными
средствами нельзя. Функция возвращает ноль в случае успешного завершения и
Socket_Error в случае ошибки.
Когда клиент вызывает функцию Connect, и по указанному
в ней адресу имеется сокет, находящийся в режиме ожидания подключения, то
информация о клиенте помещается в очередь подключений этого сокета. Успешное
завершение Connect говорит о том, что на стороне сервера подключение
добавлено в очередь. Однако для того, чтобы соединение было действительно
установлено, сервер должен выполнить ещё некоторые действия, а именно: извлечь
из очереди соединений информацию о соединении и создать сокет для его
обслуживания. Эти действия выполняются с помощью функции Accept,
имеющей следующий прототип: function Accept(S:TSocket;Addr:PSockAddr;AddrLen:PInteger):TSocket;
Параметр S задаёт сокет, который находится в режиме
ожидания соединения и из очереди которого извлекается информация о соединении.
Выходной параметр Addr позволяет получить адрес клиента,
установившего соединение. Здесь должен быть передан указатель на буфер, в
который этот адрес будет помещён. Параметр AddrLen содержит указатель
на переменную, в которой хранится длина этого буфера: до вызова функции эта
переменная должна содержать фактическую длину буфера, задаваемого параметром
Addr, после вызова - количество байт буфера, реально понадобившихся
для хранения адреса клиента. Очевидно, что при использовании TCP и входное, и
выходное значение этой переменной должно быть равно SizeOf(TSockAddr). Эти
параметры передаются как указатели, а не как параметры-переменные, что было бы
более естественно для Delphi, потому что библиотека сокетов допускает для этих
указателей нулевые значения, если сервер не интересует адрес клиента. В данном
случае разработчики модуля WinSock сохранили полную функциональность,
предоставляемую данной библиотекой.
В случае ошибки функция Accept возвращает значение
Invalid_Socket. В случае успешного завершения возвращается дескриптор
сокета, созданного библиотекой сокетов и предназначенного для обслуживания
данного соединения. Этот сокет уже привязан к адресу и соединён с сокетом
клиента, установившего соединение, и его можно использовать в функциях
Recv и Send без предварительного вызова каких-либо других
функций. Уничтожается этот сокет обычным образом, с помощью
CloseSocket.
Исходный сокет, определяемый параметром S, остаётся в
режиме прослушивания. Если сервер поддерживает одновременное соединение с
несколькими клиентами, функция Accept может быть вызвана многократно.
Каждый раз при этом будет создаваться новый сокет, обслуживающий одно
конкретное соединение: протокол TCP и библиотека сокетов гарантируют, что
данные, посланные клиентами, попадут в буферы соответствующих сокетов и не
будут перемешаны.
Для получения целостной картины кратко повторим вышесказанное.
Для установления соединения сервер должен, во-первых, создать сокет с помощью
функции Socket, а во-вторых, привязать его к адресу с помощью функции
Bind. Далее сокет должен быть переведён в режим ожидания с помощью
функции Listen, а потом с помощью функции Accept создаётся
новый сокет, обслуживающий соединение, установленное клиентом. После этого
сервер может обмениваться данными с клиентом. Клиент же должен создать сокет,
при необходимости привязки к конкретному порту вызвать Bind, и затем
вызвать Connect для установления соединения. После успешного
завершения этой функции клиент может обмениваться данными с сервером. Это
иллюстрируется приведёнными ниже примерами. Код сервера: var S,AcceptedSock:TSocket;
Addr:TSockAddr;
Data:TWSAData;
Len:Integer;
begin
WSAStartup($101,Data);
S:=Socket(AF_Inet,Sock_Stream,0);
Addr.sin_family:=PF_Inet;
Addr.sin_port:=HToNS(3030);
Addr.sin_addr.S_addr:=InAddr_Any;
FillChar(Addr.Sin_Zero,SizeOf(Addr.Sin_Zero),0);
Bind(S,Addr,SizeOf(TSockAddr));
Listen(S,SoMaxConn);
Len:=SizeOf(TSockAddr);
AcceptedSock:=Accept(S,@Addr,@Len);
{ Теперь Addr содержит адрес клиента, с которым установлено
соединение, а AcceptedSock - дескриптор, обслуживающий это
соединение. Допустимы следующие действия:
Send(AcceptedSock,…) - отправить данные клиенту
Recv(AcceptedSock,…) - получить данные от клиента
Accept(…) - установить соединение с новым клиентом }
Здесь сокет сервера привязывается к порту с номером 3030. В
общем случае разработчик сервера сам должен выбрать порт из диапазона
1024-65535. Код клиента: var S:TSocket;
Addr:TSockAddr;
Data:TWSAData;
begin
WSAStartup($101,Data);
S:=Socket(AF_Inet,Sock_Stream,0);
Addr.sin_family:=AF_Inet;
Addr.sin_port:=HToNS(3030);
Addr.sin_addr.S_addr:=Inet_Addr(…);
FillChar(Addr.Sin_Zero,SizeOf(Addr.Sin_Zero),0);
Connect(S,Addr,SizeOf(TSockAddr));
{ Теперь соединение установлено. Допустимы следующие действия:
Send(S,…) - отправить данные серверу
Recv(S,…) - получить данные от сервера }
В приведённом выше коде для краткости опущены проверки
результатов функций с целью обнаружения ошибок. При написании серьёзных
программ этим пренебрегать нельзя.
Если на момент вызова функции Accept очередь соединений пуста,
то нить, вызвавшая её, блокируется до тех пор, пока какой-либо клиент не
подключится к серверу. С одной стороны, это удобно: сервер может не вызывать
функцию Accept в цикле до тех пор, пока она не завершится успехом, а вызвать
её один раз и ждать, когда подключится клиент. С другой стороны, это создаёт
проблемы тем серверам, которые должны взаимодействовать с несколькими
клиентами. Действительно, пусть функция Accept успешно завершилась и в
распоряжении программы оказались два сокета: находящийся в режиме ожидания
новых подключений и созданный для обслуживания уже существующего подключения.
Если вызвать Accept, то программа не сможет продолжать работу до тех пор, пока
не подключится ещё один клиент, а это может произойти через очень длительный
промежуток времени или вообще никогда не произойти. Из-за этого программа не
сможет обрабатывать вызовы уже подключившегося клиента. С другой стороны, если
функцию Accept не вызывать, сервер не сможет обнаружить подключение новых
клиентов. Для решения этой проблемы библиотека сокетов предлагает средства,
которые мы рассмотрим ниже (а библиотека Windows Sockets предлагает для этого
свои средства, которые будут рассмотрены в следующей статье). Здесь же я хочу
предложить довольно популярный способ её решения, использующий средства не
библиотеки сокетов, а операционной системы. Он заключается в использовании
отдельной нити для обслуживания каждого из клиентов. Каждый раз, когда клиент
подключается, функция Accept передаёт управление программе, возвращая новый
сокет. Здесь сервер может породить новую нить, которая предназначена
исключительно для обмена данными с новым клиентом. Старая нить после этого
снова вызывает Accept для старого сокета, а новая - функции Recv и Send для
нового сокета. Такой метод решает заодно и проблемы, связанные с тем, что
функции Send и Recv также могут блокировать работу программы и помешать обмену
данными с другими клиентами. В данном случае будет блокирована только одна
нить, обменивающаяся данными с одним из клиентов, а остальные нити продолжат
свою работу. Определение готовности
сокета
Так как многие функции библиотеки сокетов блокируют вызвавшую их
нить, если соответствующая операция не может быть выполнена немедленно, часто
бывает полезно заранее знать, готов ли сокет к немедленному (без блокирования)
выполнению той или иной операции. Основным средством определения этого в
библиотеке сокетов служит функция Select: function Select(NFds:Integer;ReadFds,WriteFds,ExceptFds:PFDSet;
Timeout:PTimeVal):LongInt;
Первый параметр этой функции оставлен только для совместимости
со старыми версиями библиотеки сокетов; в существующих версиях он
игнорируется. Три следующих параметра содержат указатели на множества сокетов,
состояние которых должно проверяться. В данном случае понятие множества не
имеет ничего общего с типом множество в Delphi. В оригинальной версии
библиотеки сокетов, написанной на С, определены макросы, позволяющие очищать
такие множества, добавлять и удалять сокеты и определять, входит ли тот или
иной сокет в множество. В модуле WinSock эти макросы заменены одноимёнными
процедурами и функциями: //Удаляет сокет 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);
При создании переменной типа FDSet в той области
памяти, которую она занимает, могут находиться произвольные данные,
являющиеся, по сути дела, мусором. Из-за этого мусора функции 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 - значение 500000. Параметр
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, если программу не интересует готовность сокетов по соответствующим
критериям. Один и тот же сокет может входить в несколько множеств.
Ниже приведён пример кода TCP-сервера, взаимодействующего с
несколькими клиентами в рамках одной нити и работающего по простой схеме
запрос-ответ: 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],SoMaxConn);
while True do
begin
// 1. Формирование множества сокетов
FD_Zero(FDSet);
for I:=0 to High(Sockets) do
FD_Set(Sockets[I],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;
Как и предыдущих примерах, код для краткости не содержит
проверок успешности завершения функций. Ещё раз напоминаю, что в реальном коде
такие проверки необходимы.
Теперь разберём программу по шагам. Создание сокета, привязка к
адресу и перевод в режим ожидания подключений вам уже знакомы, поэтому мы на
них останавливаться не будем. Отмечу только, что вместо переменной типа
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, а не в том, что
он даёт готовое решение на все случаи жизни.
Неблокирующий режим
Выше мы столкнулись с функциями, которые могут надолго
приостановить работу вызвавшей их нити, если действие не может быть выполнено
немедленно. Это функции Accept, Recv, RecvFrom, Send, SendTo и Connect (в
дальнейшем в этом разделе мы не будем упоминать функции RecvFrom и SendTo,
потому что они в смысле блокирования эквивалентны функциям Recv и Send
соответственно, и всё, что будет здесь сказано о Recv и Send, применимо к
RecvFrom и SendTo). Такое поведение не всегда удобно вызывающей программе,
поэтому в библиотеке сокетов предусмотрен особый режим работы сокетов -
неблокирующий. Этот режим может быть установлен или отменён для каждого сокета
индивидуально с помощью функции IOCtlSocket, имеющей следующий прототип: function IOCtlSocket(S:TSocket;Cmd:DWORD;var Arg:u_long):Integer;
Эта функция предназначена для выполнения нескольких логически
мало связанных между собой действий. Возможно, у разработчиков первых версий
библиотеки сокетов были причины экономить на количестве функций, потому что мы
и дальше увидим, что иногда непохожие операции выполняются одной функцией. Но
вернёмся к IOCtlSocket. Её параметр Cmd определяет действие, которое выполняет
функция, а также смысл параметра Arg. Допустимы три значения параметра Cmd:
SIOCatMark, FIONRead и FIONBIO. В случае задания SIOCatMark параметр Arg
рассматривается как выходной: в нём возвращается ноль, если во входном буфере
сокета имеются высокоприоритетные данные, и ненулевое значение, если таких
данных нет (как уже было оговорено, мы в данной статье не будем касаться
передачи высокоприоритетных данных).
При Cmd равном FIONRead в параметре Arg возвращается размер
данных, находящихся во входном буфере сокета, в байтах. При использовании TCP
это количество равно максимальному количеству информации, которое можно
получить на данный момент за один вызов Recv. Для UDP это значение равно
суммарному размеру всех находящихся в буфере дейтаграмм (напомню, что
прочитать несколько дейтаграмм за один вызов Recv нельзя). Функция IOCtlSocket
с параметром FIONRead может использоваться для проверки наличия данных с целью
избежать вызова Recv тогда, когда это может привести к блокированию, или для
организации вызова Recv в цикле до тех пор, пока из буфера не будет извлечена
вся информация.
При задании аргумента FIONBIO параметр Arg рассматривается как
входной. Если его значение равно нулю, сокет будет переведён в блокирующий
режим, если не равно нулю - в неблокирующий. Таким образом, чтобы перевести
некоторый сокет S в неблокирующий режим, надо выполнить следующие
действия: var S:TSocket;
Arg:u_long;
begin
...
Arg:=1;
IOCtlSocket(S,FIONBIO,Arg);
Пока программа использует только стандартные сокеты (и не
использует Windows Sockets), сокет может быть переведён в неблокирующий или
обратно в блокирующий режим в любой момент. Неблокирующим может быть сделан
любой сокет независимо от того, какой протокол он использует и является ли он
серверным или клиентским.
Функция IOCtlSocket возвращает нулевое значение в случае успеха
и ненулевое в случае ошибки. В примере, как всегда, проверка результата для
краткости опущена.
Итак, по умолчанию сокет работает в блокирующем режиме. С
особенностями работы функций Accept, Connect, Recv и Send в этом режиме мы уже
познакомились. Теперь рассмотрим то, как они ведут себя в неблокирующем
режиме. Для этого сначала вспомним, когда эти функции блокируют вызвавшую их
нить.
Accept блокирует нить, если на момент её вызова очередь
подключений пуста.
Connect при использовании TCP блокирует сокет практически
всегда, потому что требуется время на установление связи с удалённым сокетом.
Без блокирования вызов Connect выполняется только в том случае, если
какая-либо ошибка не даёт возможности приступить к операции установления
связи. Также без блокирования функция Connect выполняется при использовании
UDP, потому что в данном случае она только устанавливает фильтр для
адресов.
Recv блокирует нить, если на момент вызова входной буфер сокета
пуст.
Send блокирует нить, если в выходном буфере сокета недостаточно
места, чтобы скопировать туда переданную информацию.
Если условия, при которых эти функции выполняются без
блокирования, выполнены, то их поведение в неблокирующем режиме не отличается
от поведения в блокирующем. Если же выполнение операции без блокирования
невозможно, функции возвращают результат, указывающий на ошибку. Чтобы понять,
произошла ли ошибка из-за необходимости блокирования или из-за чего-либо ещё,
программа должна вызвать функцию WSAGetLastError. Если она вернёт
WSAEWouldBlock, значит, никакой ошибки не было, но выполнение операции без
блокирования невозможно. Закрывать сокет и создавать новый после
WSAEWouldBlock, разумеется, не нужно, т.к. ошибки не было, и связь (в случае
использования TCP) осталась неразорванной.
Следует отметить, что при нулевом выходном буфере сокета (т.е.
когда функция Send передаёт данные напрямую в сеть) и большом объёме
информации функция Send может выполняться достаточно долго, т.к. эти данные
отправляются по частям, и на каждую часть в рамках протокола TCP получаются
подтверждения. Но эта задержка не считается блокированием, и в данном случае
Send будет одинаково вести себя с блокирующими и неблокирующими сокетами, т.е.
вернёт управление программе лишь после того, как все данные окажутся в
сети.
Для функций Accept и Recv и Send WSAEWouldBlock означает, что
операцию надо повторить через некоторое время, и, может быть, в следующий раз
она не потребует блокирования и будет выполнена. Функция Connect в этом случае
начинает фоновую работу по установлению соединения. О завершении этой работы
можно судить по готовности сокета, которая проверяется с помощью функции
Select. Следующий пример кода иллюстрирует это: var S:Scoket;
Block:u_long;
SetW,SetE:TFDSet;
begin
S:=Socket(AF_Inet,Sock_Stream,0);
...
Block:=1;
IOCtlSocket(S,FIONBIO,Block);
Connect(S,…);
if WSAGetLastError<>WSAEWouldBlock then
begin
// Произошла ошибка
raise ...
end;
FD_Zero(SetW);
FD_Set(S,SetW);
FD_Zero(SetE);
FD_Set(S,SetE);
Select(0,nil,@SetW,@SetE,nil);
if IsSet(S,SetW) then
// Connect выполнен успешно
else if IsSet(S,SetE) then
// Соединиться не удалось
else
// Произошла ещё какая-то ошибка
Напомню, что сокет, входящий в множество SetW, будет считаться
готовым, если он соединён, а в его выходном буфере есть место. Сокет, входящий
в множество SetE, будет считаться готовым, если попытка соединения не удалась.
До тех пор, пока попытка соединения не завершилась (успехом или неудачей), ни
одно из этих условий готовности не будет выполнено. Таким образом, в данном
случае Select завершит работу только после того, как будет выполнена попытка
соединения, и о результатах этой попытки можно будет судить по тому, в какое
из множеств входит сокет.
Из приведённого примера не видно, какие преимущества даёт
неблокирующий сокет по сравнению с блокирующим. Казалось бы, проще вызвать
Connect в блокирующем режиме, дождаться результата и лишь потом переводить
сокет в неблокирующий режим. Во многих случаях это действительно может
оказаться удобнее. Преимущества соединения в неблокирующем режиме связаны с
тем, что между вызовами Connect и Select программа может выполнить какую-либо
полезную работу, а в случае блокирующего сокета программа будет вынуждена
сначала дождаться завершения работы функции Connect и лишь потом сделать
что-то ещё.
Функция Send для неблокирующего сокета также имеет некоторые
специфические черты поведения. Они проявляются, когда свободное место в
выходном буфере есть, но его недостаточно для хранения данных, которые
программа пытается отправить с помощью этой функции. В этом случае функция
Send, согласно документации, может скопировать в выходной буфер такой объём
данных, для которого хватает места. В этом случае она вернёт значение, равное
этому объёму (оно будет меньше, чем значение параметра Len, заданного
программой). Оставшиеся данные программа должна отправить позже, вызвав ещё
раз функцию Send. Такое поведение функции Send возможно только при
использовании TCP. В случае UDP дейтаграмма никогда не разделяется на части, и
если в выходном буфере не хватает места для всей дейтаграммы, функция Send
возвращает ошибку, а WSAGetLastError - WSAEWouldBlock.
Сразу отмечу, что, хотя спецификация допускает частичное
копирование функцией Send данных в буфер сокета, на мне такое поведение
встретить не удалось: мои эксперименты показали, что функция S всегда либо
копирует данные целиком, расширяя при необходимости буфер, либо даёт ошибку
WSAEWouldBlock. Ниже этот вопрос будет обсуждаться подробнее. Тем не менее,
при написании программ следует учитывать возможность частичного копирования,
т.к. оно может появиться в тех условиях или в тех реализациях библиотеки
сокетов, которые в моих экспериментах не были проверены. Параметры сокета
Каждый сокет обладает рядом параметров (опций), которые влияют
на его работу. Существуют параметры уровня сокета, которые относятся к сокету
как к объекту безотносительно используемого протокола, и уровня протокола.
Впрочем, некоторые параметры уровня сокета применимы не ко всем
протоколам.
Здесь мы не будем рассматривать все параметры сокета, а
ограничимся лишь изложением методов доступа к ним и познакомимся с самыми, на
мой субъективный взгляд, интересными параметрами.
Для получения текущего значения параметров сокета используется
функция GetSockOpt, для изменения - SetSockOpt. Прототипы этих функций
выглядят следующим образом: function GetSockOpt(S:TSocket;Level,OptName:Integer;
OptVal:PChar; var OptLen:Integer):Integer;
function SetSockOpt(S:TSocket;Level,OptName:Integer;
OptVal:PChar;OptLen:Integer):Integer;
Параметры у функций почти одинаковы. Первый параметр задаёт
сокет, параметры которого следует узнать или изменить. Второй параметр
указывает, параметр какого уровня следует узнать или изменить. Третий параметр
задаёт сам параметр сокета. Параметр OptVal содержит указатель на буфер, в
котором хранится значение параметра, а OptLen - размер этого буфера (разные
параметры имеют разные типы и поэтому размер буфера может быть разным).
Функция GetSockOpt сохраняет значение параметра в буфере, заданном указателем
OptVal. Длина буфера передаётся через параметр OptLen, и через него же
возвращается размер, реально понадобившийся для хранения параметра. У функции
SetSockOpt параметр OptVal содержит указатель на буфер, хранящий новое
значение параметра сокета, а параметр OptLen - размер этого буфера.
Чаще всего параметры сокета имеют целый или логический тип. В
обоих случаях параметр OptVal должен содержать указатель на значение типа
Integer. В случае логического типа любое ненулевое значение интерпретируется
как True, нулевое - как False.
Двумя достаточно важными параметрами сокета являются размеры
входного и выходного буфера. Это параметры уровня сокета (SOL_Socket), их
номера задаются константами SO_RcvBuf и SO_SndBuf. Например, чтобы получить
размер входного буфера сокета, надо выполнить следующий код: var Val,Len:Integer;
S:TSocket;
begin
...
Len:=SizeOf(Integer);
GetSockOpt(S,SOL_Socket,SO_RcvBuf,@Val,Len);
После выполнения этого кода размер буфера будет содержаться в
переменной Val.
Немного поэкспериментировав, можно обнаружить, что размер
входного и выходного буфера равен 8192 байта как для TCP, так и для UDP. Тем
не менее, это не мешает при использовании UDP отправлять и получать
дейтаграммы большего размера, а при использовании TCP - накапливать в буфере
больший объём информации. При получении данных это достигается за счёт
использования более низкоуровневых буферов, чем буфер самого сокета. Можно
даже установить входной буфер сокета равным нулю - тогда для хранения всех
поступивших данных будут использоваться низкоуровневые буфера. Однако делать
это не рекомендуется, т.к. при этом снижается производительность.
Как уже говорилось выше, если буфер для исходящих имеет нулевой
размер, то функции Send и SendTo независимо от режима работы сокета отправляют
данные непосредственно в сеть. Если же размер этого буфера не равен нулю, при
необходимости он может увеличиваться. В MSDN'е описаны следующие правила роста
буфера:
- Если объём данных в буфере меньше, чем это задано параметром SO_SndBuf,
новые данные копируются в буфер полностью. Буфер при необходимости
увеличивается.
- Если объём данных в буфере достиг или превысил SO_SndBuf, но в буфере
находятся данные, переданные в результате только одного вызова Send,
последующий вызов приводит к увеличению буфера до размера, необходимого,
чтобы принять эти данные целиком.
- Если объём данных в буфере достиг или превысил SO_SndBuf, и эти данные
оказались в буфере в результате нескольких вызовов Send, то буфер не
расширяется. Блокирующий сокет при этом ждёт, когда за счёт отправки данных
в буфере появится место, неблокирующий завершает операцию с ошибкой
WSAEWouldBlock.
Следует отметить, что увеличение размера буфера носит временный
характер. Замечу также, что в ходе моих экспериментов мне не удалось
воспроизвести пункт 2. Если предел, заданный параметром SO_SndBuf, был
достигнут, не удавалось положить новые данные в буфер независимо от того, были
ли имеющиеся данные положены туда одним вызовом Send или несколькими. Впрочем,
это могут быть детали реализации, которые различны в разных версиях системы.
Выше мы упоминали, что UDP допускает широковещательную рассылку
(рассылку по адресу 255.255.255.255 и т.п.). Но по умолчанию такая рассылка
запрещена. Чтобы разрешить широковещательную рассылку, нужно установить в True
параметр SO_Broadcast, относящийся к уровню сокета (SOL_Socket). Таким
образом, вызов функции SetSockOpt для разрешения широковещательной рассылки
будет выглядеть следующим образом: var EnBroad:Integer;
begin
EnBroad:=1;
SetSockOpt(S,SOL_Socket,SO_Broadcast,PChar(@EnBroad),SizeOf(Integer));
Для запрета широковещательной рассылки через сокет используется
тот же код, за исключением того, что переменной EnBroad следует присвоить
ноль.
Последний параметр сокета, который мы рассмотрим, называется
SO_Linger. Он управляет поведением функции CloseSocket. Напомню, что по
умолчанию эта функция не блокирует вызвавшую её нить, а закрывает сокет в
фоновом режиме. Параметр SO_Linger имеет тип TLinger, представляющий собой
следующую структуру: type TLinger=record
L_OnOff:u_short;
L_Linger:u_short;
end;
Поле L_OnOff этой структуры показывает, будет ли использоваться
фоновый режим закрытия сокета. Нулевое значение показывает, что закрытие
выполняется в фоновом режиме, как это установлено по умолчанию (в этом случае
поле L_Linger игнорируется). Ненулевое значение показывает, что функция
CloseSocket не вернёт управление вызвавшей её нити, пока сокет не будет
закрыт. В этом случае возможны два варианта: мягкое и грубое закрытие. Мягкое
закрытие предусматривает, что перед закрытием сокета все данные, находящиеся в
его выходном буфере, будут переданы партнёру. При грубом закрытии данные
партнёру не передаются. Поле L_Linger задаёт время (в секундах), которое
даётся на передачу данных партнёру. Если за отведённое время данные,
находящиеся в выходном буфере сокета, не были отправлены, сокет будет закрыт
грубо. Если поле L_Linger будет равно нулю (при ненулевом L_OnOff), сокет
всегда будет закрываться грубо. Неблокирующие сокеты рекомендуется закрывать
только в фоновом режиме или не в фоновом, но с нулевым временем ожидания.
Остальные параметры сокета детально описаны в MSDN'е. Заключение
На этом мы завершаем рассмотрение стандартных сокетов в Windows.
Данный обзор не претендует на полноту. В частности, перечислены не все
функции, входящие в стандартную часть библиотеки сокетов, сетевые протоколы
описаны поверхностно. Тем не менее, по моему скромному мнению, эта статья
должна помочь тем, кто впервые столкнулся с программированием TCP и UDP с
помощью сокетов. Я старался, с одной стороны, собрать в этой статье достаточно
сведений, чтобы человек, прочитавший её, во-первых, мог сразу сесть за
компьютер и что-то написать, а во-вторых, понимал, что он пишет, а не
программировал по принципу "делай так, и будет тебе счастие". С другой
стороны, я старался, чтобы сюда попала только та информация, которая относится
к TCP и UDP, потому что при чтении, например, MSDN'а приходится
отфильтровывать информацию, относящуюся к другим протоколам, да ещё и
"переводить" в уме с C++ на Delphi, а эти задачи трудны для начинающего. Одним
словом, я попытался написать как можно более популярную статью,
ориентированную именно на Delphi, а вам судить, насколько мне это удалось. А
недостающие сведения легко получить из MSDN'а или, например, отсюда: http://book.itep.ru/1/intro1.htm.
Функциональности стандартных сокетов, которые описаны здесь,
достаточно, чтобы программы могли взаимодействовать между собой, но требуемые
для этого действия плохо согласуются с типичной для Windows-программ
событийно-ориентированной схемой работы. В рамках стандартных сокетов эта
проблема легче всего решается вынесением операций с сокетами в отдельные
нити.
Существует другой подход к интеграции сокетов в
Windows-программы: асинхронный режим работы. Но он относится не к стандартным
сокетам, а к сокетам Windows, и его мы рассмотрим в следующей статье цикла. До
появления следующей статьи я предлагаю по MSDN'у самостоятельно разобраться с
некоторыми аспектами программирования стандартных сокетов, которые здесь не
описаны, а именно:
- Для каждой из упоминавшихся здесь функций выяснить, какие ошибки может
возвращать WSAGetLastError в случае неуспешного завершения и что каждая из
этих ошибок означает.
- Посмотреть, какие ещё параметры (опции) есть у сокета.
- Самостоятельно разобраться с не упомянутыми здесь функциями GetSockName,
GetHostByAddr и GetAddrByHost.
И побольше практики. Даже самая лучшая статья в мире не сделать
профессионалом того, кто сам ничего не написал.
Автор: Антон Григорьев
Источник: www.delphikingdom.com
|