Книга: Сетевое программирование. От основ до приложений
Назад: Глава 2. Адресация
Дальше: Глава 4. Простой обмен данными. Raw-сокеты

Глава 3. Схема работы сети в ОС и в разных языках. UNIX и дейтаграммные сокеты

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

Doug McIlroy, «The Art of Unix Programming: Basics of the Unix Philosophy», 2003

Введение

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

В качестве ОС для рассмотрения сетевого стека возьмем Windows, QNX и Linux. Понимание структуры и механизмов работы сетевого стека позволяет пользоваться им более осмысленно и реализовывать более качественные приложения.

Затем перейдем к рассмотрению сокетных API, которые различаются на разных платформах. Так, между BSD- и POSIX-сокетами разница небольшая, а между WinSock 2 API в ОС Windows и POSIX — значительная, хотя WinSock 2 и содержит набор POSIX-совместимых функций.

На примере языка Go посмотрим, как сокетный API может быть реализован в языках, отличных от C и C++.

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

Закончим главу изучением сокетов домена Unix и дейтаграммных сокетов домена Inet, попутно затронув UDP. На примере этого протокола мы рассмотрим концепцию портов, узнаем о типах портов и функциях для работы с портами.

Схема работы сети в ОС

Рис. 3.1. Компоненты сетевой подсистемы

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

Библиотека сокетов, реализованная либо отдельно, как в Windows, либо в составе LibC, как в Linux. Эту библиотеку можно использовать из языков C и C++ напрямую. А трансляторы и библиотеки высокоуровневых языков программирования связываются с ней и используют ее функции.

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

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

Набор драйверов устройств. Разные сетевые карты и прочие устройства требуют разных способов взаимодействия с ними. Это взаимодействие обеспечивают драйверы, предоставляя высокоуровневым компонентам абстракцию устройства.

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

Библиотека выполняет проверки и преобразования, и некоторые функции, например такие, как htons(), могут быть реализованы целиком в библиотеке.

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

Например, если приложение отправляет данные, используя функцию send(), по TCP/IP через Ethernet, выполняется примерно следующий процесс:

1. Приложение формирует данные в буфере в формате протокола прикладного уровня.

2. Затем приложение осуществляет вызов библиотечной функции send(). Ей предоставляется дескриптор сокета, буфер с данными и его размер.

3. В результате выполняется системный вызов, например write() в Linux, который в ядре перенаправляется сетевому стеку.

4. Данные копируются в буфер ядра.

5. Сетевой стек, зная, что обмен производится через TCP/IP-сокет, определяет модуль, функции которого требуется вызвать, и вызывает функции передачи данных сокета.

6. Данные последовательно разбиваются на части, инкапсулируются сначала в TCP-сегменты, а затем — в IP-пакет.

7. Так как IP-адрес связан с известным устройством, стек определяет, какому драйверу и с каким идентификатором устройства передавать данные. В Linux со структурой буфера сокета в ядре уже связана структура драйвера, поэтому нужен не поиск, а просто вызов по адресу.

8. Инкапсулированные данные, переданные драйверу, передаются сетевому адаптеру с помощью специфичных для конкретного устройства команд.

9. Сетевой адаптер отправляет данные в среду передачи.

На принимающей стороне процесс будет обратным:

1. Приложение вызовет библиотечную функцию recv(), которой предоставлен дескриптор сокета, буфер под данные и максимальный размер буфера.

2. Далее будет выполнен системный вызов, который в ядре перенаправляется сетевому стеку.

3. Поскольку данных нет, вызов будет заблокирован в ожидании.

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

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

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

7. Сетевому стеку будет отправлен сигнал.

8. По типу протокола стек увидит, что это IP-пакет, сверит адрес, идентификатор фрагмента и контрольную сумму. Если адрес и содержимое остальных полей корректны, продолжит обработку.

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

10. Приложение из библиотечной функции получит данные и размер принятых данных.

В реальности обработка гораздо сложнее.

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

Во-вторых, существуют разные типы сетевых узлов. Например, роутеры, которые выполняют маршрутизацию и переадресацию.

В-третьих, сетевые адаптеры бывают разных видов: работа с Wi-Fi-адаптером отличается от работы с Ethernet. В общем случае не все особенности низких уровней полностью скрыты на более высоких.

Между разными уровнями стека лежат слои абстракции: например, между драйвером и прочими модулями стека есть уровень независимого от устройств интерфейса. В Linux это NAPI, хотя часть драйверов может работать в обход него.

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

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

Пример сетевой подсистемы: Windows

В ОС Windows прикладной разработчик обычно вызывает функции WinSock API — интерфейса библиотеки сокетов. Пример организации стека в ОС Windows показан на рис. 3.2.

Функции send() и recv() в ОС Windows вызывают функции WSASend() и WSARecv() соответственно.

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

Сокетный провайдер для обращения к ядру и работы с сетевым стеком на уровне ядра использует разные интерфейсы. Ранее в ОС Windows это был TDI — Transport Driver Interface, и для обмена данными между драйвером и приложением использовался IRP — I/O Request Packet. На данный момент TDI устарел. Теперь он называется NetIO Legacy TDI и поддерживается драйвером TDX.

Рис. 3.2. Пример организации стека в ОС Windows

Верхнеуровневые библиотеки могут работать с ядром через интерфейс ядра WinSock Kernel Network Programming Interface — WSK NPI, или просто WSK.

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

WSK NPI неверно называть «WinSock уровня ядра». Это новый интерфейс с асинхронным вводом-выводом, использующий функции IRP и обратные вызовы событий для повышения производительности.

WSK NPI поддерживает операции сокета, такие как создание, привязка адреса, подключение и обмен данными.

Драйвер AFD — Ancillary Function Driver for WinSock — также обеспечивает функциональность сокетов для приложений, использующих протоколы TCP/IP. AFD отвечает за соединения и управление буфером. Он работает с драйверами протоколов низкого уровня. Они реализуют интерфейс NDIS — Network Driver Interface Specification. Когда данные принимаются, они сначала копируются в буфер AFD. Затем, когда приложение вызывает WSARecv(), они копируются провайдером из буфера драйвера AFD в буфер приложения.

Обмен данными контролирует WFP — платформа фильтрации.

Более подробная схема поддержки сети в ОС Windows показана на рис. 3.3. На этой схеме мы видим, что приложение может как напрямую использовать сокеты через WinSock, так и работать с API высокого уровня, предоставляемыми разными библиотеками: WinHTTP, WinInet, библиотеками для работы с инфраструктурой P2P и т.п. Все эти библиотеки реализованы поверх сокетов. В свою очередь, сокеты — это только API, вызывающий функции сервисного интерфейса — SPI, реализаций которого на прикладном уровне может быть несколько.

Рис. 3.3. Взаимодействие компонентов стека ОС Windows

Предоставляют эту реализацию провайдеры двух типов:

Провайдеры имен — отвечают за разрешение имен, преобразование между именами и адресами.

Провайдеры транспорта — непосредственно выполняют обмен данными.

Раньше были доступны цепочки провайдеров — многоуровневые провайдеры, или Layered Services Providers. Они позволяли, например, подключать фильтры содержимого, перед тем как данные будут обработаны высокоуровневыми функциями и приложением. Этим активно пользовались антивирусы. Но с появлением WFP данная функциональность устарела.

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

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

