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

Глава 5. Соединение. Потоковый обмен данными. Серверный API

Порядок освобождает мысль.

Рене Декарт

Введение

Пример из предыдущей главы с сервером был реализован через обмен сообщениями по UDP. Утилита ping — через обмен сообщениями напрямую, поверх IP. Эти протоколы работают по простому принципу «отправил и забыл».

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

Мы рассмотрим, какие есть транспортные протоколы и каковы различия между ними. Затем изучим серверный и клиентский API, необходимый для работы соединения, научимся передавать данные с помощью send() и recv(). Далее покажем типовые алгоритмы работы клиента и сервера для сокетов, ориентированных на соединение, и коснемся некоторых деталей, таких как прерывание сокетных функций сигналом в Unix-подобных ОС.

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

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

Сокеты, ориентированные на соединение

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

У каждого сегмента есть свой номер — это позволяет восстанавливать данные именно в том порядке, в каком они были отправлены, даже если они были разбиты на разные IP-пакеты.

«Соединение» означает буквально следующее:

• Данные передаются до того момента, пока гарантированно не будут приняты.

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

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

Каждое соединение требует процедуры его установки. Соединение может быть разорвано, если данные не поступают слишком долго, что зависит от протокола. Часто используется механизм keep-alive — отправка пакетов по каналу для проверки того, что данные могут проходить.

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

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

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

TCP может работать, если потери не превышают 12 %. Для беспроводных сетей, например Wi-Fi, считается нормальным 0,6–2 % потерь, а процент выше 10 говорит о наличии проблем в сети.

При использовании сокетов, ориентированных на соединение, канал может быть установлен вызовом connect() на клиенте. После вызова connect() необходимости в использовании функций sendto() и recvfrom() нет, поскольку адреса уже зафиксированы в соединении.

Для обмена данными используются функции send() и recv(), не требующие указания адреса.

Сокеты, ориентированные на потоковый обмен

Важным отличием TCP от UDP является то, что TCP обеспечивает поток данных. Иными словами, отправка порции данных через send() не означает, что придет именно эта порция: данные могут буферизоваться на передающей или принимающей стороне и идти разными путями.

В случае UDP или SCTP, если вы последовательно отправите серверу слова «hello» и «exit», вам придет дейтаграмма, содержащая «hello», и дейтаграмма, содержащая «exit». В SCTP так произойдет, если данные отправлены в пределах одного потока.

В случае TCP вызов recv() может вернуть что угодно: как «hello» и «exit», так и «he», «llo», «exi», «t» или даже «helloexit». В общем случае невозможно предсказать, какой результат будет возвращен клиенту за один вызов приема данных.

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

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

Различия транспортных протоколов

Посмотрим на три разных протокола транспортного уровня:

Протокол/Особенность протокола

UDP

TCP

SCTP

Соединение, то есть хранение состояния у каждого абонента

нет

да

да

Надежная передача данных

нет

да

да

Сохранение границ сообщений

да

нет

да

Упорядоченная доставка

нет

да

да, в рамках потока

нет, между потоками

Понятно, что протокол, ориентированный на соединение, не всегда предполагает передачу потока данных. Но протокол, ориентированный на поток, обычно предполагает, что соединение будет установлено, так как для организации потока необходимо хранение состояния на обоих узлах. Разумеется, рассматривается сеть, в которой выполняется маршрутизация, а не канал «точка-точка».

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

Различия между протоколами

Особенность протокола

UDP

TCP

SCTP

Управление потоком и перегрузками

нет

да

да

Выявление оптимального размера PDU — Path MTU discovery

нет

да

да

Пакетирование сообщений в поток — Message Bundling

нет

да

да

Поддержка множественной адресации узла — multi-homed hosts

нет

нет

да

Поддержка нескольких потоков в рамках соединения

нет

нет

да

Cookie безопасности для защиты от SYN-flood атак

нет

нет

да

Встроенная проверка доступности абонента — heart-beat/keep-alive

нет

нет

да

Из-за указанных различий для разных типов сокетов сокетный API, представленный в библиотеке, несколько различается. Однако обычно все они представлены вызовами send()/sendto(), recv()/recvfrom(), набором опций и некоторыми другими функциями, общими для всех типов сокетов.

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

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

Более подробно эти механизмы рассматриваются в литературе по сетям.

Часть функций может быть реализована в системной библиотеке, такой как LibC. Например, вызов sendto(). Часто он реализуется в библиотеке, а не в ядре, и если функция вызывается для TCP-сокетов, библиотека обеспечивает проверку адреса и вызов connect() при изменении адреса. Затем будем вызван обычный send().

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

API сокетов, ориентированных на соединение

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

Установка соединения

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

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

Схемы взаимодействия различных абонентов значительно сложнее и разно­образнее. Но о них мы будем говорить позже. Сейчас термины «клиент» и «сервер» используются только в контексте сокетного API.

На рис. 5.1 видим, как отображается TCP-протокол на API, который подробно рассмотрим ниже.

Рис. 5.1. Клиент-серверное взаимодействие

Если соединение прошло успешно, будет выполнена процедура троекратного рукопожатия TCP:

1. Запрос SYN от клиента.

2. Сервер вернет SYN, ACK.

3. На что клиент снова ответит ACK.

После этого оба конца соединения будут открыты и готовы к обмену данными.

Если же сервер на данном адресе или порту не прослушивает, стек ОС вернет RST, ACK.

Функции клиента

Клиенту после определения адреса сервера необходимо вызвать функцию connect() на сокете. После этого он может начинать обмен данными, используя функции send() и recv().

Функция connect()

Устанавливает соединение для сокета, ориентированного на соединение. Для сокетов без соединения устанавливает или сбрасывает адрес.

Если применить connect() для сокетов, не ориентированных на соединение, например для UDP-сокета в PF_INET, соединение не установится, но произойдет следующее:

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

Появится возможность использовать функции без указания адреса: send() и recv().

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

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

Функция объявлена в sys/socket.h или winsock2.h в Windows.

Прототип:

int connect(int socket, const struct sockaddr *address,

            socklen_t address_len);

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

socket — дескриптор сокета;

• address — адрес для подключения;

address_len — длина структуры адреса: sizeof(address).

При ошибке функция вернет –1, а сокет перейдет в неопределенное состояние. В случае удачи функция вернет 0.

Сокеты в доменах UNIX и Internet, а также некоторых других могут разорвать ассоциацию путем подключения к адресу с членом sa_family в sockaddr, равным AF_UNSPEC.

После этого сокет может быть подключен к другому адресу. В Linux параметр AF_UNSPEC поддерживается, начиная с ядра 2.2.

Это верно именно для ассоциаций, например для UDP-сокетов. В случае же сокетов, которые реально ориентированы на подключения, будет получена ошибка «Transport endpoint is already connected».

Пример:

// Потоковый сокет и протокол – TCP.

int sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

const std::string host_name = "192.168.1.1";

auto addrs = socket_wrapper::get_client_info(host_name, "5000", SOCK_STREAM);

 

// Подключение к TCP-сокету по адресу 192.168.1.1 к порту 5000.

// connect() принимает структуру sockaddr.

if (connect(sock, addrs->ai_addr, addrs->ai_addrlen) != 0)

