Выберите вызовы posix для работы с сокетами

Обновлено: 01.07.2024

Значительное количество вызовов сокетов, таких как accept() и recv(), блокируются. Это может создать проблему для реальных сетевых приложений, где сервер сокетов должен обрабатывать большое количество клиентов. Легко видеть, что при большом количестве клиентов мы в конечном итоге будем блокироваться большую часть времени и, следовательно, вряд ли сможем масштабироваться! Обойти эту проблему можно с помощью вызова сокета select() -- select() позволяет нам отслеживать большое количество сокетов, все за один раз, без необходимости индивидуальной блокировки для каждого сокета.

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

Метод select() принимает на вход битовую маску (структура fd_set), где мы устанавливаем биты, соответствующие набору дескрипторов файлов. Не беспокойтесь — уровень сокетов предоставляет удобные макросы для установки этих битов и проверки этих битов, поэтому нам вообще не нужно беспокоиться о работе с битами. Разве это не облегчение!

После того как мы передаем fd_set с набором битов для каждого файлового дескриптора, вызов select() отслеживает их все. Если есть событие для любого из этих дескрипторов, то он немедленно возвращается и информирует приложение о том, что данный fd имеет событие, и приложение может действовать соответствующим образом. Мы можем найти fd (или fds) с событием, проверив, установлен ли соответствующий бит для переданных файловых дескрипторов.

Прежде чем мы двинемся дальше, самое время взглянуть на сигнатуру вызова select().

Вызов select() принимает несколько аргументов. Первый аргумент — это наивысший дескриптор файла плюс один. Таким образом, если мы передаем два файловых дескриптора со значениями 2 и 10, то параметр nfds должен быть 10 + 1 или 11, а не 2. Максимальное количество сокетов, поддерживаемых select(), имеет верхний предел, представленный FD_SETSIZE (обычно 1024). ). Для более простых программ передачи FD_SETSIZE в качестве nfds должно быть более чем достаточно!.

Следующие три параметра представляют три разных типа событий, отслеживаемых функцией select(): чтение, запись и события-исключения. Событие чтения означает, что для данного fd либо есть данные для чтения (поэтому приложение может вызвать recv()), либо установлено новое соединение (поэтому приложение может вызвать accept()). Событие записи означает, что для данного fd локальный буфер отправки стал непустым и приложение может отправить больше данных. Событие-исключение означает, что произошло какое-то событие-исключение, например получение внеполосных данных.

Эти три параметра являются указателями на значения fd_set, один для чтения, один для записи и третий для исключения. Приложение не обязательно должно передавать все эти fd_sets. Например, если приложение заинтересовано только в наблюдении за событиями чтения, оно может передать только чтение fd_set и передать два других как NULL. Вызовы select отслеживают все файловые дескрипторы, указанные в трех битовых масках fd_set.

Шестым и последним аргументом select() является значение времени ожидания в форме указателя на структуру timeval. Первое поле, tv_sec, хранит количество полных секунд прошедшего времени. Второе поле, tv_usec, хранит оставшееся прошедшее время (доли секунды) в виде микросекунд. Если мы передаем в это поле значение NULL, то select() ожидает событий неопределенно долго. В противном случае, если мы делаем тайм-аут выбора через определенное время, нам нужно передать ему ненулевое значение timeval.

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

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

В приведенном выше примере используется массив all_connections для хранения информации о различных сокетах. Это необходимо, поскольку мы имеем дело с несколькими сокетами. Далее в примере создается серверный сокет (используя последовательность вызовов socket(), bind() и listen()) путем вызова create_tcp_server_socket(), а затем сохраняет возвращенный файловый дескриптор в качестве первого элемента в массиве all_connections. Далее, как и когда мы получаем новые входящие соединения, мы также сохраняем их fds в этом массиве.

Чтобы облегчить поиск пустых слотов в массиве all_connections, мы инициализируем его значением -1. И когда соединение обрывается, мы сбрасываем его индекс обратно на -1. Мы используем массив all_connections для установки битов в значении read_fd_set с помощью макроса FD_SET(). Для простоты мы передаем fd_set только для событий чтения и NULL для событий записи и исключений.

