
Глава 7. Сокеты
Этот перевод может быть устаревшим. Для того, чтобы помочь с переводом, пожалуйста, обратитесь к Сервер переводов FreeBSD.
Содержание
7.1. Обзор
Сокеты BSD выводят межпроцессное взаимодействие на новый уровень. Теперь взаимодействующие процессы не обязательно должны выполняться на одной машине. Они всё ещё могут, но не обязаны.
Не только эти процессы не обязаны выполняться на одной машине, они также могут работать под разными операционными системами. Благодаря BSD-сокетам, ваше ПО на FreeBSD может легко взаимодействовать с программой, работающей на Macintosh®, другой — на рабочей станции Sun™, и ещё одной — под Windows® 2000, при этом все они подключены к локальной сети на основе Ethernet.
Но ваше программное обеспечение может так же эффективно взаимодействовать с процессами, работающими в другом здании, на другом континенте, внутри подводной лодки или космического челнока.
Он также может взаимодействовать с процессами, которые не являются частью компьютера (по крайней мере, не в строгом смысле этого слова), а таких устройств, как принтеры, цифровые камеры, медицинское оборудование. Практически со всем, что способно к цифровой коммуникации.
7.2. Сетевое взаимодействие и разнообразие
Мы уже упоминали о разнообразии сетевых технологий. Множество различных систем должны взаимодействовать друг с другом. И они должны говорить на одном языке. Также они должны понимать этот язык одинаковым образом.
Часто думают, что язык тела универсален. Но это не так. В ранней юности отец взял меня с собой в Болгарию. Мы сидели за столиком в парке Софии, когда к нам подошел продавец, предлагая купить жареный миндаль.
Я тогда еще не знал болгарского, поэтому вместо словесного отказа я покачал головой из стороны в сторону — это «универсальный» язык тела для обозначения нет. Продавец тут же начал угощать нас миндалем.
Затем я вспомнил, что мне говорили, будто в Болгарии покачивание головой из стороны в сторону означает да. Быстро я начал кивать головой вверх-вниз. Продавец заметил, взял свои миндалины и ушёл. Для непосвящённого наблюдателя я не изменил язык тела: я продолжал использовать движения головой — покачивание и кивание. Изменился смысл языка тела. Сначала продавец и я интерпретировали одни и те же жесты как имеющие совершенно разный смысл. Мне пришлось скорректировать свою собственную интерпретацию этих жестов, чтобы продавец меня понял.
То же самое и с компьютерами: одни и те же символы могут иметь разное, даже полностью противоположное значение. Поэтому, чтобы два компьютера понимали друг друга, они должны договориться не только об одном языке, но и об одном толковании языка.
7.3. Протоколы
В то время как различные языки программирования обычно имеют сложный синтаксис и используют множество многосимвольных зарезервированных слов (что облегчает их понимание для человека-программиста), языки передачи данных, как правило, очень лаконичны. Вместо многосимвольных слов они часто используют отдельные биты. Для этого есть очень убедительная причина: хотя данные внутри вашего компьютера передаются со скоростью, близкой к скорости света, между двумя компьютерами они часто передаются значительно медленнее.
Поскольку языки, используемые в передаче данных, настолько лаконичны, мы обычно называем их протоколами, а не языками.
При передаче данных от одного компьютера к другому всегда используется более одного протокола. Эти протоколы располагаются слоями. Данные можно сравнить с внутренней частью лука: необходимо снять несколько слоёв «кожи», чтобы добраться до данных. Это лучше всего проиллюстрировано на рисунке:

Рисунок 1. Уровни протоколов
В этом примере мы пытаемся получить изображение с веб-страницы, к которой подключены через Ethernet.
Изображение состоит из необработанных данных, которые представляют собой просто последовательность значений RGB, которые наше программное обеспечение может обработать, т.е. преобразовать в изображение и отобразить на нашем мониторе.
Увы, наше программное обеспечение не может определить, как организованы сырые данные: это последовательность значений RGB, последовательность интенсивностей в градациях серого или, возможно, цвета в кодировке CMYK? Представлены ли данные 8-битными квантами, или они имеют размер 16 бит, а может быть, 4 бита? Из скольких строк и столбцов состоит изображение? Должны ли определённые пиксели быть прозрачными?
Я думаю, вы поняли…
Чтобы наше программное обеспечение понимало, как обрабатывать сырые данные, они кодируются в формате PNG. Это мог бы быть GIF или JPEG, но выбран PNG.
И PNG — это протокол.
В этот момент я слышу, как некоторые из вас кричат: "Нет, это не так! Это формат файла!"
Ну что ж, конечно, это формат файла. Но с точки зрения передачи данных, формат файла — это протокол: структура файла — это язык, причем лаконичный, сообщающий нашему процессу, как организованы данные. Следовательно, это протокол.
Увы, если бы мы получили только PNG-файл, наше программное обеспечение столкнулось бы с серьёзной проблемой: как ему узнать, что данные представляют изображение, а не текст, звук или что-то ещё? Во-вторых, как ему определить, что изображение сохранено в формате PNG, а не GIF, JPEG или каком-либо другом формате изображений?
Для получения этой информации мы используем другой протокол: HTTP. Этот протокол может точно сообщить нам, что данные представляют изображение и используют протокол PNG. Он также может сообщить некоторые другие сведения, но давайте сосредоточимся на уровнях протоколов здесь.
Итак, теперь у нас есть некоторые данные, упакованные в протокол PNG, который в свою очередь упакован в протокол HTTP. Как мы получили их с сервера?
Используя TCP/IP поверх Ethernet, вот как. Действительно, это ещё три протокола. Вместо того чтобы продолжать изнутри наружу, я теперь расскажу про Ethernet, просто потому что так проще объяснить остальное.
Ethernet — это интересная система соединения компьютеров в локальной сети (LAN). У каждого компьютера есть карта сетевого интерфейса (NIC — Network Interface Card) с уникальным 48-битным идентификатором, называемым адресом. Не существует двух сетевых интерфейсов Ethernet в мире с одинаковым адресом.
Эти сетевые карты соединены между собой. Когда один компьютер хочет связаться с другим в той же локальной сети Ethernet, он отправляет сообщение по сети. Каждая сетевая карта видит это сообщение. Однако, согласно протоколу Ethernet, данные содержат адрес сетевой карты назначения (среди прочего). Таким образом, только одна из всех сетевых карт обратит на него внимание, остальные проигнорируют его.
Но не все компьютеры подключены к одной сети. Тот факт, что мы получили данные через наш Ethernet, не означает, что они возникли в нашей локальной сети. Они могли попасть к нам из другой сети (которая может быть даже не на основе Ethernet), соединённой с нашей сетью через Интернет.
Все данные передаются через Интернет с использованием IP, что означает Internet Protocol. Его основная роль — сообщать нам, откуда в мире пришли данные и куда они должны быть направлены. Он не гарантирует, что мы получим данные, только что мы узнаем, откуда они пришли, если мы их получим.
Даже если мы получим данные, IP не гарантирует, что различные фрагменты данных придут в том же порядке, в котором их отправил другой компьютер. Например, мы можем получить центр нашего изображения до того, как получим его верхний левый угол, а после — нижний правый.
Это TCP (Transmission Control Protocol), который запрашивает у отправителя повторную отправку потерянных данных и располагает их в правильном порядке.
В итоге потребовалось пять различных протоколов, чтобы один компьютер мог сообщить другому, как выглядит изображение. Мы получили данные, упакованные в протокол PNG, который был упакован в протокол HTTP, который был упакован в протокол TCP, который был упакован в протокол IP, который был упакован в протокол Ethernet.
О, и кстати, вероятно, на пути были задействованы и несколько других протоколов. Например, если наша локальная сеть была подключена к Интернету через дозвон, то использовался протокол PPP над модемом, который, в свою очередь, использовал один (или несколько) из различных модемных протоколов, и так далее, и так далее, и так далее…
Как разработчик, вы уже должны задаваться вопросом: "Как я должен со всем этим справляться?"
К счастью для вас, вам не нужно разбираться во всём этом. Вам придётся разобраться в некоторой части, но не во всей. В частности, вам не нужно беспокоиться о физическом подключении (в нашем случае Ethernet и, возможно, PPP и т.д.). Также вам не нужно разбираться с протоколом IP или протоколом TCP.
Другими словами, вам не нужно ничего делать, чтобы получить данные с другого компьютера. Ну, разве что попросить их, но это почти так же просто, как открыть файл.
Получив данные, вам предстоит решить, что с ними делать. В нашем случае потребуется понимание протокола HTTP и структуры файла PNG.
Используя аналогию, все межсетевые протоколы становятся серой зоной: не столько потому, что мы не понимаем, как они работают, а потому, что нас это больше не беспокоит. Интерфейс сокетов берёт на себя заботу об этой серой зоне:

Рисунок 2. Уровни протоколов, покрываемые сокетами
Нам нужно понимать только те протоколы, которые говорят нам, как интерпретировать данные, а не как получать их от другого процесса или как передавать их другому процессу.
7.4. Модель сокетов
Сокеты BSD построены по базовой модели UNIX®: Все является файлом. Таким образом, в нашем примере сокеты позволят нам получить, образно говоря, HTTP-файл. Затем нам предстоит извлечь из него PNG-файл.
Из-за сложности межсетевого взаимодействия мы не можем просто использовать системный вызов open
или функцию open()
в языке C. Вместо этого необходимо выполнить несколько шагов для "открытия" сокета.
Однако, как только мы это сделаем, мы можем начать обращаться с сокетом так же, как и с любым файловым дескриптором: мы можем читать
из него, писать
в него, передавать его через канал
и, в конечном итоге, закрывать
его.
7.5. Основные функции сокетов
В то время как FreeBSD предлагает различные функции для работы с сокетами, нам требуется только четыре, чтобы "открыть" сокет. А в некоторых случаях достаточно двух.
7.5.1. Разница между клиентом и сервером
Обычно одним из концов связи на основе сокетов является сервер, а другой — клиент.
7.5.1.1. Общие элементы
7.5.1.1.1. socket
Функция, используемая как клиентами, так и серверами, это socket(2). Она объявляется следующим образом:
int socket(int domain, int type, int protocol);
Возвращаемое значение имеет тот же тип, что и у open
, целое число. FreeBSD выделяет его значение из того же пула, что и дескрипторы файлов. Это позволяет обрабатывать сокеты так же, как файлы.
Аргумент domain
указывает системе, какое семейство протоколов следует использовать. Существует множество семейств, некоторые из них специфичны для определённых поставщиков, другие широко распространены. Они объявлены в sys/socket.h.
Используйте PF_INET
для UDP, TCP и других интернет-протоколов (IPv4).
Для аргумента type
определено пять значений, также указанных в sys/socket.h. Все они начинаются с “SOCK_”. Наиболее распространённое — SOCK_STREAM
, которое указывает системе, что запрашивается надёжный сервис потоковой доставки (это TCP при использовании с PF_INET
).
Если бы вы запросили SOCK_DGRAM
, вы бы запросили сервис доставки датаграмм без установления соединения (в нашем случае, UDP).
Если вы хотите управлять низкоуровневыми протоколами (такими как IP) или даже сетевыми интерфейсами (например, Ethernet), вам потребуется указать SOCK_RAW
.
Наконец, аргумент protocol
зависит от двух предыдущих аргументов и не всегда имеет смысл. В таком случае используйте значение 0
.
Неподключенный сокет Нигде в функции Это сделано намеренно: если проводить аналогию с телефоном, мы только что подключили модем к телефонной линии. Мы не сказали модему совершить звонок или ответить, если телефон зазвонит. |
7.5.1.1.2. sockaddr
Различные функции семейства сокетов ожидают адрес (или указатель, если использовать терминологию языка C) небольшой области памяти. Различные объявления на языке C в файле sys/socket.h ссылаются на неё как на struct sockaddr
. Эта структура объявлена в том же файле:
/* * Structure used by kernel to store most * addresses. */ struct sockaddr { unsigned char sa_len; /* total length */ sa_family_t sa_family; /* address family */ char sa_data[14]; /* actually longer; address value */ }; #define SOCK_MAXADDRLEN 255 /* longest possible addresses */
Обратите внимание на неопределённость, с которой объявлено поле sa_data
— просто как массив из 14
байт, с комментарием, намекающим, что их может быть больше 14
.
Эта неопределенность вполне преднамеренна. Сокеты — это очень мощный интерфейс. Хотя большинство людей, возможно, считают их не более чем интерфейсом для Интернета — и большинство приложений, вероятно, используют их именно для этого в наши дни — сокеты могут быть использованы практически для любого вида межпроцессного взаимодействия, из которых Интернет (или, точнее, IP) — лишь один из них.
sys/socket.h ссылается на различные типы протоколов, с которыми работают сокеты, как на семейства адресов, и перечисляет их непосредственно перед определением sockaddr
:
/* * Address families. */ #define AF_UNSPEC 0 /* unspecified */ #define AF_LOCAL 1 /* local to host (pipes, portals) */ #define AF_UNIX AF_LOCAL /* backward compatibility */ #define AF_INET 2 /* internetwork: UDP, TCP, etc. */ #define AF_IMPLINK 3 /* arpanet imp addresses */ #define AF_PUP 4 /* pup protocols: e.g. BSP */ #define AF_CHAOS 5 /* mit CHAOS protocols */ #define AF_NS 6 /* XEROX NS protocols */ #define AF_ISO 7 /* ISO protocols */ #define AF_OSI AF_ISO #define AF_ECMA 8 /* European computer manufacturers */ #define AF_DATAKIT 9 /* datakit protocols */ #define AF_CCITT 10 /* CCITT protocols, X.25 etc */ #define AF_SNA 11 /* IBM SNA */ #define AF_DECnet 12 /* DECnet */ #define AF_DLI 13 /* DEC Direct data link interface */ #define AF_LAT 14 /* LAT */ #define AF_HYLINK 15 /* NSC Hyperchannel */ #define AF_APPLETALK 16 /* Apple Talk */ #define AF_ROUTE 17 /* Internal Routing Protocol */ #define AF_LINK 18 /* Link layer interface */ #define pseudo_AF_XTP 19 /* eXpress Transfer Protocol (no AF) */ #define AF_COIP 20 /* connection-oriented IP, aka ST II */ #define AF_CNT 21 /* Computer Network Technology */ #define pseudo_AF_RTIP 22 /* Help Identify RTIP packets */ #define AF_IPX 23 /* Novell Internet Protocol */ #define AF_SIP 24 /* Simple Internet Protocol */ #define pseudo_AF_PIP 25 /* Help Identify PIP packets */ #define AF_ISDN 26 /* Integrated Services Digital Network*/ #define AF_E164 AF_ISDN /* CCITT E.164 recommendation */ #define pseudo_AF_KEY 27 /* Internal key-management function */ #define AF_INET6 28 /* IPv6 */ #define AF_NATM 29 /* native ATM access */ #define AF_ATM 30 /* ATM */ #define pseudo_AF_HDRCMPLT 31 /* Used by BPF to not rewrite headers * in interface output routine */ #define AF_NETGRAPH 32 /* Netgraph sockets */ #define AF_SLOW 33 /* 802.3ad slow protocol */ #define AF_SCLUSTER 34 /* Sitara cluster protocol */ #define AF_ARP 35 #define AF_BLUETOOTH 36 /* Bluetooth sockets */ #define AF_MAX 37
Используемый для IP — это AF_INET. Это символ для константы 2
.
Это семейство адресов, указанное в поле sa_family
структуры sockaddr
, определяет, как именно будут использоваться нечетко названные байты sa_data
.
В частности, когда семейство адресов — AF_INET, можно использовать struct sockaddr_in
из netinet/in.h везде, где ожидается sockaddr
:
/* * Socket address, internet style. */ struct sockaddr_in { uint8_t sin_len; sa_family_t sin_family; in_port_t sin_port; struct in_addr sin_addr; char sin_zero[8]; };
Мы можем визуализировать его организацию следующим образом:

Рисунок 3. Структура
sockaddr_in
Три важных поля — это sin_family
, которое находится в байте 1 структуры, sin_port
, 16-битное значение, расположенное в байтах 2 и 3, и sin_addr
, 32-битное целочисленное представление IP-адреса, хранящееся в байтах 4–7.
Теперь попробуем заполнить его. Предположим, мы пытаемся написать клиент для протокола daytime, который просто указывает, что его сервер записывает текстовую строку с текущей датой и временем в порт 13. Мы хотим использовать TCP/IP, поэтому нам нужно указать AF_INET
в поле семейства адресов. AF_INET
определен как 2
. Давайте используем IP-адрес 192.43.244.18
, который является сервером времени федерального правительства США (time.nist.gov
).

Рисунок 4. Конкретный пример sockaddr_in
Кстати, поле sin_addr
объявлено как имеющее тип struct in_addr
, который определён в netinet/in.h:
/* * Internet address (a structure for historical reasons) */ struct in_addr { in_addr_t s_addr; };
В дополнение, in_addr_t
является 32-битным целым числом.
192.43.244.18
— это просто удобная форма записи 32-битного целого числа, в которой перечисляются все его 8-битные байты, начиная с старшего.
До сих пор мы рассматривали sockaddr
как абстракцию. Наш компьютер не хранит short
целые числа как единую 16-битную сущность, а как последовательность 2 байт. Аналогично, он хранит 32-битные целые числа как последовательность 4 байт.
Предположим, мы написали что-то вроде этого:
sa.sin_family = AF_INET; sa.sin_port = 13; sa.sin_addr.s_addr = (((((192 << 8) | 43) << 8) | 244) << 8) | 18;
Как будет выглядеть результат?
Ну, это, конечно, зависит от многого. На компьютере с процессором Pentium® или другим на базе x86 это будет выглядеть так:

Рисунок 5.
sockaddr_in
в системе с архитектурой IntelНа другой системе это может выглядеть так:

Рисунок 6.
sockaddr_in
в системе с порядком байтов от старшего к младшемуИ на PDP это может выглядеть иначе. Однако два приведённых выше варианта являются наиболее распространёнными на сегодняшний день.
Обычно, стремясь писать переносимый код, программисты делают вид, что этих различий не существует. И им это сходит с рук (за исключением случаев, когда они пишут на ассемблере). Увы, при программировании сокетов так легко отделаться не получится.
Почему?
Потому что при обмене данными с другим компьютером вы обычно не знаете, хранит ли он данные, начиная со старшего байта (MSB) или с младшего байта (LSB).
Вы можете задаться вопросом: "Значит, сокеты не будут это делать за меня?"
Не будут.
Хотя этот ответ может сначала вас удивить, помните, что общий интерфейс сокетов понимает только поля sa_len
и sa_family
структуры sockaddr
. Вам не нужно беспокоиться о порядке байтов (конечно, в FreeBSD sa_family
занимает всего 1 байт, но многие другие UNIX®-системы не имеют sa_len
и используют 2 байта для sa_family
, ожидая данные в том порядке, который является родным для компьютера).
Но остальные данные — это просто sa_data[14]
с точки зрения сокетов. В зависимости от семейства адресов сокеты просто передают эти данные по назначению.
Действительно, когда мы указываем номер порта, это делается для того, чтобы другая компьютерная система знала, какую службу мы запрашиваем. И, когда мы выступаем в роли сервера, мы считываем номер порта, чтобы понять, какую службу ожидает от нас другая система. В любом случае, сокетам нужно лишь передать номер порта в качестве данных. Они никак его не интерпретируют.
Аналогично, мы указываем IP-адрес, чтобы сообщить всем на пути, куда отправлять наши данные. Сокеты, опять же, просто пересылают их как данные.
Вот почему мы (программисты, а не сокеты) должны различать порядок байтов, используемый нашим компьютером, и условный порядок байтов для отправки данных на другой компьютер.
Мы будем называть порядок байтов, который использует наш компьютер, порядком байтов хоста или просто хост-порядком.
Существует соглашение о передаче многобайтовых данных по IP старшим байтом вперёд. Это мы будем называть порядком байтов сети или просто сетевым порядком.
Вот, если бы мы скомпилировали приведённый выше код для компьютера на базе Intel, наш порядок байтов хоста выдал бы:

Рисунок 7. Порядок байтов на хосте в системе Intel
Но порядок байтов в сетевом формате требует, чтобы данные хранились начиная со старшего байта (MSB):

