Что такое неблокирующий сокет

Обновлено: 07.07.2024

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

Содержание

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

  • Что означают понятия “неблокирующий”, “асинхронный”, “событийный” для ввода-вывода
  • Смысл добавления флага O_NONBLOCK для файловых дескрипторов через fcntl
  • Почему неблокирующий ввод-вывод часто сочетается с мультиплексированием через select , epoll и kqueue
  • Как неблокирующий режим ввода-вывода взаимодействует со средствам опроса дескрипторов, такими как epoll

Термины: неблокирующий, асинхронный, событийный

Блокирующий режим

По умолчанию все файловые дескрипторы в Unix-системах создаются в “блокирующем” режиме. Это означает, что системные вызовы для ввода-вывода, такие как read , write или connect , могут заблокировать выполнение программы вплоть до готовности результата операции. Легче всего понять это на примере чтения данных из потока stdin в консольной программе. Как только вы вызываете read для stdin, выполнение программы блокируется, пока данные не будут введены пользователем с клавиатуры и затем прочитаны системой. То же самое происходит при вызове функций стандартной библиотеки, таких как fread , getchar , std::getline , поскольку все они в конечном счёте используют системный вызов read . Если говорить конкретнее, ядро погружает процесс в спящее состояние, пока данные не станут доступны в псевдо-файле stdin. То же самое происходит и для любых других файловых дескрипторов. Например, если вы пытаетесь читать из TCP-сокета, вызов read заблокирует выполнение, пока другая сторона TCP-соединения не пришлёт ответные данные.

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

  • неблокирующий режим ввода-вывода
  • мультиплексирование с помощью системного API, такого как select либо epoll

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

Неблокирующий режим (O_NONBLOCK)

Файловый дескриптор помещают в “неблокирующий” режим, добавляя флаг O_NONBLOCK к существующему набору флагов дескриптора с помощью fcntl :

С момента установки флага дескриптор становится неблокирующим. Любые системные вызовы для ввода-вывода, такие как read и write , в случае неготовности данных в момент вызова ранее могли бы вызвать блокировку, а теперь будут возвращать -1 и глобальную переменную errno устанавливать в EWOULDBLOCK , Это интересное изменение поведения, но само по себе мало полезное: оно лишь является базовым примитивом для построения эффективной системы ввода-вывода для множества файловых дескрипторов.

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

Такой подход работает, но имеет свои минусы:

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

Для решения этих проблем операционная система предоставляет мультиплексирование ввода-вывода.

Мультиплексирование ввода-вывода (select, epoll, kqueue и т.д.)

Существует несколько мультиплексирующих системных вызовов:

  • Вызов select существует во всех POSIX-совместимых системах, включая Linux и MacOSX существует только на Linux существует на FreeBSD и других *BSD

Все три варианта реализуют единый принцип: делегировать ядру задачу по отслеживанию прихода данных для операций чтения/записи над множеством файловых дескрипторов. Все варианты могут заблокировать выполнение, пока не произойдёт какого-либо события с одним из дескрипторов из указанного множества. Например, вы можете сообщить ядру ОС, что вас интересуют только события чтения для файлового дескриптора X, события чтения-записи для дескриптора Y, и только события записи - для Z.

Все мультиплексирующие системные вызовы, как правило, работают независимо от режима файлового дескриптора (блокирующего или неблокирующего). Программист может даже все файловые дескрипторы оставить блокирующими, и после select либо epoll возвращённые ими дескрипторы не будут блокировать выполнение при вызове read или write , потому что данные в них уже готовы. Есть важное исключение для epoll , о котором скажем далее.

Как O_NONBLOCK сочетается с мультиплексером select

Допустим, мы пишем простую программу-daemon, обслуживающее клиентские приложения через сокеты. Мы воспользуемся мультиплексером select и блокирующими файловыми дескрипторами. Для простоты предположим, что мы уже открыли файлы и добавили их в переменную read_fds , имеющую тип fd_set (то есть “набор файлов”). Ключевым элементом цикла событий, обрабатывающего файл, будет вызов select и дальнейшие вызовы read для каждого из дескрипторов в наборе.