Провайдеры взаимодействуют с сетевым стеком ядра. Они делают запросы к драйверу AFD.sys. Этот драйвер вызывает функции реализации сетевого стека для используемого протокола.

Стеки протоколов реализованы в отдельных драйверах. Например, стек TCP/IP реализован в драйвере TCPIP.sys.

DNS, как ни странно, реализован в драйвере HTTP.sys.

WFP напоминает BPF и iptables из Linux. Именно поверх WFP работает брандмауэр Windows.

NDIS абстрагирует сетевое оборудование от сетевых драйверов более высокого уровня. Он задает стандартный интерфейс между сетевым драйвером в стеке, вызывая абстрактные драйверы, которые управляют оборудованием. Также он хранит настройки и сведения о состоянии сетевых драйверов: указатели на функции, дескрипторы, блоки параметров и т.д.

Самым нижним уровнем являются драйверы конкретных сетевых устройств, которые на схеме не показаны.

Подробнее работу с сетью в ОС Windows мы рассмотрим в главах 14–20.

Пример сетевой подсистемы: QNX

Рассмотрим, как организована сетевая подсистема в ОС QNX. В ее основе лежит многопоточный редиректор и мультиплексор пакетов io-pkt, а сетевая подсистема включает его и набор подключаемых библиотек, которые реализуют драйверы устройств и протоколов.

QNX — микроядерная операционная система реального времени, предназначенная в основном для встраиваемых систем. Она POSIX-совместима и является одной из наиболее распространенных микроядерных ОС.

Ядро в таких операционных системах реализует только минимальный набор функций.

Схема организации сетевой подсистемы представлена на рис. 3.4.

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

Сетевые протоколы, например TCP или Qnet, реализованы в отдельных моду­лях. Драйверы — тоже модули, которые можно загружать и выгружать динами­чески.

Рис. 3.4. Схема сетевой подсистемы QNX

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

Сетевая подсистема QNX позволяет иметь несколько объектов протокола. Например, загружать несколько экземпляров TCP/IP-стека, которые работают через один и тот же физический интерфейс. Это дает возможность создавать виртуальные сети и легко поддерживать независимые пространства имен.

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

На рисунке 3.4 видно, что сетевые приложения работают поверх библиотеки сокетов libsocket. Библиотека сокетов преобразует интерфейс передачи сообщений в стандартный POSIX API. Возможна и работа напрямую через биб­лиотеку, реализующую определенный протокол в пространстве пользователя.

Менеджер ресурсов — точка входа для взаимодействия с сетевым стеком ядра. Приложения общаются с менеджером ресурсов посредством обмена сообщениями.

Рис. 3.5. Модули сетевой подсистемы QNX

Организация сетевой подсистемы в QNX не вполне типична для большинства ОС. Данная система модульная и предполагает, что сеть, как «процесс ядра», может быть выгружена или перезапущена.

Модули сетевой подсистемы QNX показаны на рис. 3.5. Помимо указанных компонентов, в сетевом стеке, так же как и во многих Unix-подобных системах, есть пакетный фильтр BPF, который может запускать пользовательские обработчики.

В Linux и Windows реализованы более классические варианты.

Пример сетевой подсистемы: Linux

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

Упрощенный пример ее показан на рис. 3.6. Как и в любой другой ОС, пользователю система предоставляет сокетный интерфейс — функции API в LibC. На рисунке показаны несколько функций для создания соединения, его завершения и обмена данными. Подробнее мы их рассмотрим далее.

Рис. 3.6. Сетевая подсистема Linux

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

Прямые ioctl-вызовы к драйверам, которые служат для настройки, адресуются непосредственно драйверу. На рисунке это не показано, так как это общий механизм для работы с драйверами.

Вызовы, которые используют разные протоколы, адресуются сетевому стеку. Начинается все с создания сокета и структур в ядре, которые с ним связаны. Об этом мы подробнее поговорим в главе 7.

Для протоколов, ориентированных на соединение, например TCP, при создании подключения в ядре формируются структуры, которые хранят параметры этого подключения: номер последовательности TCP, номера портов, адреса пиров — удаленных узлов и т.п.

Передаваемые данные попадают в модули протоколов, которые формируют готовый PDU нижнего уровня.

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

Этот блок называется SKB и представляет собой экземпляр структуры sk_buff, которая содержит указатель на буфер с данными, а также указатели на предыдущий и следующий экземпляр, что позволяет формировать список блоков.

После этого блок передается «модулю» IP, который формирует заголовок IP, а также ищет для пакета маршрут.

IP-пакет отправляется в «подсистему соседей», где для отправки выбирается аппаратный адрес — MAC получателя. Он может либо находиться в кэше, либо быть получен через ARP — протокол, который позволяет узнать адреса узлов в одном коллизионном домене, — или через IPv6, который имеет для этого встроенные механизмы. ARP мы подробнее рассмотрим в главе 10.

На рисунке вышесказанное представлено в виде упрощенной схемы стека протоколов.

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

Далее отправка трафика планируется исходя из приоритета и необходимости уменьшения задержек.

Некоторые устройства поддерживают аппаратное управление трафиком, в этом случае управление трафиком может выполняться прямо на сетевом оборудовании.

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

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

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

Современное оборудование, как правило, напрямую пишет данные в основную память машины, используя DMA — Direct Memory Access.

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

Дальнейшие действия показаны на рисунке упрощенно. Обработчик прерывания ставит в очередь планировщика задачу и завершается. Выполняемая планировщиком задача копирует данные из кольцевого буфера в одну из очередей, каждую из которых обрабатывает одно из вычислительных ядер процессора. Отсюда данные передаются стеку, и он по цепочке передает их соответствующим модулям протокола, например IP, затем TCP или UDP, убирая заголовок на каждом из уровней. Здесь же выполняется маршрутизация, применяются фильтры nftables и проверяется соответствие адресов в модулях протоколов, ориентированных на соединение.

Модуль транспортного протокола распределяет данные по сокетам и копирует «чистые данные» пользователя в буфер сокета. Затем, если системный вызов был выполнен как блокирующий, он разблокируется, и приложение может работать дальше: передавать данные или работать с полученными.

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

Сходства и различия сетевого API на разных платформах

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

Разница между BSD- и POSIX-сокетами

Как уже было сказано, изначально сокеты были реализованы в Университете Беркли в системах BSD — Berkeley Software Distribution. Впервые они появились в 4.2BSD. До сих пор в разных версиях BSD-систем используются сокеты Беркли.

Позднее комитет 1003 IEEE с целью облегчения переноса кода программ, унификации интерфейсов и ускорения создания документов включил сокетный API в набор стандартов, описывающих интерфейсы между ОС и программой, или POSIX — Portable Operating System Interface. Как уже говорилось в главе 1, последняя на момент написания книги редакция — .

Сокеты стали частью стандарта IEEE 1003.1g System Application Program Interface.

Отличия между стандартизованными POSIX-сокетами и сокетами Berkley есть, но не очень значительные:

inet_ntop() в POSIX — функция преобразования IP-адреса в строку. В BSD называется inet_ntoa().

• inet_pton() в POSIX — функция преобразования строки в IP-адрес. В BSD называется inet_aton().