{

    return EXIT_FAILURE;

}

Так как для адресов используются разные структуры, например sockaddr_in для IPv4 и sockaddr_in6 для IPv6, а функция connect() использует свой тип sockaddr, иногда требуется переопределение типа через reinterpret_cast.

В структуре addrinfo содержится поле ai_addr, которое имеет тип sockaddr и может быть передано в функцию без преобразования типов.

Для Python функция реализована как методы класса socket.socket:

def connect(self, address: _Address | bytes) -> None

def connect_ex(self, address: _Address | bytes) -> int

Первая в случае неудачи сгенерирует исключение ConnectionRefusedError: "[Errno 111] Connection refused". Вторая будет возвращать код ошибки. Не –1, как в C API, а именно код, записываемый C API в переменную errno, например 111 для ConnectionRefusedError.

Эти функции принимают адрес либо как кортеж, структура которого зависит от протокола, либо как число, полученное от функций, подобных inet_pton():

with socket.socket(

    socket.AF_INET,

    socket.SOCK_STREAM,

    socket.IPPROTO_TCP

) as s:

    # Для IPv4 адрес – IP либо имя хоста и порт

    s.connect(('google.com', 80))

    print(s)

Будет выведен следующий результат:

<socket.socket fd=3, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=6, laddr=('192.168.2.13', 60664), raddr=('173.194.222.100', 80)>

Видно, что соединение установлено успешно и заданы адрес и порт сервера и клиента.

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

Внимание! Поскольку в C-реализации метода для INET-сокетов выполняется разрешение адресов через inet_pton() и getaddrinfo(), обе функции могут генерировать исключения, например socket.gaierror и некоторые системные классы OSError.

Помимо этого, в Python есть функция create_connection(), вызывающая getaddrinfo() внутри себя:

def create_connection(address, timeout=_GLOBAL_DEFAULT_TIMEOUT,

                      source_address=None, *, all_errors=False)

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

address — адрес сервера для подключения. Представляет собой кортеж для IPv4 из адреса и порта.

• timeout — опциональный тайм-аут, превышение которого вызовет исключение, например OSError 101 «Network is unreachable». Если он не задан, функция будет ожидать неограниченное время.

• source_address — адрес сокета клиента. Если он задан, с ним будет выполнена привязка bind() на сокете клиента. Это полезно, если узел имеет несколько адресов, но сокет надо привязать только к одному из них. Например, обмен данными будет идти через сетевой интерфейс, соответствующий этому адресу.

all_errors — если соединение нельзя установить, по умолчанию генерируется исключение для последнего адреса в списке. Если параметр истинный, будет сгенерирована группа исключений, содержащая все ошибки. Этот параметр появился в Python 3.11.

В большинстве случаев данную функцию удобно использовать для создания подключений.

Функции сервера

Алгоритм работы серверов, ориентированных на соединение, несколько сложнее, чем алгоритм реализованного нами ранее простого UDP-сервера.

Кроме того, API предполагает одновременную работу с большим количеством клиентов.

Создание простейшего сервера включает следующие шаги:

1. Создать новый сокет вызовом функции socket(). После этого будет создан объект ядра «сокет» и выбран протокол, который обеспечит взаимодействие.

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

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

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

5. Обменяться данными с клиентом, используя send() и recv(). Эти функции будут работать с новым сокетом, который вернул accept(). Сокет, на котором был вызван listen(), будет ожидать новые соединения.

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

Работа такого сервера показана на рис. 5.2.

Сначала вспомним, как используется ранее описанная функция getaddrinfo() для сервера.

Обратите внимание на флаги:

addrinfo hints =

{

    // Данный флаг говорит о том, что нам требуются адреса, подходящие

    // для функции bind().

    // Он будет работать, так как здесь явно не задано поле ai_addr.

    .ai_flags = AI_PASSIVE,

 

    // Далее, как обычно, указываются: семейство, тип, протокол...

    .ai_family = AF_INET,

    .ai_socktype = SOCK_STREAM,

    .ai_protocol = IPPROTO_TCP

};

 

addrinfo *s_i;

 

if (int ai_status = getaddrinfo(nullptr, port, &hints, &s_i); ai_status != 0)

{

    throw std::logic_error(gai_strerror(ai_status));

}

Главное — указание флага AI_PASSIVE и отсутствие доменного имени, первого параметра функции getaddrinfo().

Внимание! Если передать имя узла вместе с флагом AI_PASSIVE, флаг будет проигнорирован, а функция сработает как для клиента.

Также полезно указать флаг AI_NUMERICSERV, если используется числовое представление службы, то есть номер порта. Подробности см. в главе 2.

Рис. 5.2. Клиент-серверный обмен

Далее рассмотрим функции, которые используются в реализации сервера.

Функция listen()

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

В ядре Linux, например, создание очереди соединений выполняется функцией inet_csk_listen_start(), вызываемой из inet_listen().

Функция объявлена в sys/socket.h или winsock2.h для Windows.

Прототип функции:

#include <sys/socket.h>

 

int listen(int socket, int backlog);

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

socket — дескриптор сокета.

backlog — желательный размер очереди подключений.

Параметр backlog в POSIX — это «подсказка» для реализации, поэтому нельзя гарантировать, что очередь будет именно того размера, который указан. Если размер меньше или равен 0, устанавливается некоторое минимальное значение по умолчанию, если же задать backlog выше максимально допустимого, он будет урезан до максимального.

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

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

Параметр backlog  

Если backlog превышен, в ОС Windows сервер отбросит соединение.

В Linux и некоторых других Unix-подобных ОС система просто отбросит SYN.

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

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

В Linux и в ОС Windows есть константа SOMAXCONN, которая определяет размер очереди.

В Windows можно задать большее значение, используя макрос SOMAXCONN_HINT, о чем см. в .

В Linux этот лимит настраиваемый. По умолчанию — 4096 в ядрах с версии 5.4 и 128 в более старых версиях. Посмотреть и установить его можно через ProcFS в файле /proc/sys/net/core/somaxconn.

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

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

Рассмотрим, как создать базовый TCP-сервер, на примере функции обертки socket_wrapper::create_tcp_server():

Socket create_tcp_server(const std::string &port)

{

    // Получить адресную информацию.

    // Обычно для INADDR_ANY или "0.0.0.0".

    // С таким адресом сервер будет прослушивать на всех адресах/интерфейсах.

    const auto servinfo = get_serv_info(port);

 

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

    Socket server_sock = {servinfo->ai_family, servinfo->ai_socktype,

                          servinfo->ai_protocol};

 

    if (!server_sock)

    {

        throw std::system_error(errno, std::system_category(), "socket");

    }

 

    // Установить опцию сокета SO_REUSEADDR. Про эту опцию читайте в главе 8.

    set_reuse_addr(server_sock);

 

    // Перед вызовом необходимо привязать адрес прослушивания для сервера.

    if (-1 == bind(server_sock, servinfo->ai_addr, servinfo->ai_addrlen))

    {

        throw std::system_error(errno, std::system_category(), "bind");

    }

    // Включить прослушивание – сокет будет ожидать подключения.

    // Вызов listen() просто изменит флаг на сокете.

    // Он не блокирует выполнение, и работа продолжится.

    if (-1 == listen(server_sock, SOMAXCONN))

    {

        throw std::system_error(errno, std::system_category(), "listen");

    }

 

    return server_sock;

}

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

def listen(self, __backlog: int) -> None

Параметр __backlog является необязательным.

Использовать метод просто:

import socket

 

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP)

s.bind(('', 9080))

s.listen(10)

Функция accept()

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

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

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

Новый сокет будет новым объектом ядра и будет иметь новый дескриптор. Через этот сокет будет проходить общение с подключившимся клиентом.

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

Функция объявлена в sys/socket.h или winsock.h для Windows.

Прототип функции:

int accept(int socket, struct sockaddr *restrict address,

           socklen_t *restrict address_len);

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

socket — прослушивающий сокет, на который пришел запрос на соединение.

• address — адрес структуры sockaddr, в которую вернутся данные адреса нового сокета. Может быть nullptr, если адрес подключившегося клиента неинтересен.

• address_len — длина структуры: sizeof(address) или 0, если адрес не был передан.

Внимание! В параметре address_len функция accept() возвращает значение. Если предоставленный буфер для адреса слишком мал, будет возвращено большее значение, чем было передано при вызове. Адрес в этом случае будет усечен.

Кроме того, в Linux существует функция accept4(), которая должна стать частью POSIX, но сейчас доступна как GNU-расширение в Linux:

// Чтобы функция стала доступна, этот макрос надо определить

// до включения файлов заголовков.

#define _GNU_SOURCE

#include <sys/socket.h>

 

int accept4(int sockfd, sockaddr *restrict addr,

            socklen_t *restrict addrlen, int flags);

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

0 — функция полностью аналогична функции accept().

• SOCK_NONBLOCK — открыть сокет в неблокирующем режиме. Использование этого флага избавляет от дополнительных вызовов функций для установки неблокирующего режима. Подробнее о них — в главах 8 и 10 и в книге 2.

SOCK_CLOEXEC — установить флаг FD_CLOEXEC для нового файлового дескриптора. Требуется, чтобы предотвратить утечку дескрипторов в многопроцессных приложениях. Об этом подробнее будет рассказано в главе 24.

Код для приема клиента достаточно типовой, и поэтому мы для этой задачи в обертке реализовали отдельную функцию socket_wrapper::accept_client():

Socket accept_client(socket_wrapper::Socket &server_sock)

{

    // Почему здесь sockaddr_storage, рассказано далее – в разделе об IPv6.

    sockaddr_storage client_addr;

    socklen_t client_addr_length = sizeof(client_addr);

 

    // Принять клиентское подключение.

    Socket client_sock(accept(server_sock,

                              reinterpret_cast<sockaddr *>(&client_addr),

                              &client_addr_length));

 

    if (!client_sock)

    {

        throw std::system_error(errno, std::system_category(), "accept");

    }

 

    assert(sizeof(sockaddr_in) == client_addr_length);

    std::array<char, INET_ADDRSTRLEN> addr;

 

    // Напечатать IP-адрес клиента.

    std::cout << "Client from "

        << inet_ntop(AF_INET,

                     &(reinterpret_cast<const sockaddr_in *const>(

                         &client_addr)->sin_addr),

                     &addr[0], addr.size())

        << "..." << std::endl;

 

    // Вернуть клиентский сокет.

    return client_sock;

}

Вызывается эта функция в простейшем случае обычно так:

// Рабочий цикл сервера.

while (true)

{

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

    // Вызов accept() блокирует выполнение до момента подключения клиента.

    auto client_sock = accept_client(listen_sock);

 

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

}

В Python метод socket.accept() возвращает новый объект socket.socket и адрес подключившегося клиента:

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:

    # Ожидать подключения на любом адресе на порту 5000.

    s.bind(('', 5000))

    s.listen(socket.SOMAXCONN)

    conn, addr = s.accept()

    # Чтобы автоматически закрыть новый сокет,

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

    with conn:

        print('Connected by', addr)

Метода, подобного функции accept4(), в Python нет, и необходимые флаги можно задать при создании объекта сокета.

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

Реализация socketpair() в Python

На возможности мгновенного возврата из accept(), если незадолго до ее вызова было выполнено соединение, основана TCP/IP-реализация функции socketpair() в стандартной библиотеке Python.

Эта реализация используется, если рассмотренная в главе 3 функция socketpair() на данной платформе недоступна, к примеру, не поддерживается семейство PF_UNIX.

Рассмотрим ее. Сначала в функции создается прослушивающий сокет:

# Если отсутствует функция socketpair() в бинарном модуле.

# if not hasattr(_socket, "socketpair") ...

 

def socketpair(family=AF_INET, type=SOCK_STREAM, proto=0):

    if family == AF_INET:

        host = _LOCALHOST

    elif family == AF_INET6:

        host = _LOCALHOST_V6

    else:

        # Вероятно, если socketpair() нет в библиотеке, Unix-domain-сокеты

        # в этой системе не поддерживаются.

        # INET-сокеты накладывают дополнительные расходы и более

          медленные.

        # Зато функция будет работать.

        raise ValueError("Only AF_INET and AF_INET6 socket address "

                         "families are supported")

 

    if type != SOCK_STREAM:

        raise ValueError("Only SOCK_STREAM socket type is supported")

 

    if proto != 0:

        raise ValueError("Only protocol zero is supported")

 

    # Cоздать TCP-сокет.

    lsock = socket(family, type, proto)

    try:

        # Сокет привязан к адресу localhost и будет слушать только интерфейс

        # локальной петли: извне к нему подключиться нельзя.

        # Нулевой порт означает случайный выбор порта для прослушивания.

        lsock.bind((host, 0))

        # Первый сокет – TCP-сервер, который ожидает подключения.

        lsock.listen()

 

        # Узнать, на каком порту сервер ожидает подключений.

        # Для IPv6 игнорировать последние два параметра:

        # flow_info и scope_id

        addr, port = lsock.getsockname()[:2]

 

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

        # Сокет клиента.

        csock = socket(family, type, proto)

        try:

            # Если connect() не сможет подключиться сразу, он сгенерирует

            # исключение.

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

            # зависаний на разных платформах, так как лучше получить

            # неработающий канал, чем зависание по непонятным причинам.

            # Но скорее всего, connect() пройдет сразу.

            csock.setblocking(False)

            try:

                # TCP-клиент подключается к серверу.

                csock.connect((addr, port))

            except (BlockingIOError, InterruptedError):

                pass

 

            # Этот вызов снова устанавливает блокирующий режим,

            # который является нормальным поведением сокета.

            csock.setblocking(True)

 

            # Вызов accept() вернет новый сокет для подключившегося

            # клиента. Причем сделает это сразу, так как подключение уже

            # будет в очереди.

            ssock, _ = lsock.accept()

        except:

            csock.close()

            raise

    finally:

        # Первый сокет, на котором выполнялось прослушивание,

        # больше не требуется.

        lsock.close()

 

    # Возврат пары сокетов.

    return (ssock, csock)

 

__all__.append("socketpair")

Это работает следующим образом:

1. Сначала в функции создается TCP/IP-сервер, который ожидает подключения от единственного клиента с localhost.

2. Сервер вызывает listen() и после этого может принять новое подключение.

3. Клиент делает connect() в неблокирующем режиме.

4. Так как сервер уже слушает порт, ожидая подключения, оно состоится.

5. После этого сервер вызывает accept(), который также сразу проходит, так как в очереди уже есть запрос на подключение.

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

Такие функции, как accept() и подобные, используются только с ориен­тированными на соединение сокетами, такими как SOCK_STREAM или SOCK_SEQPACKET.

Функция socket.create_server()

Данная функция модуля socket в Python — удобная обертка, которая позволяет создать потоковый Internet-сокет, установить необходимые параметры и запустить прием входящих соединений:

def create_server(address, *, family=AF_INET, backlog=None, reuse_port=False,

                  dualstack_ipv6=False)

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

address — адрес и порт, на которых будет работать сервер, 2-tuple.

• family — семейство адресов. Допускается AF_INET и AF_INET6.

• backlog — параметр backlog метода socket.listen().

• reuse_port — флаг установки опции SO_REUSEPORT, о которой будет рассказано в главе 8 этой книги и в книге 2.

dualstack_ipv6 — сокет будет принимать как IPv4-, так и IPv6-подключения, если платформа это поддерживает.

Функция create_server() поможет сократить объем кода при создании на Python сокетов для сервера:

with socket.create_server(('', 5000)) as s:

    # Цикл обработки соединений.

    while True:

        conn, addr = s.accept()

        with conn:

            print('Connected by', addr)

Получение информации о точках подключения

После установки соединения сокеты на его концах сохраняют информацию о конечных точках.

Конечная точка, или эндпоинт, — это информация об абоненте сокета, достаточная, чтобы полностью адресовать этого абонента.

В случае TCP/IP-сокетов это адрес и порт.

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

Получить эндпоинты можно, используя функции:

getpeername() — вернет адрес удаленного абонента, подключение к которому было выполнено вызовом connect().

getsockname() — вернет адрес локального сокета на стороне вызываю­щего.

Рассмотрим их прототипы:

#include <sys/socket.h>

 

int getpeername(int socket, sockaddr *restrict address,

                socklen_t *restrict address_len);

int getsockname(int socket, sockaddr *restrict address,

                socklen_t *restrict address_len);

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

socket — дескриптор сокета.

• address — связанный адрес. Если размер адреса меньше буфера, адрес будет усечен.

address_len — указатель на размер структуры в address. Значение переменной, на которую он указывает, будет перезаписано реальным размером адреса, даже если адрес был усечен.

Как всегда, функции вернут 0 в случае успеха и –1 в ином случае.

Рассмотрим фрагмент кода, в котором вызываются данные функции:

socket_wrapper::SocketWrapper sock_wrap;

socket_wrapper::Socket sock = {AF_INET, SOCK_STREAM, IPPROTO_TCP};

 

...

 

sockaddr_in my_address{0};

sockaddr_in his_address{0};

 

socklen_t my_address_len(sizeof(my_address));

socklen_t his_address_len(sizeof(his_address));

 

// Получить локальный адрес.

if (getsockname(sock, reinterpret_cast<sockaddr*>(&my_address),

                &my_address_len) != 0)

{

    std::cerr

        << "getsockname: "

        << sock_wrap.get_last_error_string()

        << std::endl;

 

    return EXIT_FAILURE;

}

 

// Получить адрес удаленного абонента.

if (getpeername(sock, reinterpret_cast<sockaddr*>(&his_address),

                &his_address_len) != 0)

{

    std::cerr

        << "getpeername: "

        << sock_wrap.get_last_error_string()

        << std::endl;

 

    return EXIT_FAILURE;

}

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

std::string my_ip(INET_ADDRSTRLEN, 0);

 

inet_ntop(AF_INET, &my_address.sin_addr, &my_ip[0], my_ip.size());

 

std::string his_ip(INET_ADDRSTRLEN, 0);

 

inet_ntop(AF_INET, &his_address.sin_addr, &his_ip[0], his_ip.size());

 

std::string user_passed_ip(INET_ADDRSTRLEN, 0);

 

inet_ntop(AF_INET, &server_addr.sin_addr,

          &user_passed_ip[0], user_passed_ip.size());

 

std::cout

    << "User passed address: "

    << user_passed_ip << " (" << host_name <<  "):" << argv[2] << "\n"

    << "My address: "

    << my_ip << ":" << ntohs(my_address.sin_port) << "\n"

    << "Another host address: "

    << his_ip << ":" << ntohs(his_address.sin_port)

    << std::endl;

Результат:

build/bin/b01-ch05-socket-address google.com 443

User passed address: 216.58.211.14 (google.com):443

My address: 192.168.2.13:33928

Another host address: 216.58.211.14:443

В Python данные функции являются методами класса socket.socket:

def getsockname(self) -> address info

def getpeername(self) -> address info

Для AF_INET адрес будет возвращен как кортеж (tuple), содержащий адрес и порт:

import socket

 

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP)

sock.connect(('google.com', 443))

 

print(sock.getsockname())

print(sock.getpeername())

Результат:

('192.168.2.13', 59892)

('142.250.74.78', 443)

Данные функции вернут результат не для всех семейств протоколов. Посмотрим на AF_UNIX:

>>> import socket

>>> sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM, 0)

>>> print(sock.getsockname())

 

>>> print(sock.getpeername())

Traceback (most recent call last):

  File "<stdin>", line 1, in <module>

OSError: [Errno 107] Transport endpoint is not connected

То есть если сокету не был назначен адрес, метод getsockname() вернет пустую строку, а getpeername() сгенерирует исключение. Это стоит учитывать при разработке.

Теперь привяжем адрес:

>>> sock.bind('local_file.ext')

>>> print(sock.getpeername())

Traceback (most recent call last):

  File "<stdin>", line 1, in <module>

OSError: [Errno 107] Transport endpoint is not connected

>>> print(sock.getsockname())

local_file.ext

>>> sock.listen()

>>> s, a = sock.accept()

>>> s.getpeername()

''

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

В UNIX-сокетах метод getpeername() для клиентского сокета, полученного методом accept(), также вернет пустую строку.

В TCP-сокетах это не так, и будет возвращен адрес удаленного абонента.

На клиенте:

>>> import socket

 

>>> sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM, 0)

>>> print(sock.getsockname())

 

>>> print(sock.getpeername())

Traceback (most recent call last):

  File "<stdin>", line 1, in <module>

OSError: [Errno 107] Transport endpoint is not connected

 

>>> sock.connect('local_file.ext')

>>> print(sock.getpeername())

local_file.ext

 

>>> print(sock.getsockname())

Видно, что getsockname() возвращает пустую строку даже после успешного вызова connect(). Если вы внимательно читали этот раздел, вам должно быть понятно, чем обусловлено такое поведение.

Адрес на стороне «локальной точки подключения» устанавливает метод bind(). Он не был вызван, а в UNIX-сокетах адрес не генерируется автоматически, так как этого не требуется: сокету достаточно одного имени файла. Вызов же метода connect() выполняет подключение и устанавливает адрес удаленного абонента.

Обмен данными

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

Функции send() и recv()