Приведенная выше программа передает NULL в качестве тайм-аута для вызова select(). Однако, если приложение хочет установить тайм-аут, оно может определить значение тайм-аута следующим образом, а затем передать «&timeout» в качестве последнего параметра аргумента. Например, в следующем примере для тайм-аута задается значение 30,5 секунд.

Программа находится в цикле while(), и каждый проход начинается с заполнения read_fd_set соединениями, присутствующими в массиве all_connections. Далее программа блокируется на select() и начинается игра ожидания! Когда у нас есть входящее соединение или некоторые данные о существующем соединении, функция select() возвращается. Так как при return() вызов select() обновляет read_fd_set, сначала очищая все биты, а затем устанавливая только те биты, которые имеют события чтения. Мы используем макрос FD_ISSET, чтобы узнать установленные соединения.

Для слушателя fd, который является первым элементом массива all_connections, событие чтения означает, что есть ожидающее новое соединение. И когда это произойдет, мы можем вызвать accept() и сохранить возвращенный файловый дескриптор нового соединения в массиве all_connections. Поскольку возвращаемое значение select() — это общее количество файловых дескрипторов, готовых к событию, мы уменьшаем значение ret_val на единицу. Если ret_val равно нулю, это означает, что был готов только один файловый дескриптор, поэтому мы продолжаем.

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

Для неслушающего fd, если полученные байты равны 0, это означает, что соединение закрыто, и мы удаляем его из массива all_connections, чтобы на следующем проходе мы не выполняли select() для закрытое соединение.

Было бы хорошо отметить, что в приведенной выше программе используются два цикла: один для обхода массива и установки каждого файлового дескриптора в read_fd_set, а другой для поиска возвращаемого файлового дескриптора из вызова select(). Время выполнения каждого из двух циклов составляет O(n) — очевидно, мы можем добиться большего. Одним из способов улучшить сложность времени выполнения было бы использование хэш-таблицы, чтобы цикл поиска стал быстрее. Средняя временная сложность хеш-таблицы составляет O(1), поэтому для более высоких рабочих нагрузок это будет намного быстрее. Если мы хотим ускорить оба цикла, нам придется использовать структуру данных, оптимизированную как для обхода, так и для поиска. Мы оставим это в качестве упражнения для читателя!

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

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

Из этого вывода видно, что прослушиватель создается с файловым дескриптором 3, а затем использует select() для ожидания входящих подключений. Когда появляется первый клиент, функция select() возвращается, и мы принимаем первое входящее соединение, присваивая ему файловый дескриптор 4. После этого мы выполняем select() и блокируем только для того, чтобы нас разбудило входящее соединение следующего клиента, которое получает fd из 5. В этот момент мы вызываем select, устанавливая 3 fd в fd_set: 3, 4 и 5. Когда мы получаем данные о новых соединениях, select() возвращается, и мы читаем данные. Как только соединение закрывается, функция select() возвращает значение (когда вызов recv() возвращает 0), и мы закрываем соединение. А функция select() будет вечно ждать новых подключений.

Вывод для обоих клиентов одинаков:

Следует отметить, что в этом примере функция select() используется для сокета с установлением соединения (TCP), но select() также может использоваться для сокетов без установления соединения (UDP).Если есть несколько прослушивателей UDP (серверов), то мы всегда можем использовать select() для блокировки всех из них, а когда мы получаем событие чтения, мы можем использовать recvfrom() для чтения входящих данных.

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

Операция select() и pselect() идентична с тремя отличиями:

< td valign="bottom">select() может обновить аргумент timeout, чтобы указать, сколько времени осталось. pselect() не изменяет этот аргумент.
Серийный номерОписание
(i)select() использует тайм-аут, который представляет собой struct timeval (с секундами и микросекундами) , тогда как pselect() использует struct timespec (с секундами и наносекундами).
(ii)
(iii) select() не имеет sigmask и ведет себя как pselect(), вызванный с NULL sigmask.

Отслеживаются три независимых набора файловых дескрипторов. Те, которые перечислены в readfds, будут отслеживаться, чтобы увидеть, станут ли символы доступными для чтения (точнее, чтобы увидеть, не будет ли чтение заблокировано; в частности, файловый дескриптор также готов в конце файла). ), те, что в writefds, будут отслеживаться, чтобы убедиться, что запись не будет заблокирована, а те, что в exceptfds, будут отслеживаться на наличие исключений. При выходе наборы изменяются на месте, чтобы указать, какие файловые дескрипторы фактически изменили статус. Каждый из трех наборов дескрипторов файлов может быть указан как NULL, если дескрипторы файлов не должны отслеживаться для соответствующего класса событий.

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

FD_ZERO() очищает набор.

FD_SET() и

FD_CLR() соответственно добавляет и удаляет заданный файловый дескриптор из набора.

FD_ISSET() проверяет, является ли дескриптор файла частью набора;

это полезно после возврата из select().

nfds – это файловый дескриптор с наибольшим номером в любом из трех наборов плюс 1.

timeout – это верхняя граница времени, прошедшего до возврата из select(). Он может быть равен нулю, что приведет к немедленному возврату функции select(). (Это полезно для опроса.) Если timeout равен NULL (без тайм-аута), select() может блокироваться бесконечно.

sigmask — указатель на маску сигнала (см. sigprocmask(2)); если оно не равно NULL, то pselect() сначала заменяет текущую маску сигнала той, на которую указывает sigmask, затем выполняет функцию «выбрать», а затем восстанавливает исходную маску сигнала.

Помимо разницы в точности аргумента timeout, следующий вызов pselect():

эквивалентно атомарному выполнению следующих вызовов:

Причина, по которой pselect() необходима, заключается в том, что если кто-то хочет дождаться сигнала или готовности файлового дескриптора, то для предотвращения условий гонки необходим атомарный тест. (Предположим, что обработчик сигнала устанавливает глобальный флаг и возвращается. Тогда проверка этого глобального флага, за которой следует вызов select(), может зависнуть на неопределенное время, если сигнал поступает сразу после проверки, но непосредственно перед вызовом. Напротив, pselect() позволяет сначала блокировать сигналы, обрабатывать пришедшие сигналы, а затем вызывать pselect() с желаемым sigmask, избегая гонки.)

Время ожидания

Участвующие временные структуры определены и выглядят так

(Однако см. ниже версии POSIX.1-2001.). Некоторый код вызывает select() со всеми тремя пустыми наборами, n нулем и тайм-аутом, отличным от NULL, как достаточно переносимый способ заснуть с точностью до доли секунды.

В Linux select() изменяет timeout, чтобы отразить количество времени без сна; большинство других реализаций этого не делают. (POSIX.1-2001 допускает любое поведение.) Это вызывает проблемы как тогда, когда код Linux, который читает timeout, портируется на другие операционные системы, так и когда код портируется на Linux, который повторно использует struct timeval для множественного выбора ()s в цикле без повторной инициализации. Считайте, что timeout не определен после возврата из select().

В случае успеха select() и pselect() возвращают количество файловых дескрипторов, содержащихся в трех возвращенных наборах дескрипторов (то есть общее количество битов, установленных в readfds, writefds, exceptfds), которые могут быть равны нулю, если тайм-аут истекает до того, как произойдет что-то интересное. В случае ошибки возвращается -1 и errno устанавливается соответствующим образом; наборы и timeout становятся неопределенными, поэтому не полагайтесь на их содержимое после ошибки.

МИР ИЗБРАННЫХ()

Один из традиционных способов написания сетевых серверов – использовать блок main server для accept() в ожидании соединения. Как только соединение установлено, сервер выполняет fork(), дочерний процесс обрабатывает соединение, а главный сервер может обслуживать новые входящие запросы.

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

Поэтому одним из основных преимуществ использования select() является то, что вашему серверу потребуется только один процесс для обработки всех запросов. Таким образом, вашему серверу не потребуется общая память или примитивы синхронизации для взаимодействия различных «задач».

Одним из основных недостатков использования select() является то, что ваш сервер не может действовать так, как будто существует только один клиент, как в случае с решением fork(). Например, с решением fork() ing после server fork() дочерний процесс работает с клиентом, как если бы во вселенной был только один клиент — дочернему процессу не нужно беспокоиться о новых входящих подключениях. или наличие других сокетов. С select() программирование не такое прозрачное.

Хорошо, а как использовать select() ?

select() работает путем блокировки до тех пор, пока что-то не произойдет с файловым дескриптором (он же сокет). Что такое «что-то»? Входящие данные или возможность записи в файловый дескриптор — вы указываете select(), что вы хотите разбудить. Как вы это говорите? Вы заполняете структуру fd_set некоторыми макросами.

Большинство серверов на основе select() выглядят практически одинаково:

Заполните структуру fd_set файловыми дескрипторами, которые вы хотите знать при поступлении данных. Заполните структуру fd_set дескрипторами файлов, которые вы хотите знать, когда вы можете писать. Вызовите select() и заблокируйте, пока что-то не произойдет. После возврата select() проверьте, не был ли какой-либо из ваших файловых дескрипторов причиной вашего пробуждения. Если это так, «обслуживайте» этот файловый дескриптор любым способом, который требуется вашему серверу (например, считывайте запрос на веб-страницу). Повторяйте этот процесс вечно.

Бросьте псевдокод, покажите мне настоящий код!

Хорошо, давайте взглянем на образец сервера (оригинал), включенный в часто задаваемые вопросы по программированию сокетов Вика Меткалфа (мои комментарии выделены красным):

Хорошо, возможно, это был не лучший пример.

Есть предложения? Исправления? Пожалуйста, дайте мне знать.

А пока вот несколько ссылок на другие источники информации о select() :

Часто задаваемые вопросы о сокетах Вопрос о select()
Прямо из отличного часто задаваемых вопросов о сокетах.

Примеры часто задаваемых вопросов по программированию сокетов Unix
Вышеприведенный пример кода nbserver.c и другие сведения о сокетах.


Проблема с select()

Начнем с рассмотрения дизайна и реализации API select(). Системный вызов объявляется следующим образом: fd_set — это просто растровое изображение; максимальный размер (в битах) этих растровых изображений является наибольшим допустимым значением дескриптора файла, который является системным параметром. readfds , writefds , и excludefds являются входящими и исходящими аргументами, соответственно, соответствующими наборам файловых дескрипторов, которые "интересны" для чтения, записи и исключительных условий. Данный файловый дескриптор может быть более чем в одном из этих наборов. Аргумент nfds дает наибольший фактически используемый индекс растрового изображения. Аргумент тайм-аута определяет, будет ли возвращаться функция select(), и если да, то как скоро, если ни один файловый дескриптор не будет готов.

Перед вызовом метода select() приложение создает одно или несколько растровых изображений readfds, writefds или excludefds, утверждая биты, соответствующие набору интересующих файловых дескрипторов. По возвращении select() перезаписывает эти растровые изображения новыми значениями, соответствующими подмножествам входных наборов, указывая, какие файловые дескрипторы доступны для ввода-вывода. Член набора readfds доступен, если есть какие-либо доступные входные данные; член writefds считается доступным для записи, если доступное пространство буфера превышает системный параметр (обычно 2048 байт для сокетов TCP). Затем приложение сканирует результирующие растровые изображения, чтобы обнаружить файловые дескрипторы, доступные для чтения или записи, и обычно вызывает обработчики для этих дескрипторов.

Рисунок 2 представляет собой упрощенный пример того, как приложение обычно использует select() . Один из нас показал [15], что используемый здесь стиль программирования совершенно неэффективен для большого количества файловых дескрипторов, независимо от проблем с select() . Например, построение входных растровых изображений (строки с 8 по 12 на рис. 2) не должно выполняться явно перед каждым вызовом select(); вместо этого приложение должно поддерживать теневые копии входных растровых изображений и просто копировать эти тени в readfds и writefds. Кроме того, сканирование результирующих растровых изображений, которые обычно довольно разрежены, лучше всего выполнять пословно, а не побитно.

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