• Функция getaddrinfo() в POSIX заменяет устаревшие функции gethostbyname(), gethostbyaddr(), getservbyname(), которые до сих пор актуальны в BSD.

• Функция getnameinfo() в POSIX заменяет устаревшие функции gethostbyaddr(), getservbyport().

Отличия в разных ОС

В Microsoft Windows существует своя реализация, которая называется Windows Socket 2 API, или WSA.

Данный API предоставляет:

API для приложений;

SPI для расширения сокетной библиотеки. Например, он позволяет добавить работу с новым протоколом.

Особенность WSA — неполная совместимость API с POSIX-стандартом, а также отдельный набор полностью несовместимых функций. За исключением некоторых ситуаций, лучше использовать POSIX API, чтобы обеспечить переносимость между разными ОС.

Стандартные POSIX-функции часто выполнены поверх вызовов, специфичных для Windows. Мы еще поговорим о WinSock 2 в главе 14 и далее.

В некоторых Unix-подобных ОС также есть фреймворк STREAMS, который реализует обмен сообщениями между процессами, в том числе по сети, через вызовы getmsg() и putmsg(). Этот фреймворк использовали и в ранних версиях Windows NT.

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

ОС QNX, даже последние версии QNX Neutrino, до сих пор поддерживают сеть QNet, отдаленно напоминающую NetBIOS из Windows. QNet реализована без использования TCP/IP и сокетного интерфейса. Работать с этой сетью можно через файловую систему.

Дополняет ее сеть GNS — Global Name Service, — реестр служб, которые позволяют обращаться к ним независимо от того, где они работают. Здесь можно провести некоторую параллель с WINS и DNS.

QNet ориентирована на локальную сеть доверенных машин, обязательно с одинаковым порядком байтов.

Все упомянутые ОС поддерживают TCP/IP-стек и сокетный API. Мы изучим API, но постепенно будем от него отходить в сторону использования библиотек высокого уровня, которые скрывают большинство деталей взаимодействия с конкретной платформой.

Сокетный API в разных языках

Мы уже видели примеры сокетного API Python, хотя и не углублялись в детали его реализации.

Сейчас попробуем разобраться, как реализованы вызовы сокетного API в других языках, отличающихся от C и C++, на примере языка Go.

В Go функция socket() реализована в модуле syscall по-разному для разных ОС. Чаще всего через вызов socket().

Например, реализация для Unix выглядит так:

func Socket(domain, typ, proto int) (fd int, err error) {

    if domain == AF_INET6 && SocketDisableIPv6 {

        return -1, EAFNOSUPPORT

    }

    fd, err = socket(domain, typ, proto)

    return

}

А так — реализация для Windows:

func Socket(domain, typ, proto int) (fd Handle, err error) {

    if domain == AF_INET6 && SocketDisableIPv6 {

        return InvalidHandle, EAFNOSUPPORT

    }

    return socket(int32(domain), int32(typ), int32(proto))

}

Видно, что отличия незначительны.

В Linux функция socket() вызывается из стандартной библиотеки C.

В большинстве дистрибутивов Linux стандартной библиотекой является GLibC. Но в некоторых случаях могут использоваться другие реализации, например uCLibC для встраиваемых устройств.

В Windows эта функция вызывается как ws2_32.socket() из библиотеки ws2_32. Вызов реализован в windows/zsyscall_windows.go и выглядит так:

modws2_32 = NewLazySystemDLL("ws2_32.dll")

...

procsocket = modws2_32.NewProc("socket")

...

 

func socket(af int32, typ int32, protocol int32) (handle Handle, err error) {

    r0, _, e1 := syscall.Syscall(procsocket.Addr(), 3, uintptr(af),

                                 uintptr(typ), uintptr(protocol))

    handle = Handle(r0)

    if handle == InvalidHandle {

        err = errnoErr(e1)

    }

    return

}

Отсюда видно, что системная библиотека языка просто загружает необходимые динамические библиотеки, используя механизмы ОС, которые реализованы в самом языке. Функция Syscall(), например, реализована в библиотеке Go на ассемблере.

Существует еще более низкоуровневая функция RawSyscall().

Разница между двумя функциями в том, что Syscall уведомляет среду выполнения, что она переключилась на блокирующий системный вызов, а RawSyscall() просто блокируется, не уведомляя среду.

Над загруженными функциями есть несколько оберток, каждая из которых выполняет проверки, постепенно приближая интерфейс C функции к интерфейсу, привычному для разработчика на Go. В частности, эти вызовы использует стандартная библиотека Net в Go. Код мы здесь приводить не будем, так как он достаточно сложный и включает на сокетах неблокирующий режим, который мы рассмотрим только в книге 2. Желающие могут найти его самостоятельно.

Посмотрим, как создать сокет на Go:

package main

 

import (

    "fmt"

    "syscall"

)

 

func main() {

    if s, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM,

                                syscall.IPPROTO_TCP); err != nil {

        fmt.Printf("Error message: %s\n", err)

    } else {

        fmt.Printf("Descriptor: %d\n", s)

    }

}

Результат:

src/book01/ch02/go go run main.go

Descriptor: 3

Пакет Net стандартной библиотеки предоставляет удобные «методы», например Dial() типов net.Dialer или net.Conn, и функции, например Listen(), которые включают сразу несколько вызовов сокетного API.

В языке Rust подход аналогичный: стандартная библиотека std::net, в которой реализованы высокоуровневые примитивы, например TcpListener, TcpStream или UdpSocket. Но «под капотом» лежит сокетный API.

Может возникнуть вопрос: имеет ли смысл использовать низкоуровневый сокетный API в Go и схожих языках, если детали скрыты внутри сетевых биб­лиотек?

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

Например, установка опций выполняется в функции-обработчике, но через прямой вызов сокетного API:

func main() {

    dialer := &net.Dialer{

        // Обработчик, вызываемый после создания сокета.

        Control: func(network, address string, conn syscall.RawConn) error {

            var operr error

            if err := conn.Control(func(socket_descriptor uintptr) {

                // Вызов сокетного API.

                operr = syscall.SetsockoptInt(int(socket_descriptor),

                                              syscall.SOL_SOCKET,

                                              syscall.TCP_QUICKACK, 1)

            }); err != nil {

                return err

            }

            return operr

        },

    };

 

    // Вызов Dialer.Dial().

    dialer.Dial("tcp", "google.com:443");

}

В конечном итоге опция TCP_QUICKACK устанавливается через вызов C функции setsockopt(). Чтобы вести разработку эффективно, нужно понимать, что этот код делает и для чего.

Установку опций мы рассмотрим в главе 8.

Общий процесс работы с сокетами

Все сокеты принимают и отправляют данные. Они работают согласно протоколу — описанию правил взаимодействия. Поэтому тип сокета полностью определяется стеком протоколов, лежащим под ним. «Сетевой» уровень OSI задает формат адреса, даже если сеть не используется.

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

Тип передачи

Тип данных

Пример сокета и протокола для семейства AF_INET

Соединение

Поток

SOCK_STREAM, TCP

Соединение

Сообщения

SOCK_SEQPACKET, SCTP

Передача без соединения

Сообщения

SOCK_DGRAM, UDP

