Таблица 6.1. Условия, при которых функция select сообщает, что сокет готов для чтения или для записи либо, что необходима обработка исключительной ситуации
| Условие | Сокет готов для чтения | Сокет готов для записи | Исключительная ситуация |
|---|---|---|---|
| Данные для чтения | • | ||
| Считывающая половина соединения закрыта | • | ||
| Для прослушиваемого сокета готово новое соединение | • | ||
| Пространство, доступное для записи | • | ||
| Записывающая половина соединения закрыта | • | ||
| Ошибка, ожидающая обработки | • | • | |
| Внеполосные данные TCP | • |
Максимальное число дескрипторов для функции select
Ранее мы сказали, что большинство приложений не используют много дескрипторов. Например, редко можно найти приложение, использующее сотни дескрипторов. Но такие приложения существуют, и часто они используют функцию select для мультиплексирования дескрипторов. Когда функция select была создана, операционные системы обычно имели ограничение на максимальное число дескрипторов для каждого процесса (этот предел в реализации 4.2BSD составлял 31), и функция select просто использовала тот же предел. Но современные версии Unix допускают неограниченное число дескрипторов для каждого процесса (часто оно ограничивается только количеством памяти и административными правилами), поэтому возникает вопрос: как же теперь работает функция select?
Многие реализации имеют объявления, аналогичные приведенному ниже, которое взято из заголовочного файла 4.4BSD <sys/types.h>:
/*
Значение FD_SETSIZE может быть определено пользователем,
но заданное здесь по умолчанию
является достаточным в большинстве случаев.
*/
#ifndef FD_SETSIZE
#define FD_SETSIZE 256
#endif
Исходя из этого комментария, можно подумать, что если перед подключением этого заголовочного файла присвоить FD_SETSIZE значение, превышающее 256, то увеличится размер наборов дескрипторов, используемых функцией select. К сожалению, обычно это не действует.
Чтобы понять, в чем дело, обратите внимание, что на рис. 16.53 [128] объявляются три набора дескрипторов внутри ядра, а в качестве верхнего предела используется определенное в ядре значение FD_SETSIZE. Единственный способ увеличить размер наборов дескрипторов — это увеличить значение FD_SETSIZE и затем перекомпилировать ядро. Изменения значения без перекомпиляции ядра недостаточно.
Некоторые производители изменяют свои реализации функции select, с тем чтобы позволить процессу задавать значение FD_SETSIZE, превышающее значение по умолчанию. BSD/OS также изменила реализацию ядра, чтобы допустить большие наборы дескрипторов, кроме того, в ней добавлено четыре новых макроопределения FD_ для динамического размещения больших наборов дескрипторов в памяти и для работы с ними. Однако с точки зрения переносимости не стоит злоупотреблять использованием больших наборов дескрипторов.
6.4. Функция str_cli (продолжение)
Теперь мы можем переписать нашу функцию str_cli, представленную в разделе 5.5 (на этот раз используя функцию select), таким образом, чтобы мы получали уведомление, как только завершится процесс сервера. Проблема с предыдущей версией состояла в том, что процесс мог оказаться заблокированным в вызове функции fgets, когда что-то происходило на сокете. Наша новая версия этой функции вместо этого блокируется в вызове функции select, ожидая готовности для чтения либо стандартного потока ввода, либо сокета. На рис. 6.7 показаны различные условия, обрабатываемые с помощью вызова функции select.
Рис. 6.7. Условия, обрабатываемые функцией select в вызове функции str_cli
Сокет обрабатывает три условия:
1. Если протокол TCP собеседника отправляет данные, сокет становится готовым для чтения, и функция read возвращает положительное значение (то есть число байтов данных).
2. Если протокол TCP собеседника отправляет сегмент FIN (процесс завершается), сокет становится готовым для чтения, и функция read возвращает нуль (признак конца файла).
3. Если TCP собеседника отправляет RST (узел вышел из строя и перезагрузился), сокет становится готовым для чтения, и функция read возвращает -1, а переменная errno содержит код соответствующей ошибки.
В листинге 6.1[1] представлен исходный код этой версии функции.
Листинг 6.1. Реализация функции str_cli с использованием функции select (усовершенствованный вариант находится в листинге 6.2)
//select/strcliselect01.c
1 #include "unp.h"
2 void
3 str_cli(FILE *fp, int sockfd)
4 {
5 int maxfdp1;
6 fd_set rset;
7 char sendline[MAXLINE], recvline[MAXLINE];
8 FD_ZERO(&rset);
9 for (;;) {
10 FD_SET(fileno(fp), &rset);
11 FD_SET(sockfd, &rset);
12 maxfdp1 = max(fileno(fp), sockfd) + 1;
13 Select(maxfdp1, &rset, NULL, NULL, NULL);
14 if (FD_ISSET(sockfd, &rset)) { /* сокет готов для чтения */
15 if (Readline(sockfd, recvline, MAXLINE) == 0)
16 err_quit("str_cli: server terminated prematurely");
17 Fputs(recvline, stdout);
18 }
19 if (FD_ISSET(fileno(fp), &rset)) { /* входное устройство готово для
чтения */
20 if (Fgets(sendline, MAXLINE, fp) == NULL)
21 return; /* все сделано */
22 Writen(sockfd, sendline, strlen(sendline));
23 }
24 }
25 }
8-13 Нам нужен только один набор дескрипторов — для проверки готовности сокета для чтения. Этот набор дескрипторов инициализируется макросом FD_ZERO, после чего с помощью макроса FD_SET устанавливаются два бита: бит, соответствующий указателю файла fp стандартного потока ввода-вывода, и бит, соответствующий дескриптору сокета sockfd. Функция fileno преобразует указатель файла стандартного потока ввода-вывода в соответствующий ему дескриптор. Функция select (а также poll) работает только с дескрипторами.
Функция select вызывается после определения максимального из двух дескрипторов. В этом вызове указатель на набор дескрипторов для записи и указатель на набор дескрипторов с исключениями являются пустыми. Последний аргумент (ограничение по времени) также является пустым указателем, поскольку мы хотим, чтобы процесс был блокирован, пока не будут готовы данные для чтения.
14-18 Если по завершении функции select сокет готов для чтения, отраженная строка считывается функцией readline и выводится функцией fputs.
19-23 Если стандартный поток ввода готов для чтения, строка считывается функцией fgets и записывается в сокет с помощью функции writen.
Обратите внимание, что используются те же четыре функции ввода-вывода, что и в листинге 5.4: fgets, writen, readline и fputs, но порядок их следования внутри функции str_cli изменился. Раньше выполнение функции str_cli определялось функцией fgets, а теперь ее место заняла select. С помощью всего нескольких дополнительных строк кода (сравните листинги 6.1 и 5.4) мы значительно увеличили устойчивость клиента.
6.5. Пакетный ввод
К сожалению, наша функция str_cli все еще не вполне корректна. Сначала вернемся к ее исходной версии, приведенной в листинге 5.4. Эта функция работает в режиме остановки и ожидания (stop-and-wait mode), что удобно для интерактивного использования: функция отправляет строку серверу и затем ждет его ответа. Время ожидания складывается из одного периода обращения (RTT) и времени обработки сервером (которое близко к нулю в случае простого эхо-сервера). Следовательно, мы можем предположить, сколько времени займет отражение данного числа строк, если мы знаем время обращения (RTT) между клиентом и сервером.
Измерить RTT позволяет утилита ping. Если мы измерим с ее помощью время обращения к connix.com с нашего узла solaris, то средний период RTT после 30 измерений будет равен 175 мс. В [111, с. 89] показано, что это справедливо для дейтаграммы IP длиной 84 байт. Если мы возьмем первые 2000 строк файла termcap Solaris 2.5, то итоговый размер файла будет равен 98 349 байт, то есть в среднем 49 байт на строку. Если мы добавим размеры заголовка IP (20 байт) и заголовка TCP (20 байт), то средний сегмент TCP будет составлять 89 байт, почти как размер пакета утилиты ping. Следовательно, мы можем предположить, что общее время составит около 350 с для 2000 строк (2000×0,175 с). Если мы запустим наш эхо-клиент TCP из главы 5, действительное время получится около 354 с, что очень близко к нашей оценке.
Если считать, что сеть между клиентом и сервером является двусторонним каналом, когда запросы идут от клиента серверу, а ответы в обратном направлении, то получится изображенный на рис. 6.8 режим остановки и ожидания.
Рис. 6.8. Временная диаграмма режима остановки и ожидания: интерактивный ввод
Запрос отправляется клиентом в нулевой момент времени, и мы предполагаем, что время обращения RTT равно 8 условным единицам. Ответ, отправленный в момент времени 4, доходит до клиента в момент времени 7. Мы также считаем, что время обработки сервером нулевое и что размер запроса равен размеру ответа. Мы показываем только пакеты данных между клиентом и сервером, игнорируя подтверждения TCP, которые также передаются по сети.
Но поскольку между отправкой пакета и его приходом на другой конец канала существует задержка и канал является двусторонним, в этом примере мы используем только восьмую часть вместимости канала. Режим остановки и ожидания удобен для интерактивного ввода, но поскольку наш клиент считывает данные из стандартного потока ввода и записывает в стандартный поток вывода, а перенаправление ввода и вывода выполнить в интерпретаторе команд крайне просто, мы легко можем запустить наш клиент в пакетном режиме. Однако когда мы перенаправляем ввод и вывод, получающийся файл вывода всегда меньше файла ввода (а для эхо-сервера требуется их идентичность).
Чтобы понять происходящее, обратите внимание, что в пакетном режиме мы отправляем запросы так быстро, как их может принять сеть. Сервер обрабатывает их и отправляет обратно ответы с той же скоростью. Это приводит к тому, что в момент времени 7 канал целиком заполнен, как показано на рис. 6.9.
Рис. 6.9. Заполнение канала между клиентом и сервером: пакетный режим
Предполагается, что после отправки первого запроса мы немедленно посылаем другой запрос и т.д. Также предполагается, что мы можем отправлять запросы с той скоростью, с какой сеть способна их принимать, и обрабатывать ответы так быстро, как сеть их поставляет.
Существуют различные нюансы, имеющие отношение к передаче большого количества данных TCP (bulk data flow), которые мы здесь игнорируем. К ним относятся алгоритм медленного запуска (slow start algorithm), ограничивающий скорость, с которой данные отправляются на новое или незанятое соединение, и возвращаемые сегменты ACK. Все эти вопросы рассматриваются в главе 20 [111].
Чтобы увидеть, в чем заключается проблема с нашей функцией str_cli, представленной в листинге 6.1, будем считать, что файл ввода содержит только девять строк. Последняя строка отправляется в момент времени 8, как показано на рис. 6.9. Но мы не можем закрыть соединение после записи этого запроса, поскольку в канале еще есть другие запросы и ответы. Причина возникновения проблемы кроется в нашем способе обработки конца файла при вводе, когда процесс возвращается в функцию main, которая затем завершается. Но в пакетном режиме конец файла при вводе не означает, что мы закончили читать из сокета — в нем могут оставаться запросы к серверу или ответы от сервера.
Нам нужен способ закрыть одну половину соединения TCP. Другими словами, мы хотим отправить серверу сегмент FIN, тем самым сообщая ему, что закончили отправку данных, но оставляем дескриптор сокета открытым для чтения. Это делается с помощью функции shutdown, которая описывается в следующем разделе.
Вообще говоря, буферизация ввода-вывода для повышения производительности приводит к усложнению сетевых приложений (от чего пострадала и программа в листинге 6.1). Рассмотрим пример, в котором из стандартного потока ввода считывается несколько строк текста. Функция select передаст управление строке 20, в которой функция fgets считает доступные данные в буфер библиотеки stdio. Однако эта функция возвратит приложению только одну строку, а все остальные так и останутся в буфере. Считанная строка будет отправлена серверу, после чего будет снова вызвана функция select, которая будет ждать появления новых данных в стандартном потоке ввода несмотря на наличие еще не обработанных строк в буфере stdio. Причина в том, что select ничего не знает о буферах stdio и сообщает о доступности дескриптора для чтения с точки зрения системного вызова read, а не библиотечного вызова fgets. По этой причине использование fgets и select в одной программе считается опасным и требует особой осторожности.
Та же проблема связана с вызовом readline в листинге 6.1. Теперь данные скрываются от функции select уже не в буфере stdio, а в буфере readline. Вспомните, что в разделе 3.9 мы создали функцию, проверявшую состояние буфера readline. Мы могли бы воспользоваться ею перед вызовом select, чтобы проверить, нет ли в буфере readline данных, дожидающихся обработки. Наша программа усложнится еще больше, если мы допустим, что буфер readline может содержать лишь часть строки (то есть нам придется дожидаться считывания этой строки целиком).
Проблемы буферизации мы постараемся решить в усовершенствованной версии str_cli в разделе 6.7.
6.6. Функция shutdown
Обычный способ завершить сетевое соединение — вызвать функцию close. Но у функции close есть два ограничения, которых лишена функция shutdown:
1. Функция close последовательно уменьшает счетчик ссылок дескриптора и закрывает сокет, только если счетчик доходит до нуля. Мы рассматривали это в разделе 4.8. Используя функцию shutdown, мы можем инициировать обычную последовательность завершения соединения TCP (четыре сегмента, начинающихся с FIN, на рис. 2.5) независимо от значения счетчика ссылок.
2. Функция close завершает оба направления передачи данных — и чтение, и запись. Поскольку соединение TCP является двусторонним, возможны ситуации, когда нам понадобится сообщить другому концу соединения, что мы закончили отправку, даже если на том конце соединения имеются данные для отправки нам. Это случай, рассмотренный в предыдущем разделе при описании работы нашей функции str_cli в пакетном режиме. На рис. 6.10 показаны типичные вызовы функций в этом сценарии.
Рис. 6.10. Вызов функции shutdown для закрытия половины соединения TCP
#include <sys/socket.h>
int shutdown(int
Действие функции зависит от значения аргумента howto.
■ SHUT_RD. Закрывается считывающая половина соединения: из сокета больше нельзя считывать данные, и все данные, находящиеся в данный момент в буфере приема сокета, сбрасываются. Процесс больше не может выполнять функции чтения из сокета. Любые данные для сокета TCP, полученные после вызова функции shutdown с этим аргументом, подтверждаются и «молча» игнорируются.
По умолчанию все, что записывается в маршрутизирующий сокет (см. главу 17), возвращается как возможный ввод на все маршрутизирующие сокеты узла. Некоторые программы вызывают функцию shutdown со вторым аргументом SHUT_RD, чтобы предотвратить получение подобной копии. Другой способ избежать копирования — отключить параметр сокета SO_USELOOPBACK.
■ SHUT_WR. Закрывается записывающая половина соединения. В случае TCP это называется
■ SHUT_RDWR. Закрываются и читающая, и записывающая половины соединения. Это эквивалентно двум вызовам функции shutdown: сначала с аргументом SHUT_RD, затем — с аргументом SHUT_WR.
В табл. 7.4 приведены все возможные сценарии, доступные процессу при вызове функций shutdown и close. Действие функции close зависит от значения параметра сокета SO_LINGER.
Три константы SHUT_xxx определяются в спецификации POSIX. Типичные значения аргумента howto, с которыми вы встретитесь, — это 0 (закрытие читающей половины), 1 (закрытие записывающей половины) и 2 (закрытие обеих половин).