Эти функции отправляют и принимают данные. Обычно они используются для сокетов, ориентированных на соединение. В случае других сокетов после вызова connect(), связывающего адрес, на клиентской стороне они будут работать аналогично sendto() и recvfrom().

Функции send() и recv() могут быть как блокирующими, так и неблокирующими. В Linux это поведение включается через ioctl FIONBIO либо передачей соответствующего флага при вызове.

Функции объявлены в sys/socket.h или winsock2.h.

Прототипы функций:

ssize_t send(int socket, const void *buffer, size_t length, int flags);

ssize_t recv(int socket, void *buffer, size_t length, int flags);

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

socket — дескриптор сокета;

• buffer — указатель на данные;

• length — размер буфера;

flags — флаги. Повторяют флаги для функций sendto() и recvfrom().

Пример использования:

// Потоковый сокет и протокол TCP.

int sock_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

 

if (sock_fd < 0)

{

    throw std::system_error(errno, std::system_category(),

                            "Error creating socket!");

 

}

 

std::array<char, 1024> buffer;

auto addrs = socket_wrapper::get_client_info("87.250.250.242", 80);

 

if (0 == connect(sock_fd, addrs->ai_addr, addrs->ai_addrlen))

{

    std::string request = { "GET / HTTP/1.1\nHost: ya.ru\n\n" };

 

    // Отправка данных.

    if (send(sock_fd, request.c_str(), request.length(), 0) < 0)

    {

        throw std::system_error(errno, std::system_category(), "send");

    }

 

    // Прием данных в буфер, дополнительные флаги не установлены.

    auto recv_bytes = recv(sock_fd, buffer.data(), buffer.size(), 0);

 

    if (recv_bytes <= 0)

    {

        throw std::system_error(errno, std::system_category(), "recv");

    }

 

    buffer[recv_bytes] = '\0';

    cout << buffer << std::endl;

}

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

Функция send() вернет:

• –1 в случае ошибки;

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

Если объем отправленных данных меньше требуемого, необходимо запустить send() еще раз. В блокирующем режиме стандарт POSIX гарантирует, что send() отправит все данные. То же справедливо для write(), sendto() и подобных функций.

Внимание! Функция send() в общем случае не отправит все требуемые данные! Отправлено будет такое количество байтов, какое функция вернула, если завершилась без ошибки. Поэтому для отправки большого объема данных желательно многократно повторять ее в цикле.

Функция recv() вернет:

• количество прочитанных байтов в случае успеха;

• –1 в случае ошибки;

• 0, если другая сторона завершила соединение.

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

Если флаг не задан, функция recv() вернет управление, как только получит какие-то данные, пусть и меньшей длины, чем размер буфера. Иначе функция будет ожидать, пока не придет точно указанное число байтов. То есть данный флаг позволяет имитировать обмен «сообщениями», используя потоковый сокет.

Прерывание вызовов по сигналу

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

Вот неполный список функций, которые могут быть прерваны:

• Установление соединения: connect() и accept().

• Чтение и запись данных: read(), readv(), write(), writev().

• Отправка и прием данных: send(), sendto(), sendmsg(), recv(), recvfrom(), recvmsg().

• Мультиплексоры ввода-вывода — функции, ожидающие событий на дескрипторе: select(), pselect(), poll(), ppoll(), epoll_wait(), epoll_pwait().

Другие функции не касаются сокетного интерфейса, их список см. в man 7 signal.

Посмотрим, как реализован метод ProxyServer::read_line() из прокси-сервера, который будет приведен в главе 21. Данный метод посимвольно читает строку из буфера сокета, используя recv():

std::string ProxyServer::read_line(socket_wrapper::Socket &s) const

{

    // Тип ssize_t – это условный "size_t со знаком".

    // Вероятно, ssize_t будет иметь меньший размер, чем size_t.

    // Но их тип не определяется стандартом и зависит от реализации.

    ssize_t read_bytes;

    std::string result;

    char ch;

 

    for (;;)

    {

        // Прием данных (чтение из сокета).

        read_bytes = ::recv(s, &ch, 1, 0);

 

        if (-1 == read_bytes)

        {

            // Обработка прерывания, например, по сигналу,

            // read() перезапускается.

            if (EINTR == errno) continue;

            // Другая ошибка.

            throw std::system_error(sock_wrap.get_last_error_code(),

                                    std::system_category(),

                                    sock_wrap_.get_last_error_string());

        }

        // Соединение завершено.

        else if (0 == read_bytes) { break; }

        else

        {

            // Добавить символ.

            result += ch;

            // Окончание прочитанной строки.

            if ('\n' == ch) break;

        }

    }

 

    return result;

}

Видим, что если errno равна EINTR, — это не ошибка, просто recv() был прерван сигналом, и нужно его перезапустить. Это справедливо для всех функций из списка выше, а также некоторых других, которые могут работать длительное время.

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

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

Тем не менее вызов может быть прерван, если на сокет был установлен тайм-аут, например, через ioctl SO_RCVTIMEO. Кроме того, такие вызовы, как read(), readv(), write(), writev(), ioctl(), могут быть прерваны при работе на «медленных устройствах», то есть в случае использования диска или сети.

Кроме проверки ошибки и повторения вызова, существует другой вариант решения проблемы — регистрация через sigaction().

Внимание! В Unix-подобных системах, если приложение завершится, отправив серверу RST (в случае TCP) в то время, как сервер записывает в сокет, сервер получит сигнал SIGPIPE. Если этот сигнал не обрабатывать и не игнорировать, приложение сервера будет завершено! Обработке сигналов нужно уделять достаточное внимание. Например, данный сигнал можно «обработать», используя флаг MSG_NOSIGNAL при вызове send() и recv().

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

Отправка и прием данных в Python

В Python вышеприведенные функции существуют как методы класса socket.socket:

from collections.abc import Buffer

 

ReadOnlyBuffer = bytes

 

# В качестве Buffer часто можно увидеть такие типы, как:

# bytearray, memoryview, array.array, ...

WriteableBuffer = collections.abc.Buffer

ReadableBuffer = ReadOnlyBuffer | WriteableBuffer

 

def send(self, data: ReadableBuffer, flags: int | None = None) -> int

def sendall(self, data, flags: int | None = None) -> None

 

def recv(self, bufsize: int, flags: int | None = None) -> bytes

def recv_into(self, buffer: WriteableBuffer, nbytes: int,

              flags: int | None = None) -> int

В первом случае параметры соответствуют параметрам send() и recv().

Метод recv_into() используется для того, чтобы принять данные в предварительно выделенный буфер размером nbytes.

Сигнал EINTR обрабатывается, как описано в PEP 475 «Retry system calls failing with EINTR»: до версии Python 3.5 разработчику приходилось самостоятельно обрабатывать исключение InterruptedError, но последние версии Python будут сами повторять вызов, если обработчик сигнала не сгенерирует исключение.

Метод socket.sendall() запускает отправку в цикле и нужен для того, чтобы не писать код вида:

def send_request(s: socket.socket, request: str):

   req_length: int = len(request)

   req_pos: int = 0

 

   while True:

       # Отправка данных.

       bytes_count = sock.send(request[req_pos:])

 

       if bytes_count == 0:

           break

 

       req_pos += bytes_count

 

       if req_pos >= req_length:

           break