Уровни выше транспортного в основном не представлены сокетным API: эти протоколы реализуются поверх него в приложениях.

Такие протоколы, как UDP, ориентированы на обмен сообщениями. UDP не обеспечивает надежной или упорядоченной доставки сообщений.

PDU UDP называется дейтаграмма.

Некоторые сокеты обеспечивают надежную передачу сообщений, но без соединения, что означает произвольный порядок поступления сообщений. Это, например, сокеты домена AF_RDS, дающие прямой доступ к памяти другого компьютера поверх таких сетей, как InfiniBand, — тип сетей и тип сокетов, который используется в кластерах.

В стеке TCP/IP существует протокол RDP или RUDP — Reliable UDP, который также обеспечивает надежную передачу дейтаграмм.

Протокол SCTP тоже ориентирован на сообщения, но он предлагает сервис надежной и упорядоченной доставки сообщений, то есть обеспечивает соединение.

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

Рассмотрим, как выполняется работа с данными типами протоколов.

Клиент

Алгоритм работы клиента очень прост и представлен на рис. 3.7:

1. Создать сокет вызовом функции socket().

2. Опционально вызвать функцию bind().

3. Опционально вызвать функцию connect().

4. В цикле:

4.1. Отправить данные серверу, используя send() или sendto().

4.2. Прочитать ответ сервера, используя recv() или recvfrom().

5. Закрыть сокет с помощью close() или closesocket().

Рис. 3.7. Алгоритм работы клиента

Независимо от того, ориентирован клиент на соединение или нет, можно использовать функцию connect():

• Если сокет ориентирован на сообщения, эта функция не обязательна, но она сохраняет адрес сервера в структуре адреса сокета. Это дает возможность использовать функции send()/recv() или read()/write(), которые не требуют указания адреса.

• В ином случае, например для протокола TCP, функция connect() обязательна, так как инициирует последовательность установления соединения протоколом.

Внимание! Когда используется функция connect(), в случае не ориентированного на соединение сокета сохраняется адрес сервера. Но эта информация не определяется протоколом. Например, в UDP такой поддержки нет. Поэтому в данном случае неверно говорить об установке соединения.

Ранее мы использовали функцию bind(), которая также привязывает адрес к сокету. Ее отличие от connect() в том, что последняя кроме инициирования соединения устанавливает адрес удаленной стороны, а bind() — локальный адрес сокета.

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

Зачем задавать клиентский порт вручную? Например, если межсетевой экран разрешает ответ на соединения только с заданным диапазоном портов. Такие конфигурации редки, но могут существовать.

Сервер

Алгоритм сервера, показанный на рис. 3.8, несколько сложнее, чем клиента:

1. Создать сокет вызовом функции socket().

2. Привязать сокет к прослушиваемому порту, а также к интерфейсу вызовом функции bind().

3. Для сокетов, ориентированных на соединение:

3.1. Создать очередь для ожидания подключений, вызвав listen().

3.2. Принять новое подключение через вызов accept(). Функция вернет сокет для работы с новым клиентом. Сокет, который был создан до этого, будет и дальше ожидать подключения.

4. В цикле ожидать данные от клиента, например, принимая их через recv() или recvfrom(). После получения данных функция также вернет адрес отправившего их клиента.

5. Отправить клиенту ответ через send() или sendto().

6. Закрыть сокет, используя close() или closesocket().

Рис. 3.8. Алгоритм работы сервера

Детали API сервера мы рассмотрим в главе 5 и некоторых других главах.

В случае протоколов, не ориентированных на соединение, например UDP, вызовы listen() или accept() приведут к ошибке типа «Операция не поддерживается».

Такие «серверы» должны ожидать поступления данных от любого источника.

В случае протоколов, ориентированных на соединение, функция listen() создает очередь запросов на подключение к сокету и запускает последовательность ожидания и приема.

Функция accept() принимает входящее подключение, создает и возвращает новый сокет. Этот сокет содержит состояние соединения и является оконечной точкой канала. Он позволяет работать с клиентом, например, в отдельном потоке, оставив сокет, на котором был вызван listen(), принимать новые подключения. Это поведение лежит в основе работы многопоточных и асинхронных серверов.

Означает ли это, что UDP-сокеты не предназначены для многопоточной обработки? В общем случае — да. Таков сокетный API.

Но в частном случае, например в Linux, существуют определенные механизмы, которые даже на UDP-сокете позволяют распараллелить обработку запросов на разные потоки. О них будет рассказано в книге 2.

Когда сервер, ориентированный на соединение, завершает работу с клиентом, он должен закрыть сокет, который вернул accept(), чтобы не допустить утечки ресурсов, в особенности номеров дескрипторов.

Сокеты домена Unix

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

В качестве адресного пространства данные сокеты используют файловую систему, а семейство адресов задается константой AF_UNIX или эквивалентным POSIX-вариантом AF_LOCAL.

Unix-сокеты могут быть использованы для управления различными демонами. При этом часто они создаются в каталоге /run.

Например, сокет для управления Docker — /run/docker.sock. Некоторые контейнеры, содержащие управляющие интерфейсы, например Portainer, монтируют этот файл, чтобы взаимодействовать с демоном.

Запустить контейнер можно так:

sudo curl -XPOST --unix-socket /run/docker.sock

http://localhost/containers/12345/start

{"message":"No such containr: 12345"}

Файл сокета для управления Udev имеет путь /run/udev/control. Его используют systemd и udevadm для передачи команд демону.

Сокет для управления MariaDB по умолчанию находится по адресу /run/mysqld/mysqld.sock. В данном случае Unix-сокет — один из вариантов каналов для взаимодействия СУБД и управляющих интерфейсов.

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

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

Данный тип сокетов работает только в рамках локальной машины.

Это утверждение необходимо понимать буквально.

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

Данные сокеты для этого не предназначены, для организации подобной связи потребуется использовать стороннее ПО. Например, пакет OpenSSH:

ssh \

    -R/var/run/mysql.sock:/var/run/mysql.sock \

    -R127.0.0.1:3306:/var/run/mysql.sock \

    user@server

Или утилиту socat:

socat "UNIX-LISTEN:./mysqld.sock,reuseaddr,fork" \

EXEC:'ssh user@server socat STDIO UNIX-CONNECT\:/var/run/mysqld/mysqld.sock'

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

В свою очередь, удаленный процесс будет записывать принятые данные в существующий Unix-domain сокет на машине, где он выполняется, и отправлять по TCP прочитанные из него данные.

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

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

Таким образом, сокеты домена Unix обеспечивают высокую производительность.

Название Unix-domain стоит понимать как «локальные сокеты», а не «сокеты, которые есть только в Unix».

В версиях ОС Windows Insider Build 17063, то есть с 2017 года, имеется ограниченная поддержка таких сокетов. Поддерживаются типы SOCK_DGRAM и SOCK_SEQPACKET.

С данным типом сокетов часто используют функцию socketpair(), которая может создавать любые сокеты из разных доменов, но полезнее она для сокетов Unix-domain:

#include <sys/socket.h>

 

int socketpair(int domain, int type, int protocol, int socket_vector[2]);

Параметры функции socketpair():

domain, type, protocol — такие же, как для функции socket().

socket_vector — массив для сохранения возвращенных дескрипторов.