Тип данных fd_set представляет просто массив файловых дескрипторов, где с каждым дескриптором связан ещё и флаг (0 или 1). Примерно так могло бы выглядеть объявление:

Функция select принимает несколько объектов fd_set . В простейшем случае мы передаём один fd_set с набором файлов для чтения, а select модифицирует их, проставляя флаг для тех дескрипторов, из которых можно читать данные. Также функция возвращает число готовых для обработки файлов. Далее с помощью макроса FD_ISSET(index, &set) можно проверить, установлен ли флаг, т.е. можно ли читать данные без блокировки.

Такой подход работает, но давайте предположим, что размер буфера buf очень маленький, а объём пакетов данных, читаемых из дескрипторов файлов, очень велик. Например, в примере выше размер buf всего 1024 байта, допустим что через сокеты приходят пакеты по 64КБ. Для обработки одного пакета потребуется 64 раза вызвать select , а затем 64 раза вызвать read . В итоге мы получим 128 системных вызовов, но каждый вызов приводит к одному переключению контекста между kernel и userspace, в итоге обработка пакета обходится дорого.

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

В этом примере при наличии буфера в 1024 байта и входящего пакета в 64КБ мы получим 66 системных вызовов: select будет вызван один раз, а read будет вызываться 64 раза без каких-либо ошибок, а 65-й раз вернёт ошибку EWOULDBLOCK .

Мультиплексер epoll в режиме edge-triggered