Другие аспекты реализации select() также плохо масштабируются. Райт и Стивенс подробно обсуждают реализацию 4.4BSD[23]; ограничимся эскизом. В традиционной реализации функция select() начинает с проверки для каждого дескриптора, присутствующего во входных растровых изображениях, доступен ли этот дескриптор для ввода-вывода. Если ни один из них не доступен, то select() блокирует. Позже, когда состояние модуля обработки протокола (или файловой системы) изменяется, чтобы сделать дескриптор доступным для чтения или записи, этот модуль пробуждает заблокированный процесс.

В традиционной реализации пробужденный процесс не знает, какой дескриптор только что стал доступным для чтения или записи, поэтому он должен повторить свое первоначальное сканирование. Это досадно, потому что протокольный модуль наверняка знал, какой сокет или файл изменил состояние, но эта информация не сохраняется. В нашей предыдущей работе по улучшению производительности select()[4] мы показали, что довольно легко сохранить эту информацию и тем самым повысить производительность select() в случае блокировки.

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

Таблица 1: Профиль модифицированного ядра и модифицированного Squid на живом прокси

65,43% 100,00% все время, кроме простоя ядро< /TD> 34,57% все время простоя ядро 16,02% 24,49% все выбранные функции ядро 9,42% 14,40% выбрать ядро 3,71% 5,67% new_soo_select ядро 2,82% 4,31% new_selscan_one ядро 0,03% 0,04% новое_открытие_сканирования ядро 15,45% 23,61% код, связанный с malloc пользователь 4,10% 6,27% in_pcblookup ядро 2,88% 4,40% все функции TCP ядро 0,94% 1,44% memCopy пользователь 0,92% 1,41% memset пользователь 0,88% 1,35% bcopy ядро 0,84% 1,28% read_io_port ядро 0,72% 1,10% _doprnt пользователь 0,36% 0,54% < TD ALIGN="LEFT">comm_select пользователь

В нашей предыдущей работе мы тестировали наши модификации, используя операционную систему Digital UNIX V4.0B и прокси-программу Squid версии 1.1.20[5,18]. После того, как мы сделали все возможное, чтобы улучшить реализацию select() в ядре и реализацию процедуры, которая вызывает select() в Squid, мы измерили производительность системы на загруженном некэширующем прокси-сервере, подключенном к Интернету и обрабатывающем более 2,5 миллионов запросов. день.

Мы обнаружили, что примерно удвоили эффективность системы (выраженную как время ЦП на запрос), но на select() по-прежнему приходится почти 25 % общего времени ЦП. В таблице 1 показан профиль активности ЦП ядра и пользовательского режима, созданный с помощью инструментов DCPI [1] в течение обычного часа работы с высокой нагрузкой.

В профиле comm_select() процедура пользовательского режима, которая создает входные растровые изображения для select() и сканирует выходные растровые изображения, занимает всего 0,54 % небездействующего времени процессора. Некоторые из 2.85%, связанные с memCopy() и memSet(), также должны быть отнесены на создание входных растровых изображений (поскольку модифицированный Squid использует метод теневого копирования). (Профиль также показывает много времени, затраченного на процедуры, связанные с malloc(); будущая версия Squid будет использовать предварительно выделенные пулы, чтобы избежать накладных расходов на слишком много вызовов malloc() и free() [22].)

Однако основная часть накладных расходов, связанных с select(), приходится на код ядра и составляет около двух третей общего времени процессора, не занятого в режиме ядра. Более того, это измерение отражает реализацию select(), которую мы уже улучшили настолько, насколько мы считали возможным. Наконец, наша реализация не смогла избежать затрат, зависящих от количества дескрипторов, подразумевая, что накладные расходы, связанные с select(), масштабируются хуже, чем линейно. Тем не менее, эти затраты, по-видимому, не были связаны с действительно полезной работой. Мы решили разработать масштабируемую замену select() .

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