Функция создаст пару связанных дескрипторов анонимных сокетов и вернет 0 в случае успеха и –1 в случае неудачи.

Сокеты домена Unix могут быть следующих типов:

SOCK_STREAM — через этот сокет проходит поток данных. Его преимущества — надежная доставка и сохранение порядка данных. Но в нем отсутствуют границы между «сообщениями», то есть вызовами отправки данных. Это поток.

• SOCK_DGRAM — обмен дейтаграммами без соединения, сохраняющими границы сообщений. Надежность обмена не гарантируется, как и сохранение порядка сообщений. Но на практике сокеты домена Unix всегда сохраняют порядок сообщений, а потеряться сообщения не могут.

SOCK_SEQPACKET — надежный обмен сообщениями, гарантированная доставка, сохранение порядка, сохранение границ. Для сокетов домена Unix на практике не отличается от SOCK_DGRAM.

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

1. Вызвать socketpair() — создать два файловых дескриптора сокета, которые будут являться двумя концами одного канала. Один произвольный конец родительский, второй — дочерний.

2. Создать еще один процесс. Вызвать fork(), CreateProcess() или подобную функцию. Можно использовать сокеты и для обмена между потоками, но в этом случае более оптимальна общая память.

3. В потомке:

3.1. Закрыть дескриптор родительского файла.

3.2. Сохранить дочерний дескриптор, чтобы использовать для обмена с предком.

4. В текущем процессе, который является предком:

4.1. Закрыть дескриптор дочернего сокета.

4.2. Сохранить родительский дескриптор, чтобы использовать его для обмена с потомком.

Теперь если один процесс записывает данные в свой сокет, другой сможет прочитать эти данные из своего сокета.

Рассмотрим, как использовать данную функцию на C++ в Linux.

Сначала включим заголовочные файлы и определим константу с размером буфера:

extern "C"

{

#include <sys/socket.h>

 

// Для read() и write().

#include <unistd.h>

}

 

...

 

constexpr size_t buf_size = 1024;

Реализуем функцию для потомка, которую будет выполнять новый процесс, созданный fork(). В ней мы читаем данные от предка и отправляем ему ответ:

void child(int socket)

{

    //

    // Эта функция выполняется потомком.

    //

 

    std::string buf(buf_size, 0);

    // Чтение из сокета в Unix-подобных системах может выполняться

    // через read().

    // Сокет здесь представлен обычным файловым дескриптором.

    const ssize_t res = read(socket, &buf[0], buf.size());

 

    if (res < 0)

    {

        // Обработать ошибки чтения.

        perror("child read");

        return;

    }

 

    std::cout

        << "Child received: '"

        << buf << "' (" << res << " bytes)"

        << std::endl;

 

    const std::string msg("Hello parent, I am child");

 

    // Запись также может быть выполнена через write().

    if (write(socket, msg.c_str(), msg.size()) < 0)

    {

        // Обработать ошибки записи.

    }

    close(socket);

}

Код предка вынесем также в отдельную функцию. Обратите внимание, что предок сначала пишет, затем читает, тогда как потомок — наоборот:

void parent(int socket)

{

    //

    // Эта функция выполняется предком.

    //

 

    std::string buf = "Hello, I'm your parent";

 

    if (write(socket, buf.c_str(), buf.size()) < 0)

    {

        // Обработать ошибки записи/отправки данных в сокет.

        perror("parent write");

        return;

    }

 

    // Снова прочитать данные.

    ssize_t res = read(socket, &buf[0], buf.size());

 

    if (res < 0)

    {

        // Обработать ошибки чтения.

        perror("parent read");

        return;

    }

 

    buf.resize(res);

 

    std::cout

        << "Parent received: '"

        << buf << "' (" << res << " bytes)"

        << std::endl;

 

    close(socket);

}

Внимание! Переменная, содержащая результат вызова read() и write(), должна иметь знаковый тип ssize_t, который используется, чтобы можно было сохранить отрицательный результат, возвращаемый функциями при ошибке.

В функции main() создадим массив дескрипторов, вызовем socketpair() и fork(). Процессам для взаимодействия будет передан массив дескрипторов.

int main()

{

    // Массив дескрипторов.

    int fd[2];

    constexpr int parent_socket = 0;

    constexpr int child_socket = 1;

 

    // Вызвать socketpair(): PF_LOCAL — то же, что и PF_UNIX.

    if (-1 == socketpair(PF_LOCAL, SOCK_DGRAM, 0, fd))

    {

        perror("socketpair");

        return EXIT_FAILURE;

    }

 

    // Создать новый процесс.

    const pid_t pid = fork();

 

    if (0 == pid)

    {

        // Потомок. Родительский дескриптор больше не потребуется.

        // Нужно его закрыть.

        close(fd[parent_socket]);

        child(fd[child_socket]);

    }

    else if (-1 != pid)

    {

        // Родитель. Дескриптор потомка больше не потребуется.

        // Нужно его закрыть.

        close(fd[child_socket]);

        parent(fd[parent_socket]);

    }

    else

    {

        // Ошибка создания процесса.

        perror("fork");

    }

 

    return EXIT_SUCCESS;

}

Скомпилируем и запустим пример:

build/bin/b01-ch03-socket-pair-test

Child received: 'Hello, I'm your parent'

Parent received: 'Hello parent, I am child'

Функция socket.socketpair() существует и в Python. Она имеет ту же сигнатуру, что и C-функция socket(), но возвращает кортеж из двух объектов класса socket.socket.

Интересно, что в библиотеке socket она может быть реализована как вызов из C-библиотеки в ОС, где данная функция существует. Но если в какой-то системе такого вызова нет, включается реализация на чистом Python через стек TCP/IP.

В итоге на Python эта функция работает везде. Чуть позже мы рассмотрим ее реализацию на Python.

Теперь попробуем «удлинить провод», которым связаны процессы. Использовать socketpair() и анонимные сокеты здесь уже не получится. Чтобы связать разные процессы, к сокету надо привязать адрес. Для сокетов домена Unix адрес — это имя файла, и он будет описываться такой структурой:

#include <sys/un.h>

 

struct sockaddr_un

{

    // AF_UNIX.

    unsigned short sun_family;

    // Адрес сокета.

    char sun_path[108];

};

В зависимости от содержимого атрибута sun_path имеются следующие типы адресов:

Привязанные к файловой системе, или сокеты пути. Атрибут содержит путь в файловой системе, заканчивающийся нулем. Размер адреса, передаваемый в bind(), равен sizeof(sockaddr_un).

• Абстрактные. Первый символ, то есть sun_path[0], равен нулю — '\0'. Имя не связано с путем в файловой системе. Адрес сокета в этом пространстве имен задан байтами в sun_path, начиная с sun_path[1] и до переданного в bind() размера структуры минус sizeof(sun_family).

Безымянные. Потоковый сокет, который не имеет имени. Либо оно не было привязано через bind(), либо это пара сокетов, возвращенная функцией socketpair().

Абстрактные сокеты — это нововведение Linux. Они нужны, когда Unix-сокет используется только как примитив для межпроцессного обмена и не предполагается взаимодействовать с приложением через утилиты файловой системы.

Имена таким сокетам даются, как правило, как если бы сокет находился в файловой системе, например: «\0/com/ubuntu/upstart». Проблемой в этом случае будет возможная уязвимость приложения. Данный тип сокетов не ограничен правами, поэтому к ним может подключиться кто угодно. И кто угодно может отправить в такой сокет произвольные данные.

Стоит использовать getsockopt() с SO_PEERCRED, чтобы получить как минимум UID подключающегося клиента и отсечь пользователей, которые не запускали сервер.

Внимание! Путь сокетов, привязанных к ФС, всегда должен заканчиваться нулем. Хотя некоторые реализации Unix-сокетов добавляют завершающий ноль, другие могут вернуть ошибку. Кроме того, в некоторых реализациях sun_path имеет длину всего 92 байта.

Посмотрим, как будет выглядеть бывший процесс «родитель»:

// Имя файла для сокета.

constexpr char SOCK_PATH[]{"_socket_path"};

 

int main()

{

    // Адрес сокета.

    sockaddr_un sock_address;

 

    // PF_UNIX — он же PF_LOCAL.

    int sock = socket(PF_UNIX, SOCK_DGRAM, 0);

 

    if (-1 == sock)

    {

        perror("socket");

        return EXIT_FAILURE;

    }

    // Необходимо заполнить структуру "адреса".

    sock_address.sun_family = AF_UNIX;

 

    assert(sizeof(SOCK_PATH) < sizeof(sock_address.sun_path));

    std::copy(SOCK_PATH.begin(), SOCK_PATH.end(), sock_address.sun_path);

 

    if (std::filesystem::exists(SOCK_PATH) && std::remove(SOCK_PATH) != 0)

    {

        perror("remove");

        return EXIT_FAILURE;

    }

 

    // После вызова bind() будет создан файл сокета.

    if (-1 == bind(sock, reinterpret_cast<const sockaddr *>(&sock_address),

                   sizeof(sock_address)))

    {

        // Выполнение действий при ошибке связывания адреса.

        perror("bind");

        return EXIT_FAILURE;

    }

Чтение данных также реализуем в цикле:

    std::string buf(1024, 0);

 

    while (true)

    {

        // Read ждет новых данных и записывает их в буфер.

        ssize_t bytes_read = read(sock, &buf[0], buf.size());

 

        if (bytes_read < 0)

        {

            perror("server read");

            return EXIT_FAILURE;

        }

 

        std::cout

            << "Buffer: '"

            << buf << "'"

            << std::endl;

    }

 

    return EXIT_SUCCESS;

}

После вызова данной программы будет создан файл сокета:

ls -l _socket_path

srwxr-xr-x 1 artiom artiom 0 мая  5 20:35 _socket_path

Видим, что его тип — 's', то есть socket. Записывать что-либо в этот файл, например, через echo, не имеет смысла: вывод завершится с ошибкой «Нет такого устройства или адреса». Что логично: это сокет, а не канал.

Взаимодействовать с приложением можно, используя BSD Netcat:

ncat -uU _socket_path

test

^C

Ключ -U говорит о том, что работа производится с Unix-domain-сокетом, а ключ -u — о том, что сокет ориентирован на сообщения.

Процесс, который читает сокет, выведет:

build/bin/b01-ch03-unix-socket-server

Buffer: 'test

'

Перевод строки также будет передан из Netcat и отображен в принятом сообщении.

Теперь реализуем программу, которая пишет в сокет так же, как это делает Netcat или дочерний процесс выше:

constexpr char SOCK_PATH[]{"_socket_path"};

 

int main()

{

    struct sockaddr_un sock_address;

 

    int sock = socket(AF_UNIX, SOCK_DGRAM, 0);

 

    if (-1 == sock)

    {

        perror("socket");

        return EXIT_FAILURE;

    }

 

    // Заполнение структуры адреса.

    sock_address.sun_family = AF_UNIX;

    std::copy(SOCK_PATH.begin(), SOCK_PATH.end(), sock_address.sun_path);

 

    const std::string msg{ "Hello, process!" };

 

    // В sendto() адрес передается явно.

    if (-1 == sendto(sock, msg.c_str(), msg.size(), 0,

                     reinterpret_cast<const sockaddr*>(&sock_address),

                     sizeof(sock_address)))

    {

        perror("send");

        return EXIT_FAILURE;

    }

 

    close(sock);

    return EXIT_SUCCESS;

}

Запустим код сервера, а потом отдельно запустим клиент. Вывод сервера будет следующим:

{build/bin/b01-ch03-unix-socket-server & } && \

   build/bin/b01-ch03-unix-socket-client

[3] 1336230

Buffer: 'Hello, process!'

Сначала запускается процесс сервера, который выполняется в фоне. Затем начинает выполняться клиент. Числа в начале — это номер фоновой задачи и PID сервера. Их выводит оболочка.

Далее сервер печатает то, что ему отправил клиент по сокету.

Попробуем отправить данные из кода на Python:

import socket

 

s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM, 0)

s.sendto('This is Python!'.encode(), '_socket_path')

Результат предсказуем:

...

 

Buffer: 'Hello, process!'

Buffer: 'This is Python!'

Внимание! Сервер продолжит работать в фоне. Чтобы завершить его, выполните команду kill <PID>, где <PID> — выведенное оболочкой число. В данном примере: kill 1336230.

Видно, что мы использовали функцию sendto(). Если процесс-писатель будет запущен первым и сокет еще не создан, он завершится с ошибкой. Вызов bind() на стороне клиента мог бы исправить данную проблему: он создаст файл сокета.

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

В случае Unix-domain-сокетов возможно реализовать несколько разных типов взаимодействия. Но чаще всего они используются так же, как и прочие типы сокетов.

Есть сервер, предоставляющий услугу, и клиент, услугу запрашивающий. При использовании функции socketpair() процесс, обслуживающий сокет, запущен априори. Но когда образуется более «длинный» канал, процессы разделяют функции между собой.

За создание точки подключения, в данном случае файла сокета, также отвечает сервер, а не клиент. И сервер, при выходе, должен удалить файл сокета, что в примерах не реализовано.

Далее мы будем рассматривать другие типы сокетов, но большая часть того, что будет рассказано о них, применимо и к сокетам домена Unix.

Дейтаграммные сокеты INET-доменов

Рассмотрим сокеты, используемые для обмена данными по сети, доменов AF_INET и AF_INET6 типа SOCK_DGRAM.

Чаще всего для работы с данными сокетами используется UDP, описанный в RFC 768 «User Datagram Protocol».

Вторым протоколом является RDP — Reliable Datagram Protocol — или RUDP — Reliable User Datagram Protocol, определенный в RFC 908 «Reliable Data Protocol» и RFC 1151 «Version 2 of the Reliable Data Protocol (RDP)».

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

Внимание! Существует еще один протокол RDP — Remote Desktop Protocol. Он предназначен для удаленного администрирования узлов через графический интерфейс. Это проприетарный протокол прикладного уровня и к Reliable Datagram Protocol не имеет никакого отношения.

UDP

Хотя UDP не гарантирует надежной передачи данных и не определяет порядок их получения, он может отправлять данные в несколько пунктов назначения и получать их из нескольких источников.

Если клиент отправляет данные на сервер, они передаются независимо от того, включен ли сервер и готов ли он их принять.