Рисунок 8. Порядок байтов в сети
К сожалению, наш порядок хоста полностью противоположен порядку сети.
У нас есть несколько способов решения этой проблемы. Один из них — инвертировать значения в нашем коде:
sa.sin_family = AF_INET; sa.sin_port = 13 << 8; sa.sin_addr.s_addr = (((((18 << 8) | 244) << 8) | 43) << 8) | 192;
Это обманет наш компилятор, заставив его сохранить данные в порядке байтов сети. В некоторых случаях это именно тот способ, который нужен (например, при программировании на ассемблере). Однако в большинстве случаев это может вызвать проблему.
Предположим, вы написали программу на C, использующую сокеты. Вы знаете, что она будет работать на Pentium®, поэтому вводите все константы в обратном порядке и приводите их к порядку байтов сети. Она работает хорошо.
Затем, однажды, ваш надежный старый Pentium® превращается в ржавый старый Pentium®. Вы заменяете его системой, у которой порядок байтов хоста совпадает с сетевым порядком байтов. Вам нужно перекомпилировать все ваше программное обеспечение. Все ваши программы продолжают работать хорошо, кроме той одной программы, которую вы написали.
Вы уже забыли, что принудительно задали все свои константы противоположными порядку хоста. Вы проводите некоторое время, яростно рвя на себе волосы, взывая ко всем известным вам богам (и к некоторым, которых вы придумали), стуча нерф-битой по монитору и выполняя прочие традиционные ритуалы в попытке понять, почему то, что работало так хорошо, внезапно перестало работать вообще.
В конце концов, вы разбираетесь в проблеме, произносите пару крепких словечек и начинаете переписывать свой код.
К счастью, вы не первый, кто столкнулся с этой проблемой. Кто-то уже создал функции htons(3) и htonl(3) на языке C для преобразования short
и long
соответственно из порядка байтов хоста в порядок байтов сети, а также функции ntohs(3) и ntohl(3) на языке C для обратного преобразования.
На системах с порядком старший байт первый эти функции не выполняют никаких действий. На системах с порядком младший байт первый они преобразуют значения в правильный порядок.
Итак, независимо от того, на какой системе компилируется ваше программное обеспечение, ваши данные будут в правильном порядке, если вы используете эти функции.
7.5.1.2. Функции клиента
Обычно клиент инициирует подключение к серверу. Клиент знает, к какому серверу он собирается обратиться: он знает его IP-адрес и порт, на котором работает сервер. Это похоже на то, как вы поднимаете трубку и набираете номер (адрес), а затем, когда кто-то отвечает, просите соединить со специалистом по непонятным символам (порт).
7.5.1.2.1. connect
Как только клиент создал сокет, ему нужно подключить его к определённому порту на удалённой системе. Для этого используется connect(2):
int connect(int s, const struct sockaddr *name, socklen_t namelen);
Аргумент s
— это сокет, то есть значение, возвращаемое функцией socket
. Аргумент name
— это указатель на структуру sockaddr
, которую мы подробно обсуждали. Наконец, namelen
сообщает системе, сколько байт находится в нашей структуре sockaddr
.
Если connect
завершается успешно, он возвращает 0
. В противном случае возвращается -1
, а код ошибки сохраняется в errno
.
Существует множество причин, по которым connect
может завершиться неудачей. Например, при попытке подключения к интернету, IP-адрес может не существовать, быть недоступен, перегружен или на указанном порту может не быть сервера. Или же подключение может быть явно отклонено по определённым причинам.
7.5.1.2.2. Наш первый клиент
Теперь мы знаем достаточно, чтобы написать очень простого клиента, который получит текущее время от 192.43.244.18
и выведет его в stdout.
/* * daytime.c * * Programmed by G. Adam Stanislav */ #include <stdio.h> #include <string.h> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <unistd.h> int main() { int s, bytes; struct sockaddr_in sa; char buffer[BUFSIZ+1]; if ((s = socket(PF_INET, SOCK_STREAM, 0)) < 0) { perror("socket"); return 1; } memset(&sa, '\0', sizeof(sa)); sa.sin_family = AF_INET; sa.sin_port = htons(13); sa.sin_addr.s_addr = htonl((((((192 << 8) | 43) << 8) | 244) << 8) | 18); if (connect(s, (struct sockaddr *)&sa, sizeof sa) < 0) { perror("connect"); close(s); return 2; } while ((bytes = read(s, buffer, BUFSIZ)) > 0) write(1, buffer, bytes); close(s); return 0; }
Вперед! Введите это в вашем редакторе, сохраните как daytime.c, затем скомпилируйте и запустите:
% cc -O3 -o daytime daytime.c
% ./daytime
52079 01-06-19 02:29:25 50 0 1 543.9 UTC(NIST) *
%
В данном случае дата была 19 июня 2001 года, время — 02:29:25 UTC. Естественно, ваши результаты могут отличаться.
7.5.1.3. Функции сервера
Типичный сервер не инициирует соединение. Вместо этого он ожидает, когда клиент обратится к нему и запросит услуги. Он не знает, когда клиент обратится, ни сколько клиентов обратится. В один момент он может просто спокойно ожидать, а в следующий момент он может оказаться перегруженным запросами от множества клиентов, обращающихся одновременно.
Интерфейс сокетов предоставляет три основные функции для обработки этого.
7.5.1.3.1. bind
Порты подобны внутренним номерам телефонной линии: после набора основного номера вы набираете внутренний номер, чтобы связаться с конкретным человеком или отделом.
Существует 65535 IP-портов, но сервер обычно обрабатывает запросы, поступающие только на один из них. Это как сказать оператору телефонной комнаты, что мы сейчас на месте и готовы отвечать на звонки по определённому внутреннему номеру. Мы используем bind(2), чтобы указать сокетам, на каком порту мы хотим обслуживать запросы.
int bind(int s, const struct sockaddr *addr, socklen_t addrlen);
Помимо указания порта в addr
, сервер может включать свой IP-адрес. Однако он может просто использовать символическую константу INADDR_ANY, чтобы указать, что будет обслуживать все запросы на указанный порт, независимо от его IP-адреса. Этот символ, наряду с несколькими аналогичными, объявлен в netinet/in.h
#define INADDR_ANY (u_int32_t)0x00000000
Предположим, мы пишем сервер для протокола daytime поверх TCP/IP. Напомним, что он использует порт 13. Наша структура sockaddr_in
будет выглядеть так:

Рисунок 9. Пример sockaddr_in сервера
7.5.1.3.2. listen
Продолжая аналогию с офисным телефоном, после того как вы сообщили оператору АТС, на каком внутреннем номере вы будете находиться, вы заходите в свой офис и убеждаетесь, что ваш телефон подключен и звонок включен. Кроме того, вы активируете функцию ожидания вызова, чтобы слышать звонок даже во время разговора с кем-то.
Сервер обеспечивает все это с помощью функции listen(2).
int listen(int s, int backlog);
Здесь переменная backlog
указывает сокетам, сколько входящих запросов принимать, пока вы заняты обработкой последнего запроса. Другими словами, она определяет максимальный размер очереди ожидающих соединений.
7.5.1.3.3. accept
После того как вы услышите телефонный звонок, вы принимаете вызов, отвечая на звонок. Теперь вы установили соединение с вашим клиентом. Это соединение остается активным, пока вы или ваш клиент не повесите трубку.
Сервер принимает соединение, используя функцию accept(2).
int accept(int s, struct sockaddr *addr, socklen_t *addrlen);
Обратите внимание, что в этот раз addrlen
является указателем. Это необходимо, потому что в данном случае именно сокет заполняет структуру addr
— sockaddr_in
.
Возвращаемое значение является целым числом. Действительно, accept
возвращает новый сокет. Этот новый сокет будет использоваться для обмена данными с клиентом.
Что происходит со старым сокетом? Он продолжает ожидать новые запросы (помните переменную backlog
, которую мы передали в listen
?), пока мы не закроем его (close
).
Теперь новый сокет предназначен только для обмена данными. Он полностью подключен. Мы не можем снова передать его в listen
, чтобы принимать дополнительные соединения.
7.5.1.3.4. Наш первый сервер
Наш первый сервер будет несколько сложнее, чем первый клиент: нам нужно не только использовать больше функций сокетов, но и написать его как демон.
Это лучше всего достигается созданием дочернего процесса после привязки порта. Затем основной процесс завершается и возвращает управление оболочке (или любой другой программе, которая его вызвала).
Дочерний процесс вызывает listen
, затем запускает бесконечный цикл, который принимает соединение, обслуживает его и в конечном итоге закрывает свой сокет.
/* * daytimed - a port 13 server * * Programmed by G. Adam Stanislav * June 19, 2001 */ #include <stdio.h> #include <string.h> #include <time.h> #include <unistd.h> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #define BACKLOG 4 int main() { int s, c; socklen_t b; struct sockaddr_in sa; time_t t; struct tm *tm; FILE *client; if ((s = socket(PF_INET, SOCK_STREAM, 0)) < 0) { perror("socket"); return 1; } memset(&sa, '\0', sizeof(sa)); sa.sin_family = AF_INET; sa.sin_port = htons(13); if (INADDR_ANY) sa.sin_addr.s_addr = htonl(INADDR_ANY); if (bind(s, (struct sockaddr *)&sa, sizeof sa) < 0) { perror("bind"); return 2; } switch (fork()) { case -1: perror("fork"); return 3; default: close(s); return 0; case 0: break; } listen(s, BACKLOG); for (;;) { b = sizeof sa; if ((c = accept(s, (struct sockaddr *)&sa, &b)) < 0) { perror("daytimed accept"); return 4; } if ((client = fdopen(c, "w")) == NULL) { perror("daytimed fdopen"); return 5; } if ((t = time(NULL)) < 0) { perror("daytimed time"); return 6; } tm = gmtime(&t); fprintf(client, "%.4i-%.2i-%.2iT%.2i:%.2i:%.2iZ\n", tm->tm_year + 1900, tm->tm_mon + 1, tm->tm_mday, tm->tm_hour, tm->tm_min, tm->tm_sec); fclose(client); } }
Начинаем с создания сокета. Затем заполняем структуру sockaddr_in
в sa
. Обратите внимание на условное использование INADDR_ANY:
if (INADDR_ANY) sa.sin_addr.s_addr = htonl(INADDR_ANY);
Его значение равно 0
. Поскольку мы только что использовали bzero
для всей структуры, будет избыточным снова устанавливать его в 0
. Но если мы перенесем наш код на другую систему, где INADDR_ANY, возможно, не равен нулю, нам нужно будет присвоить его sa.sin_addr.s_addr
. Большинство современных компиляторов C достаточно умны, чтобы заметить, что INADDR_ANY — это константа. Пока она равна нулю, они оптимизируют все условное выражение из кода.
После успешного вызова bind
мы готовы стать демоном: используем fork
для создания дочернего процесса. В обоих процессах, родительском и дочернем, переменная s
является нашим сокетом. Родительскому процессу он больше не нужен, поэтому он вызывает close
, затем возвращает 0
, чтобы сообщить своему родителю об успешном завершении.
Между тем, дочерний процесс продолжает работать в фоновом режиме. Он вызывает listen
и устанавливает размер очереди ожидания (backlog
) равным 4
. Здесь не требуется большое значение, так как daytime — это не протокол, который часто запрашивают клиенты, и, кроме того, он может мгновенно обрабатывать каждый запрос.
Наконец, демон запускает бесконечный цикл, который выполняет следующие шаги:
Вызовите
accept
. Он ожидает здесь, пока клиент не свяжется с ним. В этот момент он получает новый сокет,c
, который можно использовать для обмена данными с этим конкретным клиентом.Он использует функцию C
fdopen
для преобразования сокета из низкоуровневого дескриптора файла в указатель типаFILE
в стиле C. Это позволит в дальнейшем использоватьfprintf
.Он проверяет время и выводит его в формате ISO 8601 в «файл»
client
. Затем он используетfclose
для закрытия файла. Это также автоматически закроет сокет.
Мы можем обобщить это и использовать в качестве модели для многих других серверов:

Рисунок 10. Последовательный Сервер
Эта блок-схема подходит для последовательных серверов, то есть серверов, которые могут обслуживать одного клиента за раз, как это было возможно с нашим daytime сервером. Это возможно только в тех случаях, когда между клиентом и сервером не происходит реального "диалога": как только сервер обнаруживает подключение клиента, он отправляет некоторые данные и закрывает соединение. Вся операция может занять наносекунды, и она завершена.
Преимущество этой блок-схемы в том, что, за исключением короткого момента после того, как родительский процесс выполняет fork
и до его завершения, всегда активен только один процесс: Наш сервер не занимает много памяти и других системных ресурсов.
Обратите внимание, что мы добавили инициализацию демона в нашу блок-схему. Нам не нужно было инициализировать собственный демон, но это подходящее место в потоке выполнения программы для настройки обработчиков signal
, открытия необходимых файлов и т. д.
Почти все элементы блок-схемы могут быть использованы буквально на множестве различных серверов. Элемент serve является исключением. Мы рассматриваем его как "чёрный ящик", то есть нечто, что вы проектируете специально для своего сервера и просто "подключаете к остальной системе."
Не все протоколы настолько просты. Многие получают запрос от клиента, отвечают на него, а затем получают ещё один запрос от того же клиента. В результате, они не знают заранее, как долго будут обслуживать клиента. Такие серверы обычно запускают новый процесс для каждого клиента. Пока новый процесс обслуживает своего клиента, демон может продолжать прослушивать новые подключения.
Теперь сохраните приведённый исходный код в файл daytimed.c (обычно имена демонов оканчиваются буквой d
). После компиляции попробуйте запустить его:
% ./daytimed
bind: Permission denied
%
Что произошло? Как вы помните, протокол daytime использует порт 13. Однако все порты ниже 1024 зарезервированы для суперпользователя (в противном случае любой мог бы запустить демон, притворяясь, что обслуживает часто используемый порт, создавая угрозу безопасности).
Попробуйте снова, на этот раз как суперпользователь:
# ./daytimed
#
Что… Ничего? Давайте попробуем еще раз:
# ./daytimed
bind: Address already in use
#
Каждый порт может быть связан только одной программой одновременно. Наша первая попытка действительно была успешной: она запустила дочерний демон и завершилась без ошибок. Он продолжает работать и будет работать до тех пор, пока вы его не завершите командой kill, пока какой-либо из его системных вызовов не завершится с ошибкой или пока вы не перезагрузите систему.
Хорошо, мы знаем, что он работает в фоновом режиме. Но работает ли он? Как мы можем убедиться, что это настоящий сервер daytime? Просто:
% telnet localhost 13
Trying ::1...
telnet: connect to address ::1: Connection refused
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
2001-06-19T21:04:42Z
Connection closed by foreign host.
%
telnet попробовал использовать новый IPv6, но не смог. Затем он повторил попытку с IPv4, и это удалось. Демон работает.
Если у вас есть доступ к другой UNIX®-системе через telnet, вы можете использовать её для проверки удалённого доступа к серверу. Мой компьютер не имеет статического IP-адреса, поэтому я сделал следующее:
% who
whizkid ttyp0 Jun 19 16:59 (216.127.220.143)
xxx ttyp1 Jun 19 16:06 (xx.xx.xx.xx)
% telnet 216.127.220.143 13
Trying 216.127.220.143...
Connected to r47.bfm.org.
Escape character is '^]'.
2001-06-19T21:31:11Z
Connection closed by foreign host.
%
Снова, это сработало. Сработает ли это с использованием доменного имени?
% telnet r47.bfm.org 13
Trying 216.127.220.143...
Connected to r47.bfm.org.
Escape character is '^]'.
2001-06-19T21:31:40Z
Connection closed by foreign host.
%
Кстати, telnet выводит сообщение Connection closed by foreign host после того, как наш демон закрыл сокет. Это показывает, что использование fclose(client);
в нашем коде действительно работает, как заявлено.
7.6. Вспомогательные функции
Библиотека C в FreeBSD содержит множество вспомогательных функций для программирования сокетов. Например, в нашем примере клиента мы жестко прописали IP-адрес time.nist.gov
. Но мы не всегда знаем IP-адрес. Даже если знаем, наше программное обеспечение будет более гибким, если позволит пользователю ввести IP-адрес или даже доменное имя.
7.6.1. gethostbyname
Хотя нет возможности передать имя домена напрямую в какие-либо функции сокетов, стандартная библиотека C в FreeBSD предоставляет функции gethostbyname(3) и gethostbyname2(3), объявленные в netdb.h.
struct hostent * gethostbyname(const char *name); struct hostent * gethostbyname2(const char *name, int af);
Оба возвращают указатель на структуру hostent
, содержащую много информации о домене. Для наших целей поле h_addr_list[0]
структуры указывает на h_length
байтов правильного адреса, уже сохранённого в порядке байтов сети.
Это позволяет нам создать гораздо более гибкую — и гораздо более полезную — версию нашей программы daytime:
/* * daytime.c * * Programmed by G. Adam Stanislav * 19 June 2001 */ #include <stdio.h> #include <string.h> #include <unistd.h> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <netdb.h> int main(int argc, char *argv[]) { int s, bytes; struct sockaddr_in sa; struct hostent *he; char buf[BUFSIZ+1]; char *host; if ((s = socket(PF_INET, SOCK_STREAM, 0)) < 0) { perror("socket"); return 1; } memset(&sa, '\0', sizeof(sa)); sa.sin_family = AF_INET; sa.sin_port = htons(13); host = (argc > 1) ? argv[1] : "time.nist.gov"; if ((he = gethostbyname(host)) == NULL) { herror(host); return 2; } memcpy(&sa.sin_addr, he->h_addr_list[0], he->h_length); if (connect(s, (struct sockaddr *)&sa, sizeof sa) < 0) { perror("connect"); return 3; } while ((bytes = read(s, buf, BUFSIZ)) > 0) write(1, buf, bytes); close(s); return 0; }
Теперь мы можем ввести доменное имя (или IP-адрес, это работает в обоих направлениях) в командной строке, и программа попытается подключиться к его серверу daytime. В противном случае, по умолчанию будет использоваться time.nist.gov
. Однако даже в этом случае мы будем использовать gethostbyname
вместо жесткого задания 192.43.244.18
. Таким образом, даже если его IP-адрес изменится в будущем, мы всё равно сможем его найти.
Поскольку получение времени от локального сервера занимает практически нулевое время, вы можете запустить daytime дважды подряд: сначала для получения времени от time.nist.gov
, а затем от вашей собственной системы. После этого вы можете сравнить результаты и увидеть, насколько точны часы вашей системы:
% daytime ; daytime localhost
52080 01-06-20 04:02:33 50 0 0 390.2 UTC(NIST) *
2001-06-20T04:02:35Z
%
Как видно, моя система опережала время NIST на две секунды.
7.6.2. getservbyname
Иногда вы можете быть не уверены, какой порт использует определённая служба. В таких случаях очень полезна функция getservbyname(3), также объявленная в netdb.h:
struct servent * getservbyname(const char *name, const char *proto);
Структура servent
содержит s_port
, в котором находится соответствующий порт, уже в порядке байтов сети.
Если бы мы не знали правильный порт для службы daytime, мы могли бы найти его следующим образом:
struct servent *se; ... if ((se = getservbyname("daytime", "tcp")) == NULL { fprintf(stderr, "Cannot determine which port to use.\n"); return 7; } sa.sin_port = se->s_port;
Обычно порт известен. Но если вы разрабатываете новый протокол, вы можете тестировать его на неофициальном порту. Когда-нибудь вы зарегистрируете протокол и его порт (если не где-то ещё, то хотя бы в вашем /etc/services, где getservbyname
ищет). Вместо возврата ошибки в приведённом выше коде вы просто используете временный номер порта. Как только вы добавите протокол в /etc/services, ваше программное обеспечение найдёт его порт без необходимости переписывать код.
7.7. Многозадачные серверы
В отличие от последовательного сервера, многозадачный сервер должен иметь возможность обслуживать более одного клиента одновременно. Например, сервер чата может обслуживать конкретного клиента часами — он не может ждать, пока закончит обслуживать текущего клиента, прежде чем перейти к следующему.
Это требует значительных изменений в нашей блок-схеме:

Рисунок 11. Многозадачный сервер
Мы переместили службу из демона в её собственный серверный процесс. Однако, поскольку каждый дочерний процесс наследует все открытые файлы (а сокет обрабатывается так же, как файл), новый процесс наследует не только "принятый дескриптор", т.е. сокет, возвращённый вызовом accept
, но и главный сокет, т.е. тот, который был открыт главным процессом в самом начале.
Однако серверному процессу этот сокет не нужен, и он должен немедленно вызвать ему close
. Аналогично, демону больше не нужен сокет, принятый вызовом accept, и он не только должен, но и обязан вызвать ему close
— в противном случае рано или поздно закончатся доступные файловые дескрипторы.
После завершения обслуживания серверного процесса он должен закрыть принятый сокет. Вместо возврата к accept
, процесс теперь завершается.
В UNIX® процесс на самом деле не завершается. Вместо этого он возвращается к своему родителю. Обычно родительский процесс ждёт
(wait) завершения своего дочернего процесса и получает возвращаемое значение. Однако наш демон-процесс не может просто остановиться и ждать. Это бы свело на нет всю цель создания дополнительных процессов. Но если он никогда не выполняет wait
, его дочерние процессы станут зомби — более не функционирующими, но всё ещё бродящими вокруг.
По этой причине демону необходимо установить обработчики сигналов на этапе инициализации демона. Как минимум, должен обрабатываться сигнал SIGCHLD, чтобы демон мог удалять зомби-процессы из системы и освобождать занимаемые ими системные ресурсы.
Вот почему наша блок-схема теперь содержит блок обработки сигналов, который не соединен с другими блоками. Кстати, многие серверы также обрабатывают SIGHUP и обычно интерпретируют его как сигнал от суперпользователя, указывающий на необходимость перечитать конфигурационные файлы. Это позволяет нам изменять настройки без необходимости завершать и перезапускать эти серверы.
Изменено: 12 октября 2025 г. by Vladlen Popolitov