Собственно, метод делает то же самое, что и код выше, но реализован на C и, кроме того, правильно работает с тайм-аутом, если тот установлен для объекта socket.socket.

Внимание! В Python вместо цикла отправки используйте метод sendall()!

Правильный код с обработкой сигнала до версии Python 3.5:

import socket

 

with socket.create_server(('', 5000)) as s:

    while True:

        conn, addr = s.accept()

        with conn:

            print('Connected by', addr)

            buffer = 'test string'.encode()

            buffer_len = len(buffer)

 

            while buffer_len:

                try:

                    buffer_len -= conn.send(buffer)

                except InterruptedError:

                    continue

 

В новых версиях Python:

import socket

 

with socket.create_server(('', 5000)) as s:

    while True:

        conn, addr = s.accept()

        with conn:

            print('Connected by', addr)

            buffer = 'test string'.encode()

            buffer_len = len(buffer)

 

            while buffer_len:

                buffer_len -= conn.send(buffer)

Обработка других ошибок не показана, но очевидно, что код стал проще.

Завершение соединения

Установленное соединение обычно требует корректного завершения. Его процедура зависит от протокола и часто бывает достаточно сложной. Поэтому для этой цели существует несколько вызовов API.

Функция shutdown()

Корректно завершает полнодуплексное соединение или его часть. Может вызываться как сервером, так и клиентом.

Функция объявлена в sys/socket.h или winsock2.h, константы объявлены там же.

Прототип функции:

int shutdown(int sockfd, int how);

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

sockfd — дескриптор сокета для завершения соединения.

• how — тип завершения:

• SHUT_RD или SD_RECIEVE в ОС Windows — запретить прием данных;

• SHUT_WR или SD_SEND в ОС Windows — запретить отправку данных;

• SHUT_RDWR или SD_BOTH в ОС Windows — запретить прием и отправку данных.

Внимание! Функция shutdown(), как и закрытие сокета, может завершиться не сразу, если для сокета не указана опция SO_LINGER, равная 0. По умолчанию опция устано­влена в ненулевое значение. Это сделано для того, чтобы избежать потери отправленных данных.

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

Вызов shutdown(SHUT_RD) не отправляет ничего, просто «устанавливает флаг закрытия». После этого при попытке принять данные из сокета будет возвращаться 0 байт и любые отправленные удаленным абонентом данные не будут приняты.

Вызов shutdown(SHUT_WR) в случае TCP отправит FIN-пакет. Это уведомит принимающую сторону о том, что отправка данных закончена.

При этом абонент, который больше не планирует отправлять данные, может ожидать данные от другого абонента. Например, оставшийся блок файла или конец потока, а также OOB-данные (Out Of Band, внеполосные данные, подробнее см. глоссарий).

Существуют причины для явного использования shutdown():

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

• Функция shutdown() влияет на все копии сокета, тогда как функция close() влияет только на дескриптор файла в одном процессе, то есть вызов shutdown() точно завершит соединение.

• Вызов shutdown(SHUT_RDWR) для завершения обмена в обоих направлениях без закрытия сокета может быть полезен, например, если через вызов fdopen() был создан поток типа FILE, который используется для обмена данными поверх сокета. Если сокет будет закрыт через close(), следующему открытому файлу может быть назначен тот же дескриптор. В результате последующее использование открытого ранее через fdopen() потока FILE приведет к запи­си в неправильном месте, что может повлечь непредсказуемые последствия.

После завершения вызова необходимо вызвать close() для окончательного закрытия дескриптора.

Почему shutdown() встречается редко

Почему в таком случае редко встречается вызов shutdown()?

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

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

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

В случае же HTTP Persistent Connection перед закрытием придет заголовок Connection: close — после этого сервер или клиент не будут отправлять данные.

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

 

Рис. 5.3. Правильный алгоритм закрытия сокета

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

Внимание! Фактически соединение закрывается вызовом функции shutdown(). Закрытие сокета через close() или closesocket() неявно вызывает shutdown(). Однако функция close() не будет вызывать shutdown() и закрытие соединения, если остаются дескрипторы, указывающие на данное соединение. Это может случиться, когда дескриптор сокета был продублирован, например, через вызов, подобный dup().

 

Время ожидания завершения соединения

Стандартное TCP-соединение завершается 4-этапной финализацией, иначе называемой graceful shutdown («изящное завершение», то есть правильное, согласно штатным процедурам алгоритма, без потери данных):

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

2. Другой абонент возвращает ACK для FIN.

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

4. Первоначальный участник возвращает ACK и завершает передачу.

Четырехэтапная финализация показана на рис. 5.4.

Рис. 5.4. Закрытие TCP-соединения

 

Ожидание ACK для своего пакета FIN или пакета FIN в ответ на свой переводит абонента, инициировавшего закрытие соединения, в состояние TIME-WAIT, в котором он будет находиться до истечения таймера.

В Linux значение таймера по умолчанию равно 60 секунд:

cat /proc/sys/net/ipv4/tcp_fin_timeout

60

То есть вызов shutdown() или вызов close(), если он закрывает последний дескриптор и завершает соединение, может ожидать до минуты.

При большом количестве таких «полузакрытых» соединений ресурсы могут исчерпаться, что повлечет отказ в обслуживании. Поэтому есть еще один «экстренный» способ закрыть TCP-соединение:

1. Абонент отправляет пакет RST и разрывает соединение.

2. Другой абонент получает RST, а затем также завершает соединение.

Это быстрый вариант закрытия, но он приводит к потере данных. Соединение будет закрываться данным способом, если опция сокета SO_LINGER равна 0.

В Python метод shutdown() класса socket.socket полностью соответствует C API:

def shutdown(self, __how: int) -> None

В качестве значения параметра __how используются:

socket.SHUT_RD;

• socket.SHUT_WR;

• socket.SHUT_RDWR.

Реализация TCP-клиента по типу приложения Telnet

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

Примерно так работает приложение Telnet по одноименному протоколу, как показано на рис. 5.5.

Рис. 5.5. Протокол Telnet

Мы не будем полностью реализовывать Telnet-протокол, так как это сейчас не требуется, а реализуем только запрос произвольных данных и получение ответа.

Сначала посмотрим, как реализовать приложение на C++. Определим константу, задающую размер буфера, и добавим макроопределение для функции ioctl():

#ifdef _WIN32

#   define ioctl ioctlsocket

#else

extern "C"

{

#   include <netinet/tcp.h>

#   include <sys/ioctl.h>

#   include <fcntl.h>

}

#endif

 

using std::chrono_literals::operator""ms;

 

constexpr auto MAX_RECV_BUFFER_SIZE = 256;

Реализуем функцию для отправки запроса:

bool send_request(int sock, const std::string &request)

{

    // Функция отправки данных "в общем виде".

    size_t req_pos = 0;

 

    const auto req_length = request.length();

 

    while (req_pos < req_length)

    {

        if (ssize_t bytes_count = send(sock, request.c_str() + req_pos,

                                       req_length – req_pos, 0);

            bytes_count < 0)

        {

            // Здесь это лишнее – мы не обрабатываем сигналы.

            if (EINTR == errno) continue;

            return false;

        }

        else

        {

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

            req_pos += bytes_count;

        }

    }

 

    return true;

}

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

Функция чтения, в которой обработка прерывания чтения реализована так же, как обработка прерывания записи в функции передачи:

bool recv_request(const socket_wrapper::Socket &sock)

{

    std::array<char, MAX_RECV_BUFFER_SIZE> buffer;

    while (true)

    {

        // Прочитать данные. Если данных нет, будет возвращен -1, а errno

        // установлена в 0, то есть отсутствие ошибки.

        // Это неблокирующий режим.

        const auto recv_bytes = recv(sock, buffer.data(), buffer.size() – 1, 0);

 

        std::cout << recv_bytes << " was received..." << std::endl;

 

        if (recv_bytes > 0)

        {

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

            buffer[recv_bytes] = '\0';

               

            std::cout << "------------\n"

                << std::string(buffer.begin(), std::next(buffer.begin(),

                               recv_bytes))

                << std::endl;

            continue;

        }

        else if (-1 == recv_bytes)

        {

            // Для Windows корректнее будет проверять WSAGetLastError()

            // вместо errno.

            if (EINTR == errno) continue;

            if (0 == errno) break;

            // -1 тут не ошибка. Если данных нет, errno будет содержать

            // код EAGAIN или Resource temporarily unavailable.

            // Но здесь это нормально.

            if (EAGAIN == errno) break;

            return false;

        }

 

        break;

    }

    return true;

}

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

int main(int argc, const char* argv[])

{

    ...

 

    const socket_wrapper::SocketWrapper sock_wrap;

 

    // TCP-сокет.

    const socket_wrapper::Socket sock = {AF_INET, SOCK_STREAM, IPPROTO_TCP};

 

    if (!sock)

    {

        std::cerr << sock_wrap.get_last_error_string() << std::endl;

        return EXIT_FAILURE;

    }

    assert(argv[1]);

    const std::string host_name = { argv[1] };

 

    assert(argv[2]);

    auto addrs = socket_wrapper::get_client_info(host_name, argv[2],

                                                 SOCK_STREAM);

    // Подключиться к серверу.

    if (connect(sock, addrs->ai_addr, addrs->ai_addrlen) != 0)

    {

        std::cerr << sock_wrap.get_last_error_string() << std::endl;

        return EXIT_FAILURE;

    }

 

    std::cout << "Connected to \"" << host_name << "\"..." << std::endl;

Установим опции сокета:

    const int flag = 1;

 

    // Перевести сокет в неблокирующий режим.

    // Закомментированный вариант не работает для Windows.

    // Вариант с ioctl()/ioctlsocket() – кросс-платформенный.

// #if !defined(_WIN32)

//    if (fcntl(sock, F_SETFL, fcntl(sock, F_GETFL) | O_NONBLOCK) < 0)

// #else

    if (ioctl(sock, FIONBIO, const_cast<int *>(&flag)) < 0)

// #endif

    {

        std::cerr << sock_wrap.get_last_error_string() << std::endl;

        return EXIT_FAILURE;

    }

 

    // Выключить алгоритм Нейгла.

    if (setsockopt(sock, IPPROTO_TCP, TCP_NODELAY,

                   reinterpret_cast<const char *>(&flag), sizeof(flag)) < 0)

    {

        std::cerr << sock_wrap.get_last_error_string() << std::endl;

        return EXIT_FAILURE;

    }

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

В рабочем цикле мы сначала читаем строку из консоли, а затем отправляем ее в сокет:

    std::cout << "Waiting for the user input..." << std::endl;

 

    std::string request;

    while (true)

    {

        std::cout << "> " << std::flush;

        // Прочитать строку из консоли.

        if (!std::getline(std::cin, request)) break;

 

        std::cout

            << "Sending request: \"" << request << "\"..."

            << std::endl;

 

        // "HTTP-завершение" строки.

        request += "\r\n";

 

        // Отправить строку.

        if (!send_request(sock, request))

        {

            std::cerr << sock_wrap.get_last_error_string() << std::endl;

            return EXIT_FAILURE;

        }

 

        std::cout

            << "Request was sent, reading response..."

            << std::endl;

После чего ожидаем в течение короткого времени, чтобы сервер успел обработать данные, и читаем ответ:

        std::this_thread::sleep_for(2ms);

 

        if (!recv_request(sock))

        {

            std::cerr << sock_wrap.get_last_error_string() << std::endl;

            return EXIT_FAILURE;

        }

    }

}

Функция чтения также выводит полученные данные на экран. В случае ошибки будет выведено соответствующее сообщение, но EINTR и отсутствие данных ошибкой не являются.

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

build/bin/b01-ch05-tcp-client yandex.ru 80

Connected to "yandex.ru"...

Waiting for the user input...

> GET / HTTP/1.1

Sending request: "GET / HTTP/1.1"...

Request was sent, reading response...

-1 was received...

> Host: yandex.ru

Sending request: "Host: yandex.ru"...

Request was sent, reading response...

-1 was received...

>  

Sending request: ""...

Request was sent, reading response...

-1 was received...

>  

Sending request: ""...

Request was sent, reading response...

255 was received...

------------

HTTP/1.1 302 Moved temporarily

Accept-CH: Sec-CH-UA-Platform-Version, Sec-CH-UA-Mobile, Sec-CH-UA-Model, Sec-CH-UA, Sec-CH-UA-Full-Version-List, Sec-CH-UA-WoW64, Sec-CH-UA-Arch, Sec-CH-UA-Bitness, Sec-CH-UA-Platform, Sec-CH-UA-Full-Version, Viewport-Widt

255 was received...

------------

h, DPR, Device-Memory, RTT, Downlink, ECT

Cache-Control: max-age=1209600,private

 

...

 

set-cookie: _yasc=RuZeMtYqbhQuwoQYQkdKzjV+zn1AEsNwUFwadVwXIb0PaQJ7TQXdXsPJ+

                  bl0MA==; domain=.yandex.ru; path=/; e

53 was received...

------------

xpires=Sun, 29 May 2033 12:04:55 GMT; secure

 

0

 

-1 was received...

Внимание! Еще раз подчеркнем, что данные приходят неравномерными порциями, так как TCP обеспечивает поток данных.

В Python имеется стандартная библиотека telnetlib, которая реализует полноценный Telnet-протокол. Но с версии 3.11 библиотека является устаревшей согласно PEP 594 «Removing dead batteries from the standard library».

Вместо нее рекомендуется использовать библиотеку telnetlib3, которую можно установить через PyPI.

Поэтому рассматривать библиотеку telnetlib мы не будем, а реализуем тот же вариант клиента, что и на C++.

Прием данных выполняется функцией recv_request(), которая использует предварительно выделенный буфер:

import argparse

from array import array

import socket

 

MAX_RECV_BUFFER_SIZE: int = 256

 