Если сервер получает данные от клиента, он не подтверждает их получение.

Данные передаются с помощью дейтаграмм, которые представляют собой дискретные пакеты сообщений.

Протокол крайне прост, что видно из структуры дейтаграмм, изображенной на рис. 3.9.

Рис. 3.9. Структура UDP дейтаграммы

В дейтаграмме есть такие поля, как порты отправителя и получателя, длина и контрольная сумма данных. Рассмотрим их.

Контрольная сумма

Поле характерно для многих протоколов. Когда дейтаграмма UDP отправляется поверх IPv4, поле контрольной суммы может быть заполнено нулями, что означает, что контрольная сумма не будет рассчитана. Для ее расчета поле контрольной суммы сначала принимается равным 0, а затем производится расчет всех остальных данных по алгоритму, описанному в RFC 1071 «Computing the Internet Checksum».

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

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

Внимание! В IPv6 не предусмотрен контроль целостности на сетевом уровне. Поэтому расчет контрольной суммы при его использовании обязателен. В этом случае контрольная сумма будет покрывать и сетевые адреса, как того требует RFC 2460 «Internet Protocol, Version 6», раздел 8.1.

Длина

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

Минимальный размер — 8 байт, то есть только заголовок, без данных.

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

Теоретический максимальный предел — 65 507 байт — определяется по следующей формуле:

0xffff — (sizeof(IP-заголовок) + sizeof(UDP-заголовок)) = 65535 — (20 + 8) = 65507

Здесь 0xffff — максимальное значение, которое может хранить поле размера.

Но большинство протоколов, работающих поверх UDP, ограничиваются гораздо меньшим размером — обычно либо 512, либо 8192 байта. Часто можно безо­пасно увеличить размер до 548. Такое ограничение размера обусловлено тем, что сетевые устройства могут отправлять кадры определенного максимального размера — MTU, или Maximum Transmission Unit. Например, Ethernet-адаптер имеет MTU, равный 1500.

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

Если дейтаграмма с IP-заголовком, который вместе с опциональными полями может иметь размер до 60 байт, а также заголовками протоколов, в которые могут быть инкапсулированы дейтаграммы, например, при использовании IPSec, превысит MTU, протокол IP ее фрагментирует. Это значительно повысит вероятность того, что дейтаграмма не будет доставлена, то есть увеличит потери. Поэтому лучше оставить разумный запас байтов для заголовков, чтобы дейтаграмма была отправлена без фрагментации.

Обычно считается, что достаточно 512 байт полезной нагрузки, но фактический максимальный безопасный размер полезной нагрузки — 508 байт.

Согласно RFC 791 «Internet Protocol» все узлы должны принимать дейтаграммы размером до 576 октетов, независимо от того, приходят ли они целыми или фрагментированными.

RFC рекомендует отправлять дейтаграммы больше 576 октетов, только если узел уверен, что приемник готов принимать большие дейтаграммы.

Число 576 выбрано, чтобы в дополнение к заголовку мог быть передан блок данных разумного размера.

Максимальный размер заголовка IP — 60 октетов. Размер заголовка UDP — 8 октетов.

576 — 60 — 8 = 508 октетов или байт.

Порты

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

Таким образом:

IP-адрес указывает машину, которой нужно отправить данные. А точнее — сетевой интерфейс, которых у машины может быть несколько.

Порт указывает, какому процессу на этой машине отправлять данные.

Пространство портов для каждого протокола разное. Одновременно могут работать процессы, которые прослушивают, например, порты 80 TCP и 80 UDP.

Порт — это, по сути, еще один вид адреса, который вводится транспортным протоколом. Он адресует процесс или группу процессов, использующих данный сокет.

Не все протоколы имеют порты. Например, у ICMP порта нет, так как его PDU не адресованы конкретным процессам, а отправляются сетевому стеку узла. Такие PDU будут видны любому процессу в системе, если он имеет на это полномочия.

Но для транспортных протоколов использование портов характерно.

Эфемерные порты

Чтобы организовать двусторонний обмен, протоколы используют так называемый эфемерный, или динамический, порт.

Например, TCP-сокет, создаваемый сервером при подключении клиента, или UDP-сокет для приема данных на стороне клиента имеют свои номера портов.

Диапазон, из которого они выделяются, стандартизован IANA: 49152-65535. Но некоторые устаревшие системы, такие как Windows до версии Vista, используют нестандартные диапазоны, например 1024-5000. В Linux диапазон настраивается и по умолчанию равен 32768-60999.

Эти порты ОС назначает автоматически. Программист может задать порт явно, но чаще всего это не имеет смысла.

Стандартные порты

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

Известные стандартные номера регистрируют в IANA. Сейчас их уже несколько тысяч. Сначала номера соответствовали значениям меньше 1000, поэтому современные ОС запрещают приложениям без привилегий администратора использовать «общеизвестные» порты с номерами до 1024. Также эти порты называются привилегированными. Они гарантированно не будут использоваться как эфемерные. Зачастую программы, которые их занимают, являются системными демонами.

Среди номеров портов интересны следующие:

0/TCP,UDP — на стороне отправителя в bind() используется для указания свободного порта, обычно случайного. Не может появиться в PDU, и если он там есть, это ошибка. Номера портов начинаются с 1.

• 1/TCP,UDP — мультиплексор, описанный в . Позволяет работать с несколькими службами через один порт.

• 7/TCP,UDP — ECHO-сервер. Предназначен для тестирования.

• 9/TCP,UDP, SCTP — DISCARD. Принимает данные, не обрабатывая их и отбрасывая после приема. Может быть использован для Wake on LAN, то есть включения компьютера по сети.

• 20, 21/TCP — FTP, File Transfer Protocol. Незащищенный протокол для передачи файлов.

22/TCP — Secure Shell, или SSH. Позволяет управлять машиной с помощью Shell через защищенное соединение.

80/TCP — HTTP. Незащищенный протокол, используемый для работы WWW-браузера.

443/TCP — HTTPS. Защищенный HTTP. Сейчас этот протокол используют большинство сайтов WWW.

666/TCP,UDP — игра Doom.

708, 732-740, 743/TCP,UDP — свободные порты. Кроме них существуют и другие диапазоны свободных портов.

989, 990/TCP,UDP — FTPS. Защищенный протокол для передачи файлов.

• 993/TCP, UDP — IMAPS, Internet Message Access Protocol Secure. Защищенный протокол для обмена почтой между почтовым клиентом и сервером.

• 1011-1020/TCP,UDP — зарезервированы. Не используются.

• 1021, 1022/TCP, UDP — зарезервированы для сетевых экспериментов.

• 1023/TCP,UDP — зарезервирован. Не используется.

• 1024/TCP,UDP — зарезервирован. Не должен использоваться. Но его используют, например, K Display Manager или NetMeeting, а также несколько червей и троянов.

Функции для работы с портами

В Linux соответствие названий служб и используемых ими протоколов хранится в файле /etc/services. А соответствие номеров портов и названий протоколов хранится в файле /etc/protocols.

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

#include <netdb.h>

 

struct servent

{

    // Официальное имя сервиса.

    char *s_name;

    // Список псевдонимов.

    char **s_aliases;

    // Номер порта.

    int  s_port;

    // Используемый протокол.

    char *s_proto;

};

 