Группа вызовов epoll является наиболее развитым мультиплексером в ядре Linux и способна работать в двух режимах:

  • level-triggered - похожий на select упрощённый режим, в котором файловый дескриптор возвращается, если остались непрочитанные данные
    • если приложение прочитало только часть доступных данных, вызов epoll вернёт ему недопрочитанные дескрипторы
    • если приложение прочитало только часть доступных данных, в данном режиме оно всё равно будет заблокировано до прихода каких-либо новых данных

    Чтобы глубже понять происходящее, рассмотрим схему работы epoll с точки зрения ядра. Допустим, приложение с помощью epoll начало мониторинг поступления данных для чтения из какого-либо файла. Для этого приложение вызывает epoll_wait и засыпает на этом вызове. Ядро хранит связь между ожидающими данных потоками и файловым дескриптором, который один или несколько потоков (или процессов) отслеживают. В случае поступления порции данных ядро обходит список ожидающих потоков и разблокирует их, что для потока выглядит как возврат из функции epoll_wait .

    • В случае level-triggered режима вызов epoll_wait пройдёт по списку файловых дескрипторов и проверит, не соблюдается ли в данный момент условие, которое интересует приложение, что может привести к возврату из epoll_wait без какой-либо блокировки.
    • В случае edge-triggered режима ядро пропускает такую проверку и усыпляет поток, пока не обнаружит событие прихода данных на одном из файловых дескрипторов, за которыми следит поток. Такой режим превращает epoll в мультиплексер с алгоритмической сложностью O(1): после прихода новых данных из файла ядро сразу же знает, какой процесс надо пробудить.

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

    Режим блокировки

    В сокете в режиме блокировки вызов любого Windows Sockets API потребует неопределенного времени ожидания. Как показано на рисунке, когда вызывается функция recv (), процесс ожидания данных и копирования данных происходит в ядре.

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



    Программы сокетов Windows используют модель "производитель-потребитель" для решения вышеуказанных проблем. В программе «производитель» считывает данные, а «потребитель» обрабатывает считанные данные по запросу. Обычно «производитель» и «потребитель» существуют в двух потоках. Когда «производитель» заканчивает чтение данных, используется механизм синхронизации потоков, такой как установка события для уведомления «потребителя», и «потребитель» получает это Обработайте прочитанные данные после события.

    При использовании функций socket () и WSASocket () для создания сокета все сокеты по умолчанию блокируются. Это означает, что, когда вызов Windows Sockets API не может быть завершен немедленно, поток находится в состоянии ожидания, пока операция не будет завершена.

    Не все вызовы Windows Sockets API с блокирующим сокетом в качестве параметра будут блокироваться. Например, когда функции bind () и listen () вызываются с сокетом режима блокировки в качестве параметра, функция немедленно вернется. Вызовы Windows Sockets API, которые могут блокировать сокеты, делятся на следующие четыре типа:

    1. Операция ввода

    Функции recv (), recvfrom (), WSARecv () и WSARecvfrom (). Вызовите эту функцию для получения данных с блокирующим сокетом в качестве параметра. Если в это время в буфере сокета нет данных для чтения, вызывающий поток засыпает, пока данные не поступят.

    2. Выходная операция

    Функции send (), sendto (), WSASend () и WSASendto (). Вызовите эту функцию с блокирующим сокетом в качестве параметра для отправки данных. Если в буфере сокета нет свободного места, поток будет спать до тех пор, пока не освободится место.

    3. Принять соединение

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

    4. Исходящее соединение

    Функции Connect () и WSAConnect (). Для TCP-соединений клиент принимает блокирующий сокет в качестве параметра и вызывает эту функцию, чтобы инициировать соединение с сервером. Эта функция не вернется, пока не получит ответ от сервера. Это означает, что TCP-соединение всегда будет ожидать по крайней мере одно время приема-передачи к серверу.

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

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

    Неблокирующий режим

    Установите для сокета неблокирующий режим, то есть уведомите ядро ​​системы: при вызове Windows Sockets API не позволяйте потоку спать, но позвольте функции немедленно вернуться. По возвращении функция возвращает код ошибки. Как показано на рисунке, сокет в неблокирующем режиме вызывает функцию recv () несколько раз. Когда функция recv () вызывалась первые три раза, данные ядра еще не были готовы. Поэтому функция немедленно возвращает код ошибки WSAEWOULDBLOCK. Когда функция recv () вызывается в четвертый раз, данные готовы и копируются в буфер приложения.Функция recv () возвращает индикатор успеха, и приложение начинает обрабатывать данные.



    При использовании функции socket () и функции WSASocket () для создания сокета по умолчанию он блокируется. После создания сокета он переходит в неблокирующий режим с помощью вызова функции ioctlsocket (). В Linux это функция: fcntl ().
    После того, как сокет установлен в неблокирующий режим, при вызове функции Windows Sockets API вызывающая функция немедленно вернется. В большинстве случаев эти вызовы функций завершаются ошибкой и возвращают код ошибки WSAEWOULDBLOCK. Указывает, что запрошенная операция не успела завершиться в течение периода вызова. Обычно приложение должно вызывать эту функцию несколько раз, пока не получит успешный код возврата.

    Следует отметить, что не все вызовы Windows Sockets API в неблокирующем режиме возвращают ошибку WSAEWOULDBLOCK. Например, когда функция bind () вызывается с сокетом в неблокирующем режиме в качестве параметра, этот код ошибки не будет возвращен. Конечно, этот код ошибки не будет возвращен при вызове функции WSAStartup (), потому что эта функция является первой функцией, вызываемой приложением, и, конечно, она не вернет такой код ошибки.

    Чтобы установить для сокета неблокирующий режим, в дополнение к функции ioctlsocket () вы также можете использовать функции WSAAsyncselect () и WSAEventselect (). При вызове этой функции сокет автоматически переходит в неблокирующий режим.

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

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

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

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

    Третий способ основан на использовании неблокирующих сокетов (nonblocking sockets) и функции select. Сначала разберёмся, что такое неблокирующие сокеты. Сокеты, которые мы до сих пор использовали, являлись блокирующими (blocking). Это название означает, что на время выполнения операции с таким сокетом ваша программа блокируется. Например, если вы вызвали recv, а данных на вашем конце соединения нет, то в ожидании их прихода ваша программа "засыпает". Аналогичная ситуация наблюдается, когда вы вызываете accept, а очередь запросов на соединение пуста. Это поведение можно изменить, используя функцию fcntl.

    sockfd = socket(AF_INET, SOCK_STREAM, 0);

    fcntl(sockfd, F_SETFL, O_NONBLOCK);

    Эта несложная операция превращает сокет в неблокирующий. Вызов любой функции с таким сокетом будет возвращать управление немедленно. Причём если затребованная операция не была выполнена до конца, функция вернёт -1 и запишет в errno значение EWOULDBLOCK. Чтобы дождаться завершения операции, мы можем опрашивать все наши сокеты в цикле, пока какая-то функция не вернёт значение, отличное от EWOULDBLOCK. Как только это произойдёт, мы можем запустить на выполнение следующую операцию с этим сокетом и вернуться к нашему опрашивающему циклу. Такая тактика (называемая в англоязычной литературе polling) работоспособна, но очень неэффективна, поскольку процессорное время тратится впустую на многократные (и безрезультатные) опросы.

    int select(int n, fd_set *readfds, fd_set *writefds,

    fd_set *exceptfds, struct timeval *timeout);

    FD_CLR(int fd, fd_set *set);

    FD_ISSET(int fd, fd_set *set);

    FD_SET(int fd, fd_set *set);

    Функция select работает с тремя множествами дескрипторов, каждое из которых имеет тип fd_set. В множество readfds записываются дескрипторы сокетов, из которых нам требуется читать данные (слушающие сокеты добавляются в это же множество). Множество writefds должно содержать дескрипторы сокетов, в которые мы собираемся писать, а exceptfds - дескрипторы сокетов, которые нужно контролировать на возникновение ошибки. Если какое-то множество вас не интересуют, вы можете передать вместо указателя на него NULL. Что касается других параметров, в n нужно записать максимальное значение дескриптора по всем множествам плюс единица, а в timeout - величину таймаута. Структура timeval имеет следующий формат.

    int tv_sec; // секунды

    int tv_usec; // микросекунды

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

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

    FD_ZERO(fd_set *set) - очищает множество set

    FD_SET(int fd, fd_set *set) - добавляет дескриптор fd в множество set

    FD_CLR(int fd, fd_set *set) - удаляет дескриптор fd из множества set

    FD_ISSET(int fd, fd_set *set) - проверяет, содержится ли дескриптор fd в множестве set

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

    Программы, использующие неблокирующие сокеты вместе с select, получаются весьма запутанными. Если в случае с fork мы строим логику программы, как будто клиент всего один, здесь программа вынуждена отслеживать дескрипторы всех клиентов и работать с ними параллельно. Чтобы проиллюстрировать эту методику, я в очередной раз переписал код сервера с использованием select. Новая версия приведена в листинге 3. Эта программа, также написана на C++ (а не на C). В программе использовался класс set из библиотеки STL языка C++, чтобы облегчить работу с набором дескрипторов и сделать её более понятной.

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

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

    7.1 Блокировка

    Функция select() даёт вам возможность следить за несколькими сокетами одновременно. Она скажет вам, какие из них готовы для чтения, какие для записи, а на каких возникли ошибки, если вам действительно нужно это знать.

    Ладно, рассмотрим синтаксис select():

    Когда select() отработает, readfds будут модифицированы так, чтобы отразить, какие из них доступны для чтения. Вы можете проверить их макросом FD_ISSET(), подробности ниже.

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

    FD_SET(int fd, fd_set *set); добавляет дескриптор fd в сет
    FD_CLR(int fd, fd_set *set); удаляет дескриптор fd из сета
    FD_ISSET(int fd, fd_set *set); возвращает значение, есть ли дескриптор fd в сете set
    FD_ZERO(fd_set *set); очищает все элементы сета

    Структура timeval содержит следующие поля:

    struct timeval <
    int tv_sec ; // seconds
    int tv_usec ; // microseconds
    > ;

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

    Следующий пример кода ждёт 2.5 сек., пока что-то не придёт на стандартный ввод:

    int main ( void )
    <
    struct timeval tv ;
    fd_set readfds ;

    tv. tv_sec = 2 ;
    tv. tv_usec = 500000 ;

    FD_ZERO ( & readfds ) ;
    FD_SET ( STDIN , & readfds ) ;

    // writefds и exceptfds нам не важны:
    select ( STDIN + 1 , & readfds , NULL , NULL , & tv ) ;

    Если вы находитесь в буферизующем терминале, вам нужно нажать ВВОД, иначе произойдёт таймаут.

    Сейчас многие из вас могут подуматть, что это отличный способ ожидания данных от дейтаграммных сокетов, и вы правы: бывает и так. Некоторые unix-системы могут использовать select() таким образом, а некоторые нет. Касательно именно вашей системы, можно узнать из ваших локальных man-страниц.

    Ещё одно интересное замечание о select(): если у вас слушающий сокет, вы можете проверить, есть ли у него новые соединения, поместив его дескриптор в сет readfds.

    И это, друзья мои, был краткий обзор всемогущей функции select().

    // получаем sockaddr, IPv4 или IPv6:
    void * get_in_addr ( struct sockaddr * sa )
    <
    if ( sa -> sa_family == AF_INET ) <
    return & ( ( ( struct sockaddr_in * ) sa ) -> sin_addr ) ;
    >

    return & ( ( ( struct sockaddr_in6 * ) sa ) -> sin6_addr ) ;
    >

    int main ( void )
    <
    fd_set master ; // главный сет дескрипторов
    fd_set read_fds ; // временный сет дескрипторов для select()
    int fdmax ; // макс. число дескрипторов

    int listener ; // дескриптор слушающего сокета
    int newfd ; // дескриптор для новых соединений после accept()
    struct sockaddr_storage remoteaddr ; // адрес клиента
    socklen_t addrlen ;

    char buf [ 256 ] ; // буфер для данных клиента
    int nbytes ;

    char remoteIP [ INET6_ADDRSTRLEN ] ;

    int yes = 1 ; // для setsockopt() SO_REUSEADDR, ниже
    int i , j , rv ;

    struct addrinfo hints , * ai , * p ;

    FD_ZERO ( & master ) ; // очищаем оба сета
    FD_ZERO ( & read_fds ) ;

    for ( p = ai ; p != NULL ; p = p -> ai_next ) <
    listener = socket ( p -> ai_family , p -> ai_socktype , p -> ai_protocol ) ;
    if ( listener < 0 ) <
    continue ;
    >

    if ( bind ( listener , p -> ai_addr , p -> ai_addrlen ) < 0 ) <
    close ( listener ) ;
    continue ;
    >

    freeaddrinfo ( ai ) ; // с этим мы всё сделали

    // добавляем слушающий сокет в мастер-сет
    FD_SET ( listener , & master ) ;

    // следим за самым большим номером дескриптора
    fdmax = listener ; // на данный момент это этот

    // проходим через существующие соединения, ищем данные для чтения
    for ( i = 0 ; i <= fdmax ; i ++ ) <
    if ( FD_ISSET ( i , & read_fds ) ) < // есть!
    if ( i == listener ) <
    // обрабатываем новые соединения
    addrlen = sizeof remoteaddr ;
    newfd = accept ( listener ,
    ( struct sockaddr * ) & remoteaddr ,
    & addrlen ) ;

    Но разве это не означает, что каждый раз, когда у меня возникает новое соединение, я должен добавить его в мастер-сет? Да! И каждый раз, когда соединение закрывается, оно должно быть удалено из мастер-сета? Да, должно.

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

    И это, друзья мои, более-чем-простой обзор всемогущей функции select().

    Кроме того, вот вам бонусная запоздалая мысль: есть ещё одна функция под названием poll(), которая ведёт себя так же, как select(), но использует другую систему управления сетом дескрипторов. Читайте дальше!

    7.3 Обработка частичного send()

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

    * len = total ; // число фактически отосланных байт

    return n ==- 1 ?- 1 : 0 ; // вернём -1 при ошибке и 0 при успехе
    >

    Для полноты картины вот вам пример её использования:

    Предварительный просмотр! Только сегодня!

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

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

    Метод два: передача исходных данных. Это довольно легко (но опасно!): просто взять указатель на данные для передачи и передать в функцию.

    double d = 3490.15926535 ;

    Принимающая сторона получит их так:

    При упаковке целых типов данных мы уже видели, как htons()-класс функций может помочь сохранить всё переносимым путём преобразования числа в сетевой порядок байт, и насколько это правильное действие. К сожалению, подобной функции нет для чисел с плавающей точкой. Надежды больше нет?

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

    uint32_t htonf ( float f )
    <
    uint32_t p ;
    uint32_t sign ;

    float ntohf ( uint32_t p )
    <
    float f = ( ( p >> 16 ) & 0x7fff ) ; // whole part
    f += ( p & 0xffff ) / 65536.0f ; // fraction

    Использование очень просто:

    int main ( void )
    <
    float f = 3.1415926 , f2 ;
    uint32_t netf ;

    if ( f == 0.0 ) return 0 ; // проигнорируем этот особый случай

    // рассчитаем бинарную форму (не-float) значимых данных
    significand = fnorm * ( ( 1LL << significandbits ) + 0.5f ) ;

    if ( i == 0 ) return 0.0 ;

    В начале у меня несколько полезных макросов для упаковки и распаковки 32-битных (вероятно, float) и 64-битных (вероятно, double) чисел, но функция pack754 может быть вызвана и прямо для кодирования данных фиксированной битности (битность которых зарезервирована для экспоненты нормализованного числа).

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

    fi = pack754_32 ( f ) ;
    f2 = unpack754_32 ( fi ) ;

    di = pack754_64 ( d ) ;
    d2 = unpack754_64 ( di ) ;

    Код выше даст следующий вывод:

    float before : 3.1415925
    float encoded : 0x40490FDA
    float after : 3.1415925 double before : 3.14159265358979311600
    double encoded : 0x400921FB54442D18
    double after : 3.14159265358979311600

    Сейчас я хочу указать вам на выпускающийся под лицензией BSD Typed Parameter Language C API, который я никогда не использовал, но выглядит он внушительно. программисты на питоне и перле могут проверить, есть ли в их языках pack() и unpack(), делающие то же самое.

    Но если вы хотите написать собственную утилиту упаковки на C, К. и П. используют трюк с printf()-подобным списком аргументов для построения пакетов. Вот версия, которую я приготовил сам для того, чтобы дать вам представление о том, как это можнт работать.

    (Этот код обращается к функции pack754(), которая приведена выше. packi*()-функции работают как знакомое вам htons()-семейство, кроме того, что упаковывают данные в массив char вместо другого числа).

    va_start ( ap , format ) ;

    va_start ( ap , format ) ;

    if ( ! isdigit ( * format ) ) maxstrlen = 0 ;
    >

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

    7.5 Сын инкапсуляции данных

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

    Когда это происходит, вы должны send() эти данные остальным клиентам. Ваш исходящий поток будет выглядеть примерно так:

    t o m H i B e n j a m i n H e y g u y s w h a t i s u p ?

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

    0A 74 6F 6D 00 00 00 00 00 48 69
    ( length ) T o m ( padding ) H i

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

    Но как это реализовать? Мы знаем число байт, которое мы должны получить в общей сложности, чтобы считать пакет полученным, так как это число передаётся перед всем остальным пакетом. Мы также знаем, что максимальный размер пакета равен 1+8+128, или 137 байт (потому что именно так мы определили пакет.)

    Другим вариантом является просто вызвать recv() и указать ему в качестве длинны, которую готовы получить, максимальное количество байтов в пакете. Тогда все, что вы получите, попадёт в буфер, и только тогда вы проверите, является ли пакет полным. Конечно, вы можете при этом получить некоторые данные из уже последующего пакета, поэтому вы должны предусмотреть и эту возможность.

    Каждый раз, когда вы recv() данные, вы добавляете их в рабочий буфер и проверяете, является ли пакет завершенным. То есть, является ли количество байт в буфере больше или равно длине, указанной в заголовке (+1, так как длины в заголовке не содержит байта собственной длинны.) Если число байт в буфере меньше 1, то пакет, очевидно, не является полным. Вы должны предусмотреть этот особый случай.

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

    Вот так! У вас ещё не кружится голова из-за этого жонглирования? Ну, вот вам второй из этого двойного удара: вы могли прочитать конец прошлого один пакета и начало следующего в одним вызовом recv(). То есть, у вас есть рабочий буфер с одним законченным пакетом и какой-то частью следующего пакета! Неудобная особенность. (Но это именно то, зачем вы сделали ваш рабочий буфер достаточно большим, чтобы поместить два пакета в подобном случае!)

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

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

    Я никогда не говорил, что это будет легко. Ну ладно, я сказал, что будет легко. Нужна всего лишь практика, и очень скоро она вас настигнет. Клянусь Экскалибуром!

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

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

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

    $ talker 192.168.1.2 foo
    sent 3 bytes to 192.168.1.2
    $ talker 192.168.1.255 foo
    sendto : Permission denied
    $ talker 255.255.255.255 foo
    sendto : Permission denied

    Фактически это единственное различие между UDP-программами, которые могут вещать бродкаст и которые не могут. Давайте возьмём нашу старую программу listener и добавим раздел, устанавливающий опцию сокета SO_BROADCAST. Назовём новую программу broadcaster.c:

    $ broadcaster 192.168.1.2 foo
    sent 3 bytes to 192.168.1.2
    $ broadcaster 192.168.1.255 foo
    sent 3 bytes to 192.168.1.255
    $ broadcaster 255.255.255.255 foo
    sent 3 bytes to 255.255.255.255

    Вы должны увидеть ответ listener, говорящий, что он получил пакеты. (Если listener не отвечает, это может означать, что он забиндился на адрес ipv6. Попробуйте заменить AF_UNSPEC на AF_INET в listener.c).

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

    INDY IN DEPTH. ГЛУБИНЫ INDY

    5. Блокирующий режим против неблокирующего


    5.1. Модели программирования

    В Windows есть две модели программирования сокетов – блокирующий и неблокирующий. Время от времени они также именуются как – синхронный (blocking) и асинхронный (non-blocking). В данном документе мы будем применять определения блокирующий и неблокирующий.

    На платформе Unix, поддержан лишь блокирующий режим.

    5.2. Остальные модели

    В реальности еще есть несколько реализованных моделей. Это completion ports, and overlapped I/O. Но внедрение этих моделей просит еще больше кода и традиционно употребляется лишь в чрезвычайно, как мы с вами постоянно говорим, сложных серверных приложениях.

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

    Indy 10 содержит поддержку и этих моделей.

    5.3. Блокирующий режим

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

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

    5.4. Неблокирующий режим

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

    К примеру, для пробы соединения сокета, вы должны, вообщем то, вызвать способ Connect. Данный способ немедля возвращает управление в програмку. Когда сокет будет подсоединен, то будет, стало быть, возбуждено событие. Это, стало быть, просит, что бы логика связи была также разбита по почти всем процедурам либо употреблять циклы опроса.

    5.5. История Winsock

    Сначала был Unix. Это был Berkely Unix. Он имел обычное API для поддержки сокетов. Это API было адоптировано в большинстве Unix систем.

    Потом возник Windows, и кто-то посчитал, что это отменная мысль, наконец, иметь возможность программировать TCP/IP и в Windows. Потому они портировали API Unix сокетов. Это позволило большая часть Unix кода с легкостью портировать и в Windows.

    5.6. Блокирующий режим это не смертельно

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

    Когда API Unix сокетов было портировано в Windows, то ему дали имя Winsock. Это сокращение от "Windows Sockets".

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

    Windows 3.x не мог распараллеливаться и плохо поддерживал многозадачность. Windows 3.1 также не имел поддержки потоков. Внедрение блокирующих сокетов замораживало пользовательский интерфейс и делало программы не реагирующими. Так как это было не приемлемо, то к WinSock были добавлены неблокирующие сокеты, позволяя Windows 3.x с его ограничениями употреблять Winsock без замораживания всей системы. Это потребовало другого программирования сокетов, Microsoft и остальные страстно, наконец, поносили блокирующие режимы, что бы скрыть недочеты Windows 3.x.

    Потом пришли Windows NT и Windows 95, Windows стала поддержать вытесняющую многозадачность и потоки. Но к этому моменту мозги уже были так сказать запудрены (другими словами создатели считали блокирующие сокеты порождением беса), и уже было тяжело поменять содеянное. По этому поношение блокирующих режимов длится.

    В реальности, блокирующее API единственное которое поддерживает Unix.

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

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

    5.7. Плюсы блокирующего режима

    5.9. Компонент TIdAntiFreeze

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

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

    5.10. Плюсы неблокирующего режима

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

    5.12. Сопоставление технологий

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

    Для тех, кто никогда не программировал сокеты до Indy, то будет просто и естественно употреблять его. Но для тех кто программировал сокеты ранее, Indy будет камнем преткновения. Так как Indy работает совершенно по другому. Попытка, вообщем то, программировать в Indy этим же самым образом. Это не значит, что остальные решения некорректные, просто Indy работает по другому. Пробовать программировать в Indy так же, как с иными сокетными библиотеками, равносильна попытке приготовить пирожное в микроволновой печи, как в духовке. Результатом будет испорченное пирожное.

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

    Забудьте все,
    что вы знали
    ранее!

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

    5.13. Файлы против сокетов

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

    5.14. Сценарий записи в файл

    Представим, как мы выражаемся, обычный сценарий записи в файл. Так как данная процедура чрезвычайно обычная, то она чрезвычайно подступает демонстрации.
    1. Открыть файл
    2. Записать данные
    3. Закрыть файл

    5.15. Блокирующий режим записи файла

    Блокирующая запись в файл смотрится последующим образом:

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

    5.16. Неблокирующий режим записи файла

    Потратим, как всем известно, незначительно времени, что бы попробовать осознать, что тут делается. Ежели вы используете неблокирующие сокеты, то вы должны просто наконец-то осознавать данный код. Это приблизительно последующее:
    1. При вызове Button1Click раскрывается файл. Способ Open немедля так сказать вернет управление в програмку, но файл еще не открыт и нельзя с ним еще нельзя работать.
    2. Обработчик действия OnOpen будет возбужден, когда файл, в конце концов, будет открыть и готов к работе. Делается попытка записать данные в файл, но все данные еще не акцептированы. Способ Write вернет количество записанных б. Оставшиеся данные будут сохранены позднее.
    3. Обработчик действия OnWrite будет возбужден, когда файл будет готов, мягко говоря, воспринять последующую порцию данных, и способ Write будет повторяться для оставшихся данных.
    4. Шаг 3 повторяется до того времени, пока все данные не наконец-то будут записаны способом Write. По окончанию записи всех данных вызывается способ Close. Но файл еще пока не закрыт.
    5. The OnClose event is fired. The file is now closed.

    5.17. Сопоставление записи файлов

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

    Для блокирующего примера, просто откройте, записывайте данные, и закройте файл когда нужно:
    • 3 File1 действия
    • 1 поле в Form

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

    5.18. Практически как файлы

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

    with IndyClient do
    begin
    Connect;
    Try
    WriteLn('Hello World.');
    finally
    Disconnect;
    end;
    end;

    Как вы сможете созидать, Indy в реальности чрезвычайно похож работе с файлами. Способ Connect замещает функцию Open, а способ Disconnect замещает функцию Close. Ежели вы думаете и признаете сокеты как чтение и запись в файл, то для вас будет применять Indy до боли просто.

    Читайте также: