Никогда не недооценивайте пропускную способность несущегося по шоссе грузовика, набитого магнитными картриджами.
Эндрю Таненбаум, «Компьютерные сети» (изд-во «Питер»)
Сперва ответим на вопрос, что же такое сетевое программирование.
Сетевое программирование — это реализация обмена данными по сети между определенными сущностями. Сеть в данном случае — среда для связи.
Компьютерная сеть — это множество взаимодействующих и совместно использующих ресурсы вычислительных устройств. Сеть состоит из узлов и каналов, соединяющих узлы.
Такую среду можно представить в виде графа, изображенного на рис. 1.1, где узлы — это субъекты взаимодействия, а ребра — каналы обмена данными между узлами.
Субъектами взаимодействия могут быть:
• физические устройства;
• драйверы протоколов;
• приложения;
• пользователи, которые общаются по сети, используя данные приложения.
Каналы могут быть физическими, например проводной Ethernet или радиоканал, либо виртуальными, например канал OpenVPN, опирающийся на протоколы более высокого уровня, или разделяемые на маршрутизаторе по тегам — каналы VLAN.
В этой главе мы:
• разберем, что такое сетевое программирование, зачем оно нужно и какое место занимает в структуре знаний и навыков специалиста;
• узнаем, какие стандарты определяют интерфейс сокетов и работу протоколов;
• узнаем, где и как находить информацию;
• поймем, что такое сокеты, как их создавать, закрывать и назначать им адреса.
Рис. 1.1. Схема обмена данными по сети
Чтобы закрепить знания, мы начнем разрабатывать свою простую обертку над сокетами.
Когда мы говорим о сетевом программировании, то в большинстве случаев подразумеваем написание сетевых приложений, работающих под управлением ОС. Но это не всегда так. Реализация сетевых протоколов, например их стеков, также относится к области сетевого программирования.
В книге мы рассматриваем преимущественно разработку приложений.
Сетевое взаимодействие приложений представлено стеком уровней. Разбивка по уровням дает возможность использовать на каждом из них независимые протоколы, не затрагивая вышележащие.
Физический уровень отправляет единицы и нули по кабелю, оптоволокну, радиоканалу и т.п.
Затем уровень канала передачи данных организует эти единицы и нули во фрагменты данных и безопасно доставляет их в нужное место по сети. Сетевой уровень передает организованные данные по нескольким сетям, а транспортный уровень доставляет данные нужному приложению в пункте назначения.
Стек уровней в компьютерных сетях описывается такими моделями, как OSI — Open Systems Interconnection или DoD — Department of Defence — модель Министерства обороны США.
Модель OSI, показанная на рис. 1.2, — наиболее общая. Она состоит из семи уровней, каждый из которых обычно представлен одним протоколом либо его частью.
Рис. 1.2. Модель OSI
Уровни OSI сверху вниз:
• Прикладной — протоколы, которые используют конкретные приложения, например браузер: HTTP, FTP и т.п.
• Представления данных — кодировки ASCII, UNICODE, преобразования UTF, форматы данных, например JPEG или PNG, и т.п.
• Сеансовый — протоколы установления сессии, аутентификации, такие как L2TP, PAP/CHAP и т.п.
• Транспортный — обеспечивает передачу данных внутри сети: TCP, UDP, UDP Lite, SCTP и т.д.
• Сетевой — обеспечивает возможность обмена данными между разными сетями: IPv4, IPv6, устаревший — IPX.
• Канальный — протоколы обмена данными по каналу. Например Ethernet, определяющий формат кадров и правила согласования настроек адаптеров, PPP, выполняющий согласование параметров обмена, в частности PPPoE, работающий поверх Ethernet.
• Физический — различные физические каналы, спецификация типа среды, формы сигналов, напряжений, если это применимо к среде, например Ethernet по витой паре.
Модель OSI критикуют за то, что не все ее уровни всегда явно присутствуют в реальных системах.
Уровней в ней действительно много, и последние три часто описываются и реализуются в рамках одного протокола.
На практике удобнее работать с более простыми моделями, которых около десятка: модель Таненбаума, модель Cisco Academy и др. Одна из таких упрощенных моделей, показанная на рис. 1.3, — это DoD-модель, она же модель TCP/IP, которая содержит всего четыре уровня:
• Прикладной уровень — объединение прикладного и сеансового уровней и уровня представления OSI. Уровень конкретных протоколов, используемых приложениями.
Рис. 1.3. DoD-модель
• Транспортный уровень — соответствует транспортному уровню OSI. Отвечает за гарантированную или негарантированную доставку и правильную последовательность прихода данных.
• Межсетевой — аналог сетевого уровня OSI. Нужен для передачи данных из одной сети в другую, независимо от протоколов нижнего уровня.
• Уровень сетевого доступа — объединяет физический и канальный уровни OSI. Описывает способ кодирования данных на физическом уровне и среду передачи, ее физические параметры.
Количество уровней вложенности потенциально не ограничено. Как правило, новые протоколы добавляются поверх модели.
В некоторых случаях, например при использовании отдельных реализаций VPN, поверх транспортного или прикладного уровня может «вырасти» стек протоколов, начиная с уровня верхнего протокола.
В моделях данные переходят с верхних уровней на нижние и инкапсулируются в обертки протоколов, необходимые для сетевого обмена.
Эти обертки в рамках модели называются служебными модулями данных — Service Data Unit, или SDU. Служебные потому, что передаются между уровнями внутри модели.
С последнего уровня производится отправка данных в сеть.
После того как нижний уровень принял данные, он «снимает» обертку, которая предназначена для него, и передает данные, которые были обернуты, верхнему уровню. По сути, данные на предыдущем уровне декапсулируются обратно в понятные следующему уровню.
Получается, что реальное взаимодействие происходит на самом нижнем, физическом уровне. Но если выполнить срез на любом из уровней, понятные ему данные будут передаваться между ним и тем же уровнем на другой стороне коммуникационной среды, как показано на рис. 1.4.
Рис. 1.4. Срез обмена данными по сети
Данные, которые циркулируют между приложениями, на каждом уровне передаются как единицы данных протокола, или Protocol Data Unit — PDU. Это могут быть пакеты TCP, дейтаграммы или IP-пакеты.
Существуют разные соглашения об именовании IP PDU:
• В MIL-STD-1777 и RFC-791 «Internet Protocol» — дейтаграмма.
• Популярное название в книгах — пакет.
В этой книге мы будем использовать популярное название «пакет».
Для PDU TCP будем использовать название «сегмент», а для UDP — «дейтаграмма».
Приложение обычно не занимается их обработкой, которая возложена на стек ОС, но может и обрабатывать их, если, например, требуется осуществлять маршрутизацию не так, как это делает операционная система.
Такая возможность позволяет строить нетривиальные схемы работы в сети. Например, VPN, работающий поверх HTTPS, позволяет обходить DLP-системы провайдеров, которые блокируют иные протоколы. Другой пример — разрабатываемый стандарт DoH, который подразумевает передачу DNS-запроса по обычному защищенному HTTPS-протоколу.
Протоколы нижних уровней в общем случае могут передавать любые данные, но внешние требования, которые реализуются в приложениях, часто ограничивают их, задавая следующие параметры:
• время задержки от начала передачи;
• скорость передачи;
• возможность потерь и гарантии доставки;
• ограничение на маршрут передачи, особенно если данные конфиденциальные;
• срочность доставки.
Некоторые требования противоречивы; например, гарантия доставки с пересылкой увеличивает надежность, однако уменьшает скорость передачи данных.
Поэтому для разных задач есть транспортные протоколы, как правило, реализуемые в стеке операционной системы:
• TCP — протокол, обеспечивающий гарантированную упорядоченную надежную передачу данных. Позволяет организовать поток из одного узла в другой. При его использовании отправитель точно знает, получил адресат данные или нет. Надежность в данном случае означает, что данные точно придут без искажений в содержании. Поток байтов означает, что отдельные вызовы отправки данных не создают границ и могут быть преобразованы во множество PDU. Этот протокол был описан и реализован в 1974 году.
• UDP — протокол без гарантий. На его основе пользователи могут реализовывать собственные протоколы. В рамках протокола UDP производится обмен сообщениями или дейтаграммами, каждый вызов отправки данных создает один PDU — дейтаграмму, которая содержит точно одну порцию данных.
• UDP Lite — протокол UDP с частичным расчетом контрольной суммы. Является расширением протокола UDP и используется для того, чтобы не выполнять лишние вычисления.
• SCTP — Stream Control Transmission Protocol. Транспортный протокол, как и TCP, обеспечивающий надежную упорядоченную передачу данных. Протокол был разработан в 2000 году, в нем исправлены недостатки TCP и реализованы многопоточность (внутри одного соединения может быть несколько каналов), защита от DDoS атак и синхронное соединение между двумя хостами по двум и более независимым физическим каналам. Обмен данными производится в виде сообщений, границы которых сохраняются.
Это не единственные транспортные протоколы, мы не охватили еще достаточно многие, но основные — это TCP и UDP, поверх которых реализуется большинство других.
Протокол SCTP, который был призван устранить недостатки TCP, так и не стал популярным. Он плохо поддерживается сетевым оборудованием и редко используется на практике.
Кроме транспортных протоколов общего назначения, существуют специальные транспортные протоколы для особых условий, и позже мы вкратце поговорим и о них.
Протокол удаленного сетевого обмена TCP был изобретен Робертом Эллиотом Каном совместно с Винтоном Серфом в рамках решения проблемы совместимости систем и каналов связи. Р.Э. Кана и В. Серфа называют отцами-основателями интернета.
В DoD-модели уровнем выше транспортного реализуются протоколы для обмена данными между приложениями.
Некоторые протоколы вводят свою адресацию, например:
• Протоколы сетевого уровня, например IP, вводят адреса узлов и сетей.
• Протоколы канального уровня, например Ethernet, вводят адреса оборудования, или MAC-адреса, для того чтобы доставить кадры на правильный узел из множества подключенных к шине. MAC-адрес используется даже в соединениях типа «точка-точка», хотя в этом случае он является просто рудиментом.
• Протоколы транспортного уровня, такие как TCP и UDP, вводят идентификаторы портов для того, чтобы выбрать, какому процессу на узле отправить принятые данные.
Сказанное выше означает, что на разных уровнях модели появляются разные адреса и сеть больше нельзя полностью описать только одним графом.
Для каждого уровня с адресацией имеется свой граф, описывающий топологию. Но в частном случае, когда протокол некоторого уровня не предполагает адресации, графы данного уровня и следующего за ним могут совпадать.
Например в сети Ethernet граф физического уровня будет состоять из узлов, подключенных к одной шине, один из которых — маршрутизатор, подключенный к другому маршрутизатору через соединение «точка-точка», а к тому, в свою очередь, подключены узлы на одной шине.
На сетевом уровне эта топология может быть представлена двумя подсетями — каждая за своим маршрутизатором. Шина видна не будет, то есть граф будет состоять из двух соединенных «звезд» с центрами-маршрутизаторами.
Если же сети объединены маршрутизаторами на канальном уровне в одну сеть, их граф сетевого уровня будет представлять собой просто связь всех со всеми, а маршрутизаторы вообще не будут видны.
Примеры топологий представлены на рис. 1.5.
Рис. 1.5. Топологии сетей
Обычно физическая сеть представлена шиной, то есть полносвязным графом, где все связаны со всеми; однонаправленным кольцом, например, с передачей маркера; или топологией «звезда», когда все узлы подключены к разным портам центрального роутера, а он решает, кому, что и куда отправлять.
Однако на уровне коммуникации приложений у каждого узла может быть доступ ко всем остальным узлам, то есть сеть будет полносвязной.
В книге мы будем писать код для компьютерных сетей, которые имеют следующие особенности:
• Это цифровые сети. В них, независимо от среды передачи, с физического уровня поступают нули и единицы и любой сигнал оцифровывается.
• Это сети, для которых предполагается не канальная, а пакетная коммутация. Канальная коммутация тоже встречается, например, в случае использования модемов для соединения через PSTN, но даже тогда, при установленных каналах, верхние уровни обеспечивают пакетную коммутацию. Пакетная коммутация настолько хорошо себя зарекомендовала, что используется даже в сетях межпланетной связи.
• Абсолютное большинство таких сетей стандартизированы и основаны на стеке TCP/IP. Когда-то для локальных сетей был популярен стек IPX/SPX, но с угасанием популярности ОС Novell NetWare он уступил место TCP/IP и больше практически не встречается.
Большую часть внимания в книге мы уделим следующим вопросам:
• изучению и использованию того, что реализовано выше транспортного уровня OSI;
• изучению API и библиотек;
• реализации собственных простых протоколов прикладного уровня.
API, предоставляемый операционной системой, обычно позволяет работать поверх уровня транспортного протокола и частично конфигурировать нижележащие уровни для обмена данными.
Обычно непривилегированному пользователю запрещено работать на уровнях ниже транспортного, потому что это небезопасно. Но бывают случаи, когда работа непривилегированного пользователя на уровнях ниже транспортного оправданна:
• Прослушивание всего трафика, который приходит в сетевые устройства, и обычно его последующая интерпретация либо запись. Используется в анализаторах трафика, или снифферах.
• Приложения реального времени, которые должны знать, сколько времени требуется на запрос и ответ. В ОС, которые не являются системами реального времени, зачастую недопустимы задержки, которые вносит стек ОС. Как пример — VoIP-приложения, которые могут работать поверх сетевого уровня или даже ниже, самостоятельно формируя дейтаграммы и пакеты.
• Различные специальные протоколы, например, требующие отправки большого потока трафика, такие как протоколы синхронизации между узлами кластеров.
• Экспериментальные реализации сетевых протоколов, в том числе протоколов канального уровня: PPP, MPLS и др.
• Конфигурирование и настройка сетевого оборудования.
• Использование служебных протоколов: ICMP, IGMP, SNMP и др.
• Реализация взаимодействия с операционными системами, которые не поддерживают TCP/IP. Например, QNX 4.25 использует собственные протоколы. А простейшим сетям на микроконтроллерах зачастую не хватает ресурсов памяти и CPU, чтобы выполнять полный код стека со всеми проверками и условиями.
Все эти случаи мы рассмотрим в книге и некоторые из них — подробно.
Служебные протоколы, используемые в стеке TCP/IP, мы разберем отдельно. Прежде всего поговорим о протоколе ICMP — Internet Control Message Protocol, который позволяет узнавать состояние отдельных узлов сети.
В остальном даже протоколы, необходимые для взаимодействия маршрутизаторов, реализуются поверх TCP, как, например, BGP, или поверх IP, как например, OSPF. И поэтому работе с протоколами, реализованными поверх TCP и IP, мы уделим больше внимания.
Ни одна книга не может быть всегда актуальной и такой же точной, как спецификации и код.
Обычно книга дает картину «с высоты», а при увеличении масштаба неизбежны «белые пятна». Но книга может подсказать, как и где найти подробную информацию.
Перечислим некоторые источники данных о сетевых протоколах и способах их реализации. На другие источники мы будем ссылаться по ходу изложения.
Это предложения изменений, которые де-факто являются стандартом после их публикации, хотя де-юре им не являются.
Вот некоторые примеры:
• — программное обеспечение узла. Первое, уже не вполне актуальное RFC.
• — описание IP.
• — описание UDP.
• — описание TCP.
• — описание стандартных протоколов Internet.
• — туториал по TCP/IP.
• — расширения сокетного API для IPv6.
• — правила использования UDP.
В RFC содержится практически вся информация, необходимая для понимания и реализации протоколов, а значит, и сетевого взаимодействия. Но зачастую их тяжело читать. К тому же они дают лишь понимание того, как работает протокол, но не о том, какие реализации существуют, если только это не RFC о реализациях. Таким образом, RFC — это отправная точка для тех, кто реализует протокол, и тех, кто хочет точно понимать все нюансы.
Прикладные разработчики читают RFC нечасто, хотя в них описано большинство действующих стандартов интернета.
После написания RFC и частичной проверки работы основные протоколы стека TCP/IP были закреплены в различных стандартах, таких как:
• MIL-STD-1777для IP, MIL-STD-1778 для TCP.
• — стандарт на API операционной системы.
• Стандарты от X/Open и OpenGroup.
• Прочие стандарты, которые выпускаются организациями, такими как W3C или OMG, и перечислены в книге 2.
Стандарты разработаны на многие протоколы и решения. Зачастую они достаточно объемны и написаны сложным для понимания языком. Но они являются стандартами де-юре, и некоторые системы, например военные, обязаны им точно соответствовать.
Мы предполагаем, что большинство разработчиков сетевых приложений будут работать в Unix-подобных операционных системах, таких как Linux.
Вести разработку можно и на ОС Windows, но авторы исходят из своей практики: многие крупные компании предполагают, что разработчики будут или могут использовать Linux.
Сами же приложения часто являются кросс-платформенными, если это не серверные решения: нет смысла терять аудиторию из-за того, что некоторые пользователи работают на других ОС.
На ОС Windows до сих пор работает большинство пользователей, если не учитывать мобильные устройства.
Далее указаны не все команды. Например, существует команда locate, в современных системах Linux устанавливаемая из пакета slocate. Она ведет поиск по базе данных, которую требуется иногда актуализировать.
Подобных команд достаточно много, и возможно, одна из них будет удобна лично вам, поэтому следует изучить возможности системы.
Команда man в Linux выдает информацию о большинстве параметров системы, если установлены man-страницы.
Примеры:
• man 3 socket покажет описание C-функции socket(), используемой для создания сокетов.
• man 7 socket в Linux даст описание Linux Socket Interface.
В некоторых дистрибутивах Linux, например в Arch и Gentoo, man-страницы включены в пакет с основными страницами и чаще всего предустановлены.
В Debian и дистрибутивах на его основе пакеты с man-страницами поставляются отдельно.
Основные man-страницы находятся в пакете manpages. А страницы в разделе 3 или 3p обычно идут в пакете manpages-posix-dev.
Если какая-то документация отсутствует, информацию о том, какой пакет нужно установить, можно искать в руководстве и ресурсах сообщества по используемому дистрибутиву или воспользоваться man-страницами онлайн.
Man-страницы также доступны онлайн на многих ресурсах, например:
• — сайт die.net содержит коллективные проекты нескольких десятков друзей из США. В частности, linux.die.net предоставляет доступ к документации Linux.
• — сайт Майкла Керриска, автора книги «Linux API Interface». Предоставляет доступ не только к man-страницам онлайн, но и к полезным ресурсам, таким как ссылки на статьи, описывающие различные опции.
• — русский перевод man-страниц.
• — русские man-страницы на проекте OpenNet.
• Специфичные для ОС man страницы доступны на ресурсах ОС:
• — FreeBSD.
• — NetBSD.
• — OpenBSD.
• — документация различных систем Oracle, man также доступен в отдельном разделе.
• — документация продуктов IBM. Например, описание man по AIX 7.3: .
Внимание! В разных разделах находятся разные man-страницы, которые могут иметь одинаковое название: в Linux команда man 2 daemon выводит страницу о функции daemon(), а команда man 7 daemon — страницу с инструкцией о том, как написать демон правильно. Иногда пишут daemon(2) — это означает man 2 daemon.
Вызов man без указания раздела возвращает первую статью, найденную в кэше man, сформированном обычно исходя из того, как часто, согласно представлениям разработчиков ОС, используется раздел.
Показывает, в каких разделах искать страницы man. Пример: whatis socket будет искать по названиям страниц и, среди прочего, покажет, какие аспекты socket описаны в разделах 3 и 7:
➭ whatis socket
socket (7) - Linux socket interface
socket (n) - Open a TCP network connection
Socket (3perl) - networking constants and support functions
socket (3p) - create an endpoint for communication
socket (2) - create an endpoint for communication
Выводит интерактивное руководство в системе Info. Пример: info socket выдаст страницу руководства, где простым языком описано, как создать сокет и для чего, как его потом закрыть и что с ним делать.
Ведет поиск не только по именам, но и по кратким описаниям. Вывод команды объемен — прибегать к ней лучше в крайнем случае:
➭ apropos socket
IO::Socket::IP (3perl) - Family-neutral IP socket supporting both IPv4 and IPv6
Socket (3perl) - networking constants and support functions
accept (2) - accept a connection on a socket
accept (3p) - accept a new connection on a socket
accept4 (2) - accept a connection on a socket
address_families (7) - socket address families (domains)
...
zmq_setsockopt (3) - set 0MQ socket options
zmq_socket (3) - create 0MQ socket
zmq_socket_monitor (3) - monitor socket events
zmq_socket_monitor_versioned (3) - monitor socket events
zmq_unbind (3) - Stop accepting connections on a socket
zmq_vmci (7) - 0MQ transport over virtual machine communication interface (VMCI) sockets
Также для получения справки можно использовать утилиты пакетной системы, специфичные для конкретной ОС.
Примеры:
• apt-cache search 'socket' в системах на основе Debian будет искать пакеты, в описании или названии которых есть «socket».
• pacman -Qs ".*socket.*" — то же самое, но в системах на основе Arch.
Следует знать соответствующие команды для системы, которую вы используете на постоянной основе.
Если установлен соответствующий пакет, например kernel-doc, в подкаталогах /usr/share/doc/kernel-doc-<версия>/Documentation будут доступны подкаталоги текстовых файлов с проектными решениями, принятыми в ядре Linux. Документацию по разделам можно найти на сайте .
В книге при указании ссылок на документацию ядра путь дается относительно корня, содержащего документацию каталога.
Стоит помнить, что для Linux доступен исходный код, в том числе на .
Для разработчиков под ОС Windows компания Microsoft предоставляет базу данных с достаточно полным описанием API Windows, в том числе API для работы с сокетами. Она называется MSDN Library — библиотека MicroSoft Developer Network. Ранее она могла быть установлена вместе с MS Visual Studio, а сейчас .
Там же доступна библиотека портала , ориентированная преимущественно на других IT-профессионалов и опытных пользователей.
Примеры кода доступны:
• на Github в организации ;
• на портале .
Исходный код ОС Windows
Для ОС Windows, как правило, возможности посмотреть исходный код нет. Но для версии XP код появился в сети и сейчас доступен на GitHub: . Его изучение может помочь в решении некоторых вопросов.
Кроме того, концепции решений можно посмотреть в исходном коде свободного аналога ОС Windows — ReactOS: . Однако стоит понимать, что их реализация в ОС Windows может значительно отличаться.
Любой популярный язык программирования включает не только описание конструкций и компилятор, но и стандартную библиотеку. Зачастую в ней содержатся и сетевые возможности. Например:
• для C++ существуют:
• описание Стандарта на ;
• сайт ;
• многие другие сайты, описывающие STL;
• для Python существуют:
• документы Python Enhancement Proposals, или PEP, на . Например, из PEP 475 Retry system calls failing with EINTR можно узнать, как Python должен обрабатывать ошибку EINTR, что пригодится нам при обмене данными;
• полная документация по его библиотеке: .
Для других языков существует своя документация, которой не следует пренебрегать.
Необходимо понимать, что реализованное в библиотеках большинства языков API опирается на интерфейс сокетов, который мы рассмотрим далее, в частности, на примере языка Go в конце следующей главы. Это в основном справедливо и для прочих языков. Поэтому если вы хотите действительно разобраться в теме, данный интерфейс потребуется изучить.
позволяет находить готовые решения, задавать вопросы другим специалистам и обмениваться мнениями. Стоит учитывать, что ответы могут быть неполными или не вполне правильными.
Также надо отметить, что StackOverflow — это лишь одно из сообществ, которые предоставляет сервис .
Некоторые вопросы могут рассматриваться в других его сообществах, прежде всего:
• ;
• ;
• .
Сообщества StackOverflow поддерживаются на разных языках, в том числе на русском: .
Внимание! StackOverflow и схожие ресурсы — источники мнений и решений, которые не всегда верны. Проверяйте найденные на них решения перед тем, как их применить. Не следует внедрять их бездумно.
Кроме того, наиболее полезен англоязычный ресурс, поскольку большинство людей говорит по-английски и на нем больше информации, проверенной большим количеством специалистов.
Из неспецифических ресурсов стоит отметить Википедию: она содержит множество описаний протоколов и API. По ссылкам под статьями удобно переходить к полным материалам.
Кроме того, иногда бывают полезны блоги компаний, занимающихся сетевыми технологиями, например CloudFlare: .
Нововведения в Linux, в том числе в сетевой части, обычно публикуются на сайте — новостном сайте для разработчиков. Там можно найти описания многих опций ядра, которые в ином месте не документированы.
И конечно, нельзя забывать о литературе, среди которой нужно выделить до сих пор актуальную книгу Ричарда Стивенса «UNIX. Разработка сетевых приложений».
Иногда подсказку может дать и бот сети ChatGPT или его аналоги. Но его ответы не всегда точны, особенно если запрос выходит за рамки примитивных и типовых.
Кроме того, необходимо обязательно проверять то, что сгенерировала такая сеть. Это все же генеративная модель, и она может «придумывать», а достоверности ответа не обещает и подавно.
Как пример, на вопрос о том, откуда пошло название «пакет» для PDU IP, бот ChatGPT ответил, что из RFC 791 «Internet Protocol». Но в данном RFC PDU IP называется «дейтаграмма» — datagram.
Чтобы процессы могли обмениваться данными по сети, необходимо создать канал обмена. Проще всего представить канал как две оконечные точки, через которые происходит чтение или запись данных. Сокет, или «розетка», — это и есть оконечная точка, условный пример которой показан на рис. 1.6.
Рис. 1.6. Так можно представить сокет
Процессы могут исполняться на одной или на разных машинах внутри сети.
Если требуется обмен только в рамках одной машины, используются Unix-сокеты, в ином случае — интернет-сокеты. Последние могут применяться и на одной машине, но это менее эффективно, потому что в этом случае задействуются все уровни сетевого стека системы.
Именно поэтому вполне возможно работать с приложениями на локальной машине через «сеть» и демонстрировать это в примерах.
Знание о том, что приложение общается с другим приложением на той же системе, отсутствует намеренно, и не предпринимается действий, чтобы обойти механизмы IP-стека, даже по соображениям производительности.
Например, передача по TCP всегда будет вызывать два переключения контекста, чтобы отправить данные удаленному сокету. Разница лишь в том, что данные будут проходить не через настоящий сетевой интерфейс, а через интерфейс петли — lo.
Как и при обмене по обычной сети, будет поступать подтверждение получения данных: ACK, TCP будет выполнять управление потоком, а IP — выполнять разбивку данных на пакеты размером в максимальный объем передачи данных интерфейса (MTU) и т.д.
Для работы с сокетами система предоставляет API.
Сокетное API уровня транспортного протокола и выше обычно предполагает наличие вызывающего и отвечающего абонентов. Часто их называют «клиент» и «сервер». Но стоит различать эти понятия на разных уровнях протоколов:
• На транспортном уровне сервер — этот тот, кто ожидает подключения, а клиент — тот, кто подключается.
• На прикладном уровне сервер — тот, кто предоставляет услугу. В ответ на запрос он может вернуть данные, перевести деньги на счет, включить двигатель или установку и т.д. А клиент — тот, кто запрашивает услугу. Как правило, в ответ он получает данные, хотя бы о том, что запрос принят или не принят сервером.
Например, в случае протокола FTP сервер — это тот, кто предоставляет услугу хранения данных некоторым клиентам. Если же FTP работает в активном режиме, сервер на транспортном уровне в начале его работы представляет собой то же самое, что и сервер на прикладном уровне: он ждет подключения, обычно на порт 21.
Клиент подключается к нему и отправляет запрос. Но затем клиент и сервер меняются местами: клиент ожидает подключения на порт 20, и к нему подключается сервер. То есть на транспортном уровне сервер теперь клиент, а клиент — сервер. Такое поведение обеспечивает наличие независимого канала управления передачей данных, которое осуществляется по второму каналу.
Обычно, когда мы говорим «клиент» или «сервер», мы подразумеваем работу на прикладном уровне, если не указано обратное.
Технология сокетов была разработана в Университете Беркли, а затем стандартизирована IEEE в рамках POSIX — Portable Operating System Interface.
Стандарт, который определяет сокеты, включает описания:
• заголовочных файлов для C;
• структур данных;
• функций.
Сейчас сокеты POSIX — наследники сокетов Беркли — один из вариантов сокетного API.
Сокеты — одна из фундаментальных технологий, на которой основан интернет. Все современные операционные системы так или иначе реализуют интерфейс POSIX-сокетов или похожий.
Первое название стандарта — Berkeley Sockets API. Последняя на момент написания книги версия стандарта — или, более полно, The Open Group Base Specifications Issue 7, 2018 edition.
Сокетный интерфейс описан в разделе 2.10.
Как говорилось ранее, интерфейс сокетов можно назвать самым низкоуровневым для прикладного разработчика. И во многих случаях его использование в программе для сетевого взаимодействия не будет оправданно. Целесообразно использовать библиотеки, авторы которых уже решили множество проблем и позаботились о различных нюансах, которые неопытный программист может не учесть.
Для опытного же разработчика нет смысла «изобретать велосипед»: в библиотеках сделано все то же самое, при этом хорошие библиотеки реализованы эффективно.
Но для того, чтобы пользоваться библиотеками уверенно, а не спрашивать, что означает та или иная опция, глядя на параметры вызовов как на набор магических символов, чтобы понимать, из-за чего возникла ошибка и как ее исправить, чтобы решать задачи, выходящие за рамки инструментария, предоставляемого библиотекой, необходимо хорошо разбираться в сокетном интерфейсе.
Кроме того, имеется некоторый класс задач, в основном по управлению системой, реализация которых в библиотеках попросту отсутствует, и для их решения пригодится хотя бы общее понимание сокетного интерфейса.
Сокетный интерфейс не первый и не единственный. Просто он более зрелый и широко используемый.
Кроме сокетного интерфейса, в AT&T UNIX System V Release 3 и AT&T Solaris существовал Transport Layer Interface — TLI. Его преемником стал стандартизованный TLI — , или XTI. Этот интерфейс похож на сокетный, но все его функции начинаются с префикса t_. Кроме того, он содержит функции, отсутствующие в интерфейсе сокетов: t_unbind(), t_alloc(), t_sync() и т.п. Многие из них легко реализовать, используя сокеты. Например, t_unbind() — это bind() с параметром AF_UNSPEC в семействе адресов.
Этот интерфейс был реализован в Unix поверх фреймворка STREAMS — конвейера сопрограмм, передающего сообщения между приложениями или между приложением и драйвером. Сейчас подобный фреймворк используется в системе Plan 9.
XTI/TLI также использовался в системах Novell, сеть в которых была реализована на базе стека протоколов IPX/SPX.
Всем сокетам при создании задаются некоторые характеристики. И только сокеты с полностью одинаковыми характеристиками можно связать друг с другом. Рассмотрим эти характеристики подробнее.
Существует более 30 семейств протоколов, все они перечислены в файле socket.h. Иногда семейства называют доменами.
socket.h на разных платформах
В Linux данные семейства определены в заголовочных файлах LibC.
Например, для GLibc в Debian и дистрибутивах-наследниках это пакет libc6-dev, где номер зависит от версии библиотеки.
Поскольку данный пакет для разных платформ различается, сам файл находится по соответствующему пути. Например, для платформы x86_64, то есть 64-битных Intel-совместимых машин, путь следующий: /usr/include/x86_64-linux-gnu/bits/socket.h.
В дистрибутивах, унаследованных от Arch Linux, файл содержится в пакете core/glibc. Сам же файл находится по пути /usr/include/bits/socket.h.
В других типах дистрибутивов файлы могут быть расположены по-другому. В любом случае это справочная информация, включать этот заголовочный файл в приложения нельзя.
В ОС Windows данный файл вообще отсутствует: там реализация сокетов не полностью POSIX-совместима, а константы перечислены в других заголовочных файлах.
Внимание! В создаваемых на C++ приложениях нужно включать файл sys/socket.h, а не bits/socket.h.
Примеры семейств протоколов:
• PF_INET — Internet Protocol версии 4. Это обычный TCP/IP на IPv4.
• PF_UNIX — локальные сокеты, или Unix sockets. Другое название — PF_LOCAL.
• PF_INET6 — Internet Protocol версии 6 или IPv6.
• PF_LINK — интерфейс канального уровня.
• PF_IEEE80211 — IEEE 802.11 канальный интерфейс беспроводных протоколов, Wi-Fi.
• PF_NETGRAPH — сокеты Netgraph. Это специфичные для FreeBSD сокеты. Netgraph — модульная сетевая подсистема, основанная на графах.
• PF_INET_SDP и PF_INET6_SDP — сокеты OpenFabrics Enterprise Distribution. Сокеты . Под этим типом сокетов лежит отдельный стек, работающий на адаптерах Mellanox и обеспечивающий функционирование . Это стек инфраструктуры датацентров.
• PF_BLUETOOTH — сокеты для работы со стеком протоколов Bluetooth. Bluetooth имеет свой отдельный большой стек протоколов со своими типами сокетов.
• PF_IPX — протоколы IPX/SPX Novell, для локальных сетей 1990-х.
Семейств протоколов множество, и к ним регулярно добавляются новые, а какие-то устаревают. В книге невозможно рассмотреть их все, но на некоторых мы будем останавливаться по ходу изложения. Самые важные для нас семейства — PF_INET и PF_INET6, которые содержат протоколы стека TCP/IP.
Семейства адресов определяют формат адреса и преобразования формата. Они связаны с семейством протоколов и должны иметь те же значения, что и PF-макросы:
• AF_INET соответствует PF_INET.
• AF_UNIX соответствует PF_UNIX.
• AF_INET6 соответствует PF_INET6.
• AF_BLUETOOTH соответствует PF_BLUETOOTH.
• AF_IPX соответствует PF_IPX.
PF-макросы берут свое начало из BSD-систем. Сейчас в большинстве случаев их можно заменить AF-макросами. Новые стандарты используют AF-макросы вместо PF, и это не ошибка.
Префикс PF_ означает Protocol Family, префикс AF_ — Address Family.
Посмотреть, какие семейства адресов поддерживаются в Linux, можно, введя следующую команду:
➭ man address_families
Определяет группу транспортных протоколов со схожим поведением.
Существует несколько вариантов поведения, например, для PF_INET:
• SOCK_STREAM — упорядоченный поток байтов с гарантированной доставкой, основанный на соединении. К этому типу относят сокеты, использующие TCP, но не любой сокет этого типа является TCP-сокетом. Основное свойство данного типа — поток без границ между порциями данных.
• SOCK_DGRAM — отправка дейтаграмм без соединения, гарантии доставки или сохранения порядка. UDP может работать как протокол для сокета этого типа. Данный тип сокетов характеризуется наличием отдельных сообщений.
• SOCK_RAW — сырые сокеты. Данные передаются поверх канального уровня OSI. Они поддерживаются в большинстве операционных систем, включая Windows, начиная с версии XP.
• SOCK_RDM — сокеты, ориентированные на надежную доставку сообщений. Нужны для реализации протоколов 4-го уровня. Они «надежнее, чем UDP, и отзывчивее, чем TCP». Ориентированы на сообщения.
• SOCK_SEQPACKET — обеспечивает последовательную, надежную, двунаправленную передачу записей в режиме соединения. Для семейства интернет-сокетов это протокол SCTP.
• SOCK_DCCP — сокет минимального транспортного протокола общего назначения. Управляет созданием и завершением ненадежного потока пакетов и отслеживанием перегрузок. Заменяет UDP при реализации протоколов трансляции потоковых данных в реальном времени.
• SOCK_PACKET — специфический для Linux способ получения пакетов. Используется разработчиками сетевого стека для написания служебных протоколов, таких как RARP и подобных, на уровне пользователя.
Допустимые типы для сокета зависят от того, в какой домен PF_* он входит.
В семействе PF_INET, например, может использоваться протокол IPPROTO_TCP для типа SOCK_STREAM и IPPROTO_SCTP для SOCK_SEQPACKET. А для SOCK_DGRAM — IPPROTO_UDP или IPPROTO_UDPLITE.
В общем случае в каждом семействе протоколов может быть несколько протоколов для каждого из типов. Но часто одному семейству соответствует один протокол. В семействе PF_UNIX протокол вообще не используется, он всегда равен 0.
Также существуют специальные значения протокола, которые передаются в функцию socket(), описанную ниже. Например, IPPROTO_IP со значением 0. Это значение предполагает разное поведение для разных комбинаций типа сокета и семейства. Например, для комбинации AF_INET/SOCK_STREAM передача нуля заставит стек использовать протокол по умолчанию, что будет эквивалентно IPPROTO_TCP. Для AF_INET/SOCK_DGRAM оно будет эквивалентно IPPROTO_UDP. В raw-сокетах поведение вызова с этим флагом зависит от конкретной ОС.
Помимо обязательных свойств, указываемых при создании, для сокета можно указать множество других параметров, используя функции для их установки, например включение буферизации, работу в неблокирующем режиме, отключение алгоритма Нейгла и т.д.
Параметры можно указывать для всего сокета, а некоторые — для конкретных вызовов функций передачи и приема данных.
Наиболее распространенная и основная функция сокетов — обеспечение интерфейса обмена данными между приложениями. Но важно понимать, что эта функция не единственная и далеко не все типы сокетов используются для этого.
Например:
• Сокеты AF_ALG используются для взаимодействия с Linux Crypto API.
• Сокеты AF_NETLINK используются для взаимодействия с различными подсистемами ядра Linux, обычно связанными с сетевым стеком. При использовании данных сокетов протокол NETLINK_FIREWALL даст возможность получать данные от встроенного межсетевого экрана, а NETLINK_KOBJECT_UEVENT — читать события ядра. На основе этого работает подсистема устройств udev.
• AF_VSOCK обеспечивает взаимодействие гипервизора и виртуальной машины.
Специальных типов сокетов достаточно много. Стоит понимать, что если программа может создавать различные типы сокетов, она может иметь достаточно широкие возможности управления машиной, на которой запущена.
Как правило, в составе большинства современных ОС уже имеется сетевой стек, и он соответствует POSIX. Встречаются также отдельные реализации стека для встраиваемых систем, некоторые из них будут рассмотрены в книге.
Стандарт ориентируется на язык C, поскольку большинство ОС реализовано на данном языке. Но как мы увидим далее, в других языках сокетный интерфейс во многом повторяет его структуру. POSIX-стандарт также распространяется на расположение и названия заголовочных файлов языка C.
В Unix-подобных системах заголовочные файлы, в которых определен сокетный POSIX API, поставляются вместе с ОС:
• sys/socket.h — базовые функции сокетов и основные структуры данных.
• netinet/in.h — семейства адресов/протоколов AF_INET/PF_INET и AF_INET6/PF_INET6. Включают в себя IP-адреса, а также номера портов TCP и UDP.
• sys/un.h — семейство адресов PF_UNIX/PF_LOCAL.
• arpa/inet.h — функции для работы с числовыми IP-адресами.
• netdb.h — функции для преобразования протокольных имен и имен хостов в числовые адреса.
И так далее.
В ОС Windows состав заголовочных файлов отличается, они будут рассмотрены в главе 14. Сейчас достаточно знать, что большинство функций из этой главы доступны через включение заголовочных файлов winsock2.h или winsock.h.
Посмотрим, какие функции включает API сокетов Беркли. В нескольких последующих главах мы будем рассматривать их, а также низкоуровневые API других языков более подробно.
• Функции создания и уничтожения сокета, которые мы рассмотрим в этой главе:
• socket() — создает новый сокет определенного семейства протоколов и адресов, идентифицируемого целым числом, и выделяет ему системные ресурсы.
• close() — закрывает сокет и освобождает выделенные ресурсы. В случае TCP-сокета соединение разрывается. Данная функция сама вызовет функцию shutdown(), если это необходимо.
• shutdown() — корректно закрывает канал для обмена данными в одну или обе стороны.
• Работа с адресами будет рассмотрена в главе 2:
• bind() — связывает сокет со структурой адреса, например с указанным IP-адресом и номером порта.
• gethostbyname(), gethostbyaddr() — разрешение имен и адресов хостов. Применяются только для IP.
• getaddrinfo() — функция, возвращающая список адресов по имени.
• getnameinfo() — функция, возвращающая список имен по адресу.
• Отправка и получение данных, которые мы начнем рассматривать в главе 4:
• send(), sendto(), sendmsg() — отправить данные:
• Функция send() используется, когда адрес привязан к сокету, например, в случае TCP-соединения.
• Функция sendto() используется, когда сокет не ориентирован на соединение, например, для UDP. В случае UDP также может использоваться send(), если предварительно был вызван connect().
• Функция sendmsg() служит для отправки сообщения, заголовок которого предварительно сформирован, что нужно для увеличения производительности, а также отправки специальных данных.
• recv(), recvfrom(), recvmsg() — принять данные:
• Функция recv() используется, когда адрес привязан к сокету, например, в случае TCP-соединения.
• Функция recvfrom() используется, когда сокет не ориентирован на соединение, например, для UDP. Так же как и для send(), для сокетов без установления соединения может быть использован recv().
• Функция recvmsg() аналогична sendmsg(), но работает на прием.
• В POSIX- и BSD-сокетах для отправки и приема данных также можно использовать стандартные функции write() и read(). Но send()/recv() кросс-платформенные и позволяют задать дополнительные опции.
• Оптимизированные функции, такие как sendmmsg(), recvmmsg(), writev(), readv(), sendfile() и пр., мы рассмотрим в книге 2.
• Работа с сокетами, ориентированными на соединение, будет рассмотрена в главе 5:
• connect() — используется на стороне клиента и привязывает сокет к адресу. Присваивает сокету свободный номер локального порта. В случае TCP-сокета это инициирует попытку установить новое TCP-соединение.
• listen() — используется на стороне сервера и указывает сокету, к которому привязан адрес, перейти в состояние прослушивания. Работает не для всех протоколов, но применимо, например, для TCP.
• accept() — используется на стороне сервера, принимает входящую попытку создать новое TCP-соединение от удаленного клиента и создает новый сокет, связанный с соединением.
• Оптимизированные нестандартные функции, такие как connectx(), мы рассмотрим в книге 2.
• Управление сокетами будет рассмотрено в главе 8:
• getsockopt() — получение текущего значения указанной опции сокета.
• setsockopt() — установка конкретной опции сокета.
• В главах 10–11 мы изучим управление сетевыми интерфейсами.
• Асинхронная работа рассматривается преимущественно в книге 2:
• select() — ожидает готовности сокетов из списка к чтению, записи или переходу в состояние ошибки.
• poll() — проверяет состояние сокета в списке: готов ли сокет к записи или чтению данных либо возникла ошибка. Как альтернатива select(), работает быстрее и не ограничивает число дескрипторов в списке. Упрощает проверку сокетов в многопоточных приложениях.
Это не все функции, которые мы будем изучать: для работы с сокетами могут быть использованы функции общего назначения, например функция ioctl() для настройки.
_r функции
В Glibc часто можно встретить функции с суффиксом _r. Например: gethostbyname_r(), gethostbyaddr_r(), strerror_r() и подобные. Суффикс _r — re-entant — означает, что функции, , то есть могут одновременно быть вызваны несколько раз, например из разных потоков.
Glibc создавался в те времена, когда многопоточные приложения еще не были популярны. Функция могла хранить, например, статический буфер, который использовался для формирования результата. Такая функция не была потокобезопасной. Позже для этой проблемы нашли несколько решений. Например, переменная errno хранится в локальном хранилище потока.
Часть функций API, представленного Glibc, получила суффикс _r и дополнительный параметр — буфер, в котором функция формирует результат и который передается ей пользователем.
Начнем подробное рассмотрение функций с создания и уничтожения сокета.
Создать объект сокета можно с помощью функции socket(). Она определена в sys/socket.h.
Прототип функции socket():
// Только для POSIX-совместимых ОС и стеков.
// Для ОС Windows заголовочные файлы другие.
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
Параметры функции socket():
• domain — коммуникационный домен, упомянутый выше;
• type — тип сокета;
• protocol — используемый протокол.
Возвращаемое значение — дескриптор нового сокета либо –1 в случае неудачи. Для ОС Windows это константа INVALID_SOCKET.
В Linux с версии 2.6.27 второй параметр функции socket(), помимо типа протоколов, также позволяет задавать некоторые флаги сокета:
• SOCK_NONBLOCK — установить неблокирующий режим при открытии сокета. О том, что это значит, будет рассказано позже.
• SOCK_CLOEXEC — закрыть дескриптор, если процесс или его потомки выполняют exec() и подобные функции. Предотвращает утечку дескрипторов.
Пример использования этих флагов:
int new_socket = socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK, IPPROTO_UDP);
Python также поддерживает эту возможность, но такой код не обязательно будет кроссплатформенным.
Функция close() закрывает сокет. Эта функция определена в unistd.h:
#include <unistd.h>
int close(int fd);
Здесь fd — дескриптор сокета, который требуется закрыть.
В ОС Windows используется closesocket() из winsock2.h. Про отличия Windows мы поговорим в главе 16.
Вызов функции socket() создает объект ядра «сокет» и, в случае успеха, возвращает его дескриптор — уникальный в рамках процесса идентификатор этого объекта:
extern "C"
{
#ifndef _WIN32
# include <sys/socket.h>
# include <unistd.h>
#else
# include <winsock2.h>
#endif
}
...
// Создать новый UDP-сокет.
int new_socket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
...
#ifdef _WIN32
closesocket(new_socket);
#else
close(new_socket);
#endif
При завершении программы функция close() вызывается автоматически: ОС самостоятельно закроет сокет. Вызов функции close() для сокетов, ориентированных на соединение, инициирует вызов shutdown().
Внимание! Функция close() не так проста, как может показаться. Ее использование предполагает множество нюансов. Для TCP, например, данная функция не завершится сразу и может «зависнуть» на несколько секунд. Мы будем описывать ее особенности по мере освоения порядка работы с протоколами.
Функцию close() нужно вызывать явно — это признак хорошего стиля и предупреждает утечки ресурсов.
Внимание! Функция close() может не завершить соединение и не закрыть сокет, если на соединение будет ссылаться более, чем один дескриптор, например созданного через вызов dup(). В этом случае функция лишь закроет один из дескрипторов.
Теперь посмотрим, как описанное выше реализуется в Python. Сокетный интерфейс содержится в модуле socket, а большая часть его функций инкапсулирована в класс socket. Сокеты в Python являются объектами этого класса:
# Импортировать модуль для работы с сокетами.
import socket
# Создать новый UDP-сокет.
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
# Распечатать объект сокета.
print(s)
# Закрыть сокет.
s.close()
print(s)
Вывод будет следующим:
<socket.socket fd=3, family=AddressFamily.AF_INET, type=SocketKind.SOCK_DGRAM, proto=17, laddr=('0.0.0.0', 0)>
<socket.socket [closed] fd=-1, family=AddressFamily.AF_INET, type=SocketKind.SOCK_DGRAM, proto=17>
Видим, что сокет был успешно создан, после чего успешно закрыт. После закрытия Python установил дескриптор fd в значение –1, которое не является корректным значением дескриптора. Проверив его, можно убедиться, что данный объект не является корректным сокетом. Также явно видно состояние: "[closed]".
В Python создать новый сокет можно и из существующего дескриптора сокета, используя функцию fromfd() или параметр конструктора fileno класса socket.socket:
# Создать объект класса socket из дескриптора сокета.
def fromfd(fd, family, type, proto=0) -> socket
class socket:
def__init__(self, family=-1, type=-1, proto=-1, fileno=None):
...
Внимание! Функция fromfd() продублирует дескриптор сокета, в результате чего закрытие предыдущего дескриптора не вызовет разрыва соединения, если только явно не был вызван метод shutdown().
Внимание! Функция fromfd() доступна не везде, некоторые ОС ее не поддерживают.
Допустима и подобная конструкция:
with socket.socket(
socket.AF_INET,
socket.SOCK_STREAM,
socket.IPPROTO_TCP
) as s:
# Тут что-то делаем с данным сокетом.
s.connect(("www.python.org", 80))
При выходе из блока with сокет будет корректно закрыт.
Объекты класса socket.socket в Python имеют следующие атрибуты:
• family — семейство адресов;
• type — тип сокета;
• proto — протокол.
Эти атрибуты равны значениям, которые были переданы сокету при создании.
В случаях, когда требуется работать только с дескриптором, его можно отвязать от объекта socket.socket, используя метод socket.detach():
def socket.detach() -> int
Функция возвращает дескриптор. После этого вызова объект сокета будет переведен в закрытое состояние. Но в действительности дескриптор закрыт не будет и может продолжать использоваться.
Выше мы показали, что в CPython не стали изобретать новый интерфейс, а просто транслировали интерфейс C на объекты. При этом объектный интерфейс сокетов в Python значительно упрощает работу и является кросс-платформенным.
Большинство функций реализует класс socket из модуля socket. Кроме того, модуль содержит набор констант и несколько функций, не являющихся специфичными для сокета. Их описание будет приведено ниже.
В текущей реализации класс socket — это наследник класса _socket.socket, псевдонима SocketType:
static PyType_Spec sock_spec =
{
.name = "_socket.socket",
.basicsize = sizeof(PySocketSockObject),
.flags = (Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE |
Py_TPFLAGS_HAVE_GC | Py_TPFLAGS_IMMUTABLETYPE),
.slots = sock_slots,
};
В последующих реализациях детали могут измениться.
Класс _socket.socket в CPython реализован на C, он напрямую работает с интерфейсом POSIX-сокетов.
Сам класс является важной встроенной частью языка Python и реализован в файле Modules/socketmodule.c.
Класс SocketType в CPython сгенерирован из расширения Python, реализованного на C, которое работает с интерфейсом POSIX-сокетов. Само расширение поставляется вместе с библиотекой CPython и реализовано в файле Modules/socketmodule.c. Это общий подход для всех языков высокого уровня, кроме C++.
Чтобы компьютеры с разной архитектурой могли согласовать значения целых чисел, состоящие из нескольких байт, необходимо определить порядок их обработки.
Число — абстрактное понятие, и существует много различных представлений чисел, используемых в компьютерах. Например, двоично-десятичный код, или BCD, в котором 4 бита используются для представления цифр от 0 до 9, а некоторые комбинации битов запрещены. В ассемблере процессоров Intel сохранились даже команды для работы с такими числами. Но чаще всего числа представляются как цепочка байтов фиксированного размера — слово, каждый из байтов которого содержит по два шестнадцатеричных разряда.
Порядок чтения этих байтов в слове может быть следующим:
• Big-endian — от старшего байта в слове к младшему, слева направо, или «старший байт по старшему адресу». Например, число, которое пришло как 0x00 0x01 0x02 0x03, в таком порядке будет считано как 0x00010203. Это «прямой порядок».
• Little-endian — от младшего байта в слове к старшему, «обратный порядок», справа налево, или «младший байт по старшему адресу». Число, пришедшее как 0x00 0x01 0x02 0x03, в данном порядке будет считано как 0x03020100.
• Middle-endian — гибридный. Используется для работы с числами, размер которых превышает машинное слово. Порядок байтов зависит от архитектуры, порядок слов обратный. Порядок реализован в ARM для вещественной арифметики и в рамках книги не представляет интереса.
Особенности, характерные для протоколов TCP/IP:
• Строго говоря, эти протоколы не оперируют байтами. Причина в том, что хотя сейчас и принят размер байта в 8 бит, на практике на разных машинах он может различаться. Сетевые протоколы оперируют словами, которые состоят из октетов — 8-битных последовательностей. Размер слова в TCP — 4 октета, или 32 бита.
• Принят прямой порядок октетов, который в этом контексте называется сетевым.
Немного истории байта и бита
На практике, когда люди говорят «байт», они подразумевают «октет». Но слово «байт» (или byte) не означает 8 бит. Изначально это слово «bite» — «кусок», «часть», искаженное для того, чтобы было проще отличать его от bit.
В свою очередь, бит является сокращением от BInary digiT. Это слово использовал Клод Шеннон в работе «Математическая теория связи» 1948 года.
Первые байты появились в 1956 году на машине IBM 7030 Stretch для обозначения пачки битов, передаваемых одновременно. Эти байты состояли из 6 бит.
Второй источник происхождения термина — система SAGE, или Semi-Automatic Ground Environment, — предшественник систем управления NORAD. Над ней в том числе работали сотрудники IBM, так что не исключено, что они пересекались с разработчиками суперкомпьютеров.
Советские ЭВМ, такие как БЭСМ-6 и М-220, также имели байты размером 6 бит. При этом размер машинного слова БЭСМ-6 составлял 48 бит. А в советской ЭВМ «Сетунь» 1959 года, показанной на рис. 1.7, вообще использовались триты — разряды с тремя состояниями — и байты, соответственно, назывались трайтами, имея размер в 9 трит.
Рис. 1.7. Работа на ЭВМ «Сетунь»
Только почти через 10 лет в IBM System/360, выпущенной в 1964 году, стали использоваться 8-битные байты, ставшие со временем стандартом.
Разные процессоры могут работать с разным порядком байтов:
• Процессоры Intel используют little-endian.
• Процессоры ARM тоже работают с little-endian, но могут переключаться на big-endian.
• Процессоры z/Architecture в мейнфреймах IBM, SPARC до версии 9, а также контроллеры Atmel AVR32 используют big-endian.
В случае процессоров говорить о порядке байтов корректно, но это не значит, что размер байта всегда будет составлять октет.
Порядок имеет значение, когда производится интерпретация данных. Например, команда присваивания работает везде одинаково, а для сложения и других команд порядок байтов критичен.
Каждая машина имеет «внутреннюю совместимость», то есть при интерпретации всегда читает собственные записанные данные в корректном порядке. Но это не означает, что машина другого типа интерпретирует эти данные таким же образом.
Платформы MIPS и ARM могут работать как в режиме big-endian, так и в режиме little-endian. Есть разные сборки ОС, например, под один и тот же процессор, работающий в режиме little endian или big endian.
Это нарушает принцип «совместимости с собой». Но в описанном случае абсолютно точно известно, в каком режиме работает машина в данный момент, и эти два режима можно считать просто «разными типами» машин.
Поэтому число во внутреннем представлении узла, то есть байты которого хранятся в порядке данных узла или хостовом порядке, всегда нужно преобразовывать в сетевой порядок до того, как данные будут переданы сетевым функциям. А принятые из сети числа, которые закодированы в сетевом порядке, нужно преобразовывать в порядок узла до выполнения над ними каких-либо действий.
Чтобы узнать порядок байтов на машине, достаточно прочесть младший байт числа, в котором старший и младший байты разные.
Для этого напишем простой код:
#include <iostream>
#include <cstdint>
intmain ()
{
// Двухбайтовое значение: один байт — 0x00, второй байт — 0x01.
uint16_t x = 0x0001;
// Приведение адреса значения к указателю на байт.
// Указатель всегда будет содержать адрес первого (младшего) байта.
// В big-endian-машине это байт по младшему адресу.
// В little-endian — наоборот.
// Затем по значению байта (0 или 1) можно определить,
// какой из двух байтов выделен.
std::cout
<< (*(reinterpret_cast<uint8_t*>(&x)) ? "little" : "big")
<<"-endian"
<<std::endl;
}
Результат для процессора Intel Core i5:
➭ g++ src/book01/ch01/cpp/test_byte_order.cpp -o test_byte_order && \
./test_byte_order
little-endian
На практике, чтобы преобразовать данные из сетевого порядка в порядок обрабатывающей его машины и обратно, существуют функции преобразования.
Внимание! Даже если в конкретной машине используется прямой порядок байтов в слове, вызывать функцию преобразования необходимо, чтобы написанный код был переносимым. В данном конкретном случае вызванная функция преобразования не будет ничего делать.
Функции описаны в заголовочных файлах endian.h, netinet/in.h или arpa/inet.h:
#include <arpa/inet.h>
// Конвертирует 32-битную беззнаковую величину из локального порядка байтов
// в сетевой.
// Название функции — аббревиатура от Host TONetwork Long.
uint32_t htonl(uint32_t hostlong);
// Конвертирует 16-битную беззнаковую величину из локального порядка байтов
// в сетевой.
// Аббревиатура от Host TONetwork Short.
uint16_t htons(uint16_t hostshort);
// Конвертирует 32-битную беззнаковую величину из сетевого порядка байтов
// в локальный.
// Аббревиатура от Network TOHost Long.
uint32_t ntohl(uint32_t netlong);
// Конвертирует 16-битную беззнаковую величину из сетевого порядка байтов
// в локальный.
// Аббревиатура от Network TOHost Short.
uint16_t ntohs(uint16_t netshort);
Для сетевого порядка в протоколах стека TCP/IP указано, что длина слова составляет 4 байта, или 32 бита, поэтому функции преобразования определены максимум для 32-битных слов, и это htonl().
В Linux реализован гораздо более широкий набор функций преобразования, в том числе функции для 64-битных слов:
#include <endian.h>
uint16_t htobe16(uint16_t host_16bits);
uint16_t htole16(uint16_t host_16bits);
uint16_t be16toh(uint16_t big_endian_16bits);
uint16_t le16toh(uint16_t little_endian_16bits);
uint32_t htobe32(uint32_t host_32bits);
uint32_t htole32(uint32_t host_32bits);
uint32_t be32toh(uint32_t big_endian_32bits);
uint32_t le32toh(uint32_t little_endian_32bits);
uint64_t htobe64(uint64_t host_64bits);
uint64_t htole64(uint64_t host_64bits);
uint64_t be64toh(uint64_t big_endian_64bits);
uint64_t le64toh(uint64_t little_endian_64bits);
А функции типа hton()/ntoh() реализованы через htobe()/betoh():
# if __BYTE_ORDER == __BIG_ENDIAN
/* Порядок байтов узла такой же, как и сетевой порядок байтов, поэтому все эти функции просто возвращают значение аргумента.*/
# define ntohl(x)__uint32_identity (x)
# define ntohs(x)__uint16_identity (x)
# define htonl(x)__uint32_identity (x)
# define htons(x)__uint16_identity (x)
# else
# if __BYTE_ORDER == __LITTLE_ENDIAN
# define ntohl(x) __bswap_32 (x)
# define ntohs(x) __bswap_16 (x)
# define htonl(x) __bswap_32 (x)
# define htons(x) __bswap_16 (x)
# endif
# endif
#endif
Пример вызова функции в C и C++:
#include <arpa/inet.h>
...
uint32_t net_order_var;
...
uint32_t host_order_var = ntohl(net_order_var);
Пример для Python:
import socket
# Результат: 36895
print(socket.htons(8080))
# Результат: 2417950720
print(socket.htonl(8080))
Внимание! Обратите внимание, что значения разные и зависят от предполагаемого размера числа, что нехарактерно для Python.
В ОС Linux и прочих Unix-подобных ОС о порядке байтов можно почитать в man:
man byteorder
man endian
man endianness
Если преобразование на стороне отправителя не выполнено, чтобы определить порядок байтов в данных, полученных из сети, можно добавить к отправляемым данным несколько байт, например b'1100101110111100'.
Ответ 0xCBBC означает, что порядок байтов на отправителе и получателе одинаковый, а ответ 0xBCCB — что данные отправлены в другом порядке и потребуется конвертация, то есть читать отправленные числа необходимо с конца.
Чтобы не думать об этом каждый раз, существуют библиотеки сериализации, которые мы рассмотрим позднее, в первых главах книги 3.
В MacOS X также реализован свой набор операций для работы с порядком байтов. Их объявления находятся в файле libkern/OSByteOrder.h:
OSSwapHostToBigInt16();
OSSwapHostToLittleInt16();
OSSwapBigToHostInt16();
OSSwapLittleToHostInt16();
OSSwapHostToBigInt32();
OSSwapHostToLittleInt32();
OSSwapBigToHostInt32();
OSSwapLittleToHostInt32();
OSSwapHostToBigInt64();
OSSwapHostToLittleInt64();
OSSwapBigToHostInt64();
OSSwapLittleToHostInt64();
Свои функции конвертации есть и в ОС Windows.
Зачастую, чтобы скрыть различия между ОС, создают и добавляют заголовочный файл portable_endian.h, в котором определяются макросы, имеющие одно и то же имя, но реализованные через функции, предоставленные данной ОС.
API сокетов в разных ОС не полностью совместимы, и для упрощения работы и минимизации повторений в книге реализована минималистичная обертка, которая скрывает некоторые детали реализации, например инициализацию сокетной подсистемы в ОС Windows. Эту обертку мы используем в коде нескольких первых глав далее. Она реализована как отдельная библиотека socket_wrapper и включает следующее:
• Класс SocketWrapper с реализацией под Unix и под Windows. Класс скрывает инициализацию для ОС Windows, а также предоставляет универсальный интерфейс для получения сообщений об ошибках.
• Класс Socket — класс RAII, инкапсулирующий дескриптор сокета. Он требуется, чтобы скрыть различия в закрытии сокета.
• Несколько полезных функций, назначение которых будет подробнее рассмотрено позже:
• Получение адресной информации для сервера — get_serv_info().
• Разрешение адреса для клиента — get_client_info().
• Различные функции установки опций, например set_reuse_addr().
• Функция приема нового клиента для TCP-серверов: accept_client().
• Функция для создания типового сервера TCP/IP: create_tcp_server().
В конструкторе класс Socket вызывает функцию socket, в деструкторе — закрывает сокет:
namespace socket_wrapper
{
// Конструктор.
Socket::Socket(int domain, int type, int protocol) :
socket_descriptor_(INVALID_SOCKET)
{
open(domain, type, protocol);
}
// Конструктор из дескриптора.
Socket::Socket(SocketDescriptorType socket_descriptor) :
socket_descriptor_(socket_descriptor)
{
}
// Конструктор перемещения.
Socket::Socket(Socket &&s)
{
socket_descriptor_ = s.socket_descriptor_;
s.socket_descriptor_ = INVALID_SOCKET;
}
void Socket::open(int domain, int type, int protocol)
{
if (opened()) close();
// Создать новый сокет.
socket_descriptor_ = socket(domain, type, protocol);
}
...
Конструктор копирования удален, чтобы сокет нельзя было случайно закрыть два раза.
Для разных операционных систем обертка реализована по-разному. Например, метод Socket::close() в POSIX-совместимых ОС вызовет функцию close(), а в ОС Windows — функцию closesocket():
#ifdef _WIN32
# define close_socket closesocket
#else
# define close_socket ::close
#endif
namespace socket_wrapper
{
...
int Socket::close()
{
// Закрыть сокет.
const int status = close_socket(socket_descriptor_);
// После закрытия сделать атрибут дескриптора некорректным.
socket_descriptor_ = INVALID_SOCKET;
// Метод должен вести себя как функция close().
return status;
}
// В деструкторе сокет будет закрыт.
Socket::~Socket()
{
if (opened()) close();
}
Также класс предоставляет сокетный дескриптор, когда используется в стандартных функциях сокетного API. В случае проверки в условии класс будет преобразован в true, если сокет корректно открыт, то есть содержит корректный дескриптор, иначе — в false. Socket sock = socket(...) легко заменяется на int sock = socket(...):
class Socket final
{
public:
Socket(int domain, int type, int protocol);
explicitSocket(SocketDescriptorType socket_descriptor);
Socket(const Socket &) = delete;
Socket(Socket &&s);
Socket &operator=(const Socket &s) = delete;
Socket &operator=(Socket &&s);
~Socket();
public:
// Проверка на то, что сокет открыт.
bool opened() const noexcept;
public:
// Приведение к bool выполнит проверку.
explicit operator bool() const noexcept { return opened(); }
// Приведение к int вернет дескриптор.
operator SocketDescriptorType() const noexcept
{ return socket_descriptor_; }
public:
int close() noexcept;
private:
void open(int domain, int type, int protocol);
private:
// Значение INVALID_SOCKET разное для POSIX и Windows.
SocketDescriptorType socket_descriptor_{INVALID_SOCKET};
};
Различные константы определены в файле socket_headers.h, например, так определен макрос INVALID_SOCKET:
// Проверка нужна, так как для Windows макрос уже определен.
#if !defined(INVALID_SOCKET)
# define INVALID_SOCKET (-1)
#endif
#if !defined(SOCKET_ERROR)
# define SOCKET_ERROR (-1)
#endif
Там же определен тип SocketType, который для ОС Windows будет аналогичен SOCKET, а для Unix-подобных систем — int. Читатель может убедиться в этом самостоятельно, изучив обертку в репозитории книги.
Подробнее работу обертки мы разберем в следующих главах. Например, инициализация в ОС Windows будет рассмотрена в главе 13. Кроме того, на примере данной обертки должно стать понятно, как функционируют и создаются библиотеки.
Можно использовать Libsocket, Sockpp или другую библиотеку C++, которая скрывает особенности работы с сокетами.
Однако, во-первых, на данном этапе нам достаточно простой обертки.
Во-вторых, на этапе обучения полезнее опираться на стандарт, чтобы знать API без прослойки библиотек и понимать, как работает сокетный API. Наша обертка не скрывает эту информацию, убирая «под капот» лишь некоторые рутинные операции.
Сетевое программирование — это реализация обмена данными по сети между физическими устройствами, приложениями и пользователями.
Обмен данными принято называть сетевым взаимодействием. Оно представлено стеком уровней, который в компьютерных сетях описывается такими моделями, как OSI, DoD, модель Танненбаума, модель Cisco Academy.
Главное свойство стека уровней состоит в том, что на каждом уровне к данным PDU от предыдущего уровня добавляются служебные данные, образуя SDU. Таким образом каждый уровень создает «обертку» из служебных данных, содержащую особенности, удовлетворяющие новым требованиям, вводимым уровнем.
На разных уровнях модели имеется своя адресация. Для IP-протокола это IP-адреса узлов и сетей, для Ethernet — МАС-адреса, для TCP и UDP — идентификаторы портов. Для каждого уровня с адресацией может быть свой граф и своя топология.
Большую часть внимания в книге мы уделим изучению и использованию того, что уже реализовано на сетевом, транспортном уровнях OSI и выше. Мы изучим API и библиотеки, а также выполним реализации своих простых протоколов прикладного уровня.
Дополнительная и справочная информация о сетевом программировании доступна в RFC, на страницах man и info для Linux-подобных ОС, в MSDN — для ОС Windows и на таких ресурсах, как StackOverflow.
В сетевом программировании процессы используют сокеты — фундаментальную технологию, на которой основан интернет. Технология сокетов была разработана в Университете Беркли и стандартизирована IEEE в рамках POSIX — Portable Operating System Interface.
Для прикладного разработчика сокет — это самый низкоуровневый интерфейс. Во многих случаях его использование в приложении неоправданно. Вместо него рекомендуется использовать библиотеки, авторы которых уже решили множество проблем и позаботились о нюансах, которые неопытный программист может не учесть. Однако для уверенного использования библиотек этот интерфейс необходимо знать. Кроме того, сетевые возможности библиотек различных языков программирования, как правило, тоже используют сокеты.
Сокеты имеют несколько важных характеристик: семейство протоколов и адресов, тип, используемый протокол и опции. Связываться друг с другом могут только те сокеты, чьи основные характеристики совпадают.
Сокет создается функцией socket(), а закрывается функцией close().
Данные в сокетах при использовании стека TCP/IP передаются с использованием прямого или сетевого порядка байтов. Независимо от того, какой порядок байтов используется на вашем компьютере, перед записью данных в сокет нужно вызывать функции преобразования в сетевой порядок.
Хотя все современные операционные системы так или иначе реализуют POSIX-совместимый интерфейс сокетов, API в разных ОС не полностью совместимы. Поэтому для упрощения работы в книге реализована обертка, которая скрывает некоторые детали реализации, например инициализацию сокетной подсистемы в ОС Windows. Эту обертку мы будем использовать в примерах будущих глав. Она реализована как отдельная библиотека socket_wrapper.
1. Что такое сетевое программирование и какова его основная цель?
2. Как можно представить среду для связи в контексте сетевого программирования?
3. Назовите несколько моделей сетевого взаимодействия. Чем они различаются? Какие еще имеются модели, кроме перечисленных в этой главе?
4. Почему часто прикладной и сеансовый уровень и уровень представления модели OSI объединяют в один?
5. Что такое PDU и SDU? В чем их отличие?
6. Приведите несколько примеров протоколов разного уровня.
7. Для чего нужны адреса? Какие бывают адреса?
8. Что такое RFC и каково его значение в контексте стандартов интернет-технологий?
9. Перечислите другие источники информации о сетевом программировании.
10. Для чего нужны разделы в man? Как вывести справку из определенного раздела?
11. Всегда ли стоит доверять решениям на StackOverflow?
12. Что такое клиент-серверная модель? Какие задачи выполняет сервер? Для чего нужен клиент?
13. В чем отличие приложений, написанных для клиента и сервера?
14. Что такое сокеты и для чего их можно использовать?
15. Как правильно включить заголовочный файл socket.h?
16. Каковы свойства сокетов?
17. Как создать новый сокет?
18. Как закрыть сокет? Могут ли в процессе закрытия возникнуть проблемы? Если да, то какие?
19. Почему API сокетов может различаться в разных операционных системах?
20. Что представляет собой сокет в Python?
21. Что такое сетевой и хостовой порядок байтов?
22. Для чего и когда следует вызывать функции преобразования в сетевой порядок на компьютерах?
23. Почему в своих приложениях лучше использовать готовые библиотеки?
24. Для чего мы реализуем собственную обертку над сокетным интерфейсом?
25. Самостоятельно создайте и закройте сокет TCP с помощью обертки socket_wrapper и без нее.
)