// Возвращает структуру servent, для сервиса с именем name,

// использующего протокол proto.

// Если proto нулевой, подойдет любой протокол.

servent *getservbyname(const char *name, const char *proto);

 

// Возвращает структуру servent для строки, совпадающей с портом port,

// указанным в сетевом порядке расположения байтов и использующим протокол

// proto.

// Если proto нулевой, подойдет любой протокол.

servent *getservbyport(int port, const char *proto);

Для получения стандартных номеров портов по названию протокола и обратно существуют функции getprotobyname() и getprotobynumber():

#include <netdb.h>

 

struct protoent

{

    // Официальное название протокола.

    char  *p_name;

    // Список псевдонимов.

    char **p_aliases;

    // Номер протокола.

    int p_proto;

};

 

// Возвращает структуру protoent, соответствующую имени протокола.

// Например "tcp", "udp"...

protoent *getprotobyname(const char *name);

 

// Возвращает структуру protoent, соответствующую коду протокола.

protoent *getprotobynumber(int proto);

Для навигации по базе протоколов также существуют функции:

// Открыть базу, если требуется.

// При каждом следующем вызове читает новую запись protoent из базы.

protoent *getprotoent();

 

// Открыть базу и установить указатель на первую запись.

void setprotoent(int stayopen);

 

// Закрыть базу.

void endprotoent();

Если параметр stayopen функции setprotoent() ненулевой, база не будет закрываться с каждым вызовом функции.

В общем случае для подобного API в GLibC есть несколько возможных бэкендов, выполняющих поиск: простой файл, DNS, NIS или даже X.500.

В большинстве систем программы обращаются к кэшу DNS, например к DNSMasq, а затем кэш выполняет DNS-запросы на быстрый сервер.

Но при использовании подключения к DNS или другому сетевому бэкенду стоимость подключения может быть значительной, например, когда используется TCP.

Параметр stayopen необходим, чтобы выполнить подключение один раз и оставить соединение открытым.

В случае неудачного завершения функции возвращают нулевой указатель, в противном случае — указатель на структуру.

В Python функции с аналогичными сигнатурами представлены в модуле socket, кроме функции getprotobynumber(), которая отсутствует:

def getservbyname(servicename, protocolname=None) -> int

def getprotobyname(name) -> int

def getservbyport(port, protocolname=None) -> string

В Linux C API также существует функция bindresvport():

#include <sys/types.h>

#include <netinet/in.h>

 

int bindresvport(int sockfd, struct sockaddr_in *sin);

Она используется для привязки сокета к привилегированному анонимному порту, номер которого произвольно выбирается из диапазона от 512 до 1023. В MacOS X — от 1 до 1023. Соответственно, успешно вызванная функция может быть только приложением, обладающим необходимыми правами или привилегиями.

Перед вызовом поле sockaddr_in должно быть установлено в AF_INET либо AF_INET6. Если поле не установлено, считается, что его значение — AF_INET.

Если привязка, выполнена успешно, то есть sin->sin_port установлен, функция вернет 0, в противном случае –1.

В Python данная функция отсутствует.

Применение этой функции зачастую нецелесообразно. Она вводилась для того, чтобы Network File System на сервере могла доверять идентификатору пользователя, который передает клиент. Если порт клиента меньше 1024, значит, на машине пользователя демон работает с правами root и пользователь не может подменить данные, отправляемые клиентом, так как предполагается, что у клиента root доступа нет.

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

Резюме

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

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

Сокеты работают в соответствии с протоколом, который определяет правила взаимодействия. Тип сокета полностью зависит от стека протоколов, на котором он основан.

В сокетах обычно есть клиентская и серверная части. Клиент — компонент, который активно выполняет запрос на подключение, сервер — компонент, который ожидает подключения.

Алгоритм работы клиента:

1. Создать сокет с использованием функции socket().

2. При необходимости использовать функции bind() и connect().

3. Отправить данные серверу с помощью функции send() или sendto().

4. Прочитать ответ от сервера с помощью функции recv() или recvfrom().

5. Закрыть сокет с помощью функции close() или closesocket().

Алгоритм работы сервера:

1. Создать сокет с использованием функции socket().

2. Привязать сокет к прослушиваемому порту и интерфейсу с помощью функции bind().

3. Принять новое подключение с помощью функции accept().

4. В цикле на сокете, возвращенном функцией accept(), ожидать данных от клиента, например, с помощью функций recv() или recvfrom().

5. Отправить ответ клиенту с помощью функций send() или sendto().

6. Закрыть сокет с помощью функции close() или closesocket().

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

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

Сервер не подтверждает прием данных от клиента. Данные передаются в виде дейтаграмм, которые доставляются конкретному процессу, идентифицируемому с помощью порта.

Порты могут быть эфемерными — автоматически выбранными системой; обыч­ными — выбранными явно, для сервера чаще всего из номеров выше 1024; или стандартными, то есть зарегистрированными IANA, из диапазона 1–1024.

Вопросы и задания

1. Назовите основные компоненты сетевой подсистемы большинства ОС.

2. Кратко опишите процесс отправки данных в типовом сетевом стеке.

3. Каковы три характерные особенности сетевой подсистемы ОС Windows?

4. Что является центральным звеном сетевой подсистемы QNX?

5. В чем отличие реализации сети в QNX от реализации в большинстве других ОС?

6. Почему в стеке Linux очереди и различные дисциплины очередей существуют только для передачи?

7. Чем различаются POSIX- и BSD-сокеты?

8. В чем отличие WinSock 2 API от сокетного API POSIX-совместимых ОС?

9. Как реализован вызов функций сокетного API в Go?

10. Для чего на клиенте могут использоваться функции bind() и connect()?

11. Почему для сокетов без установки соединения вызовы функций bind() и connect() опциональны?

12. Обязателен ли на сервере вызов функции bind()? Почему?

13. Для чего обычно используются UNIX-сокеты и каков их принцип работы?

14. Перечислите возможные типы UNIX-сокетов.

15. Что собой представляет адрес UNIX сокета?

16. Перечислите возможные типы адресов UNIX-сокетов.

17. Какая функция используется обычно для создания только UNIX-сокетов и редко с другими семействами и почему?

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

19. В одном из примеров сервер остался работать в фоновом режиме. Как его завершить?

20. Зачем нужны протоколы, не гарантирующие надежную передачу данных?

21. Чем отличается работа клиента и сервера, когда используется протокол без установки соединения?

22. Что такое протокол RDP?

23. Для чего нужен UDP?

24. Зачем нужно рассчитывать контрольную сумму UDP дейтаграммы? Всегда ли это требуется при использовании UDP поверх IPv4? А поверх IPv6?

25. Каким образом данные доставляются конкретному процессу при использовании дейтаграмм?

26. В заголовке UDP указан порт получателя и отправителя, но не указан IP-адрес. Каким образом дейтаграмма находит адресата?

27. Что такое порт и для чего он используется?

28. Что такое эфемерные порты?

29. Чем обычные порты отличаются от стандартных?

30. Напишите приложение, которое принимает в качестве параметра название протокола и выдает список стандартных портов, за которыми закреплен этот протокол.


Назад: Глава 2. Адресация
Дальше: Глава 4. Простой обмен данными. Raw-сокеты