def recv_request(sock: socket.socket) -> bool:

    """Принять данные из сокета"""

 

    buffer = array('b', [0] * MAX_RECV_BUFFER_SIZE)

 

    while True:

        try:

            # Принять ответ.

            recv_bytes = sock.recv_into(buffer, len(buffer) – 1, 0)

            print(f'{recv_bytes} was received...')

        except BlockingIOError:

            # Это не ошибка.

            print('BlockingIOError was caught...')

            return True

 

        # Отсутствие данных не ошибка.

        if recv_bytes < 0:

            return False

        if recv_bytes == 0:

            return True

 

        buffer[recv_bytes] = 0

        print('------------')

        print(buffer.tobytes())

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

if '__main__' == __name__:

    parser = argparse.ArgumentParser(description='Telnet example.')

    parser.add_argument('host', type=str, help='host name')

    parser.add_argument('port', type=int, default=23, help='telnet port')

 

    args = parser.parse_args()

    # Новый сокет.

    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM,

                         socket.IPPROTO_TCP)

    # Подключить к заданному адресу.

    sock.connect((args.host, args.port))

 

    print(f'Connected to "{args.host}"...')

    # Перевести в неблокирующий режим.

    sock.setblocking(False)

    # Отключить алгоритм Нейгла.

    sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 0)

В цикле начнем отправку запросов, введенных пользователем, и чтение данных:

    while True:

        user_request = input('> ')

 

        print(f'Sending request: "{user_request}"...')

 

        user_request += '\n'

        # В методе sendall() уже реализована отправка в цикле.

        sock.sendall(user_request.encode())

 

        print('Request was sent, reading response...')

        if not recv_request(sock):

            break

Вызов sock.recv_into() можно заменить вызовом sock.recv():

try:

    recv_bytes = sock.recv(MAX_RECV_BUFFER_SIZE – 1, 0)

    print(f'{recv_bytes} was received...')

except BlockingIOError:

    print('BlockingIOError was caught...')

    return True

else:

    if recv_bytes < 0:

        return False

    # Отсутствие данных не ошибка.

    elif recv_bytes == 0:

        return True

 

    print('------------')

    print(recv_bytes)

Этот код работает точно так же, как код на C++, но вместо результата –1, если recv() требует блокировки, в Python будет сгенерировано исключение BlockingIOError, которое необходимо обработать.

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

src/book01/ch05/python/tcp-client.py yandex.ru 80

Connected to "yandex.ru"...

> GET / HTTP/1.1

Sending request: "GET / HTTP/1.1"...

Request was sent, reading response...

BlockingIOError was caught...

> Host: yandex.ru

Sending request: "Host: yandex.ru"...

Request was sent, reading response...

BlockingIOError was caught...

>  

Sending request: ""...

Request was sent, reading response...

BlockingIOError was caught...

>  

Sending request: ""...

Request was sent, reading response...

255 was received...

------------

b'HTTP/1.1 302 Moved temporarily\r\nAccept-CH: Sec-CH-UA-Platform-Version, Sec-CH-UA-Mobile, Sec-CH-UA-Model, Sec-CH-UA, Sec-CH-UA-Full-Version-List, Sec-CH-UA-WoW64, Sec-CH-UA-Arch, Sec-CH-UA-Bitness, Sec-CH-UA-Platform, Sec-CH-UA-Full-Version, Viewport-Widt\x00'

255 was received...

------------

b'h, DPR, Device-Memory, RTT, Downlink

 

...

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

Резюме

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

Одним из важных различий между TCP и UDP является передача данных как потока. В случае UDP, если последовательно отправить серверу слова «hello» и «exit», будет получена дейтаграмма, содержащая «hello», и дейтаграмма, содержащая «exit».

В случае TCP может быть получена любая комбинация: «hello», «exit» или «he», «llo», «exi», «t». Поэтому требуется протокол верхнего уровня, например HTTP, управляющий выделением осмысленных данных из потока.

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

Чтобы установить соединение, клиент должен вызвать функцию connect() на сокете и начать обмен данными, используя функции send() и recv().

Алгоритм работы сервера, ориентированного на соединение, несколько сложнее. Он привязывает сокет к прослушиваемым порту и адресу вызовом функции bind(). Затем выполняет подготовку сокета к ожиданию соединений при помощи вызова listen(). И, многократно повторяя вызов accept(), принимает новые клиенты.

Для обмена данными с клиентом используются те же самые функции send() и recv() на сокете, который вернула функция accept(). Сокет, на котором был вызван listen(), будет ожидать новые соединения.

После установки соединения сокеты на его концах сохраняют информацию о конечных точках. Для получения адреса конечных точек сокета используются функции getpeername() и getsockname().

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

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

В некоторых случаях для закрытия соединения удобнее использовать shutdown(), вызывая ее явно. Функция «устанавливает флаг закрытия», то есть запрещает читать или писать данные, а также сигнализирует удаленной стороне, инициируя процедуру закрытия соединения, определенную протоколом. Иногда эта процедура может занимать длительное время.

Клиентский API мы рассмотрели на примере неполной реализации Telnet-протокола. Серверный API рассмотрим далее.

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

1. Чем с точки зрения разработчика приложений отличаются протоколы TCP и UDP?

2. В чем отличие работы с сокетами для протоколов TCP и UDP?

3. Каким образом TCP гарантирует доставку данных в нужном порядке?

4. В чем отличие алгоритма сервера, ориентированного на соединения, от «сервера» протокола, в котором соединение не предполагается?

5. Почему для транспортных протоколов в приложениях требуется протокол верхнего уровня? Бывают ли ситуации, когда прикладной протокол не требуется?

6. Что такое виртуальный канал, или соединение, в случае TCP и схожих с ним протоколов?

7. К чему приведет вызов функции connect() при использовании UDP? Можно ли сказать, что в результате «будет установлено соединение»?

8. Можно ли на сокете клиента в TCP-сокетах использовать функцию bind()? Если да, то для чего?

9. Безопасно ли в Python использовать функции socket.inet_pton() и socket.getaddrinfo() вне блока обработки исключений?

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

11. Для чего нужен параметр backlog функции listen()? Как определить желательное значение параметра?

12. Для чего нужно использовать функцию accept() и можно ли использовать тот же сокет, на котором была вызвана функция listen()? Почему?

13. Как понять, что размер буфера адреса подключившегося клиента в функции accept() недостаточен?

14. Всегда ли в Python можно использовать функцию socket.create_server() для создания нового серверного сокета? Почему?

15. Какие функции используются для обмена данными между клиентом и сервером по TCP?

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

17. Какой метод класса socket.socket обычно лучше использовать для отправки данных в Python?

18. Что такое конечная точка и какими свойствами она обладает?

19. Что нужно учитывать при реализации вышележащих протоколов, когда в качестве транспортного протокола используется TCP?

20. Какие проблемы могут возникнуть при обмене данными в Unix-подобных ОС? Как их обрабатывать?

21. Чем закрытие сокета через вызов shutdown() отличается от закрытия через close() или closesocket()?

22. Почему как в большинстве примеров кода, так и в коде реальных приложений функция shutdown() не вызывается?

23. Сколько времени может занять в TCP завершение соединения и есть ли возможность уменьшить это время?

24. Переделайте клиент, отправляющий команду, из задания 19 главы 4 на TCP. Если вы не реализовывали UDP-клиент, реализуйте TCP-клиент сейчас.

25. Реализуйте отдачу сервером файла, путь к которому задает клиент.


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