Текст книги "UNIX: разработка сетевых приложений"
Автор книги: Уильям Ричард Стивенс
Соавторы: Эндрю М. Рудофф,Билл Феннер
Жанр:
ОС и Сети
сообщить о нарушении
Текущая страница: 17 (всего у книги 88 страниц) [доступный отрывок для чтения: 32 страниц]
Мы говорили об ожидании готовности дескриптора для ввода-вывода (чтения или записи) или возникновения исключительной ситуации, требующей обработки (внеполосные данные). В то время как готовность к чтению и записи очевидна для файловых дескрипторов, в случае дескрипторов сокетов следует более внимательно изучить те условия, при которых функция select
сообщает, что сокет готов (см. рис. 16.52 [128]).
1. Сокет готов для чтения, если выполнено хотя бы одно из следующих условий:
1) число байтов данных в приемном буфере сокета больше или равно текущему значению минимального количества данных (low water-mark) для приемного буфера сокета. Операция считывания данных из сокета не блокируется и возвратит значение, большее нуля (то есть данные, готовые для чтения). Мы можем задать значение минимального количества данных (low-water mark) с помощью параметра сокета SO_RCVLOWAT
. По умолчанию для сокетов TCP и UDP это значение равно 1;
2) на противоположном конце соединение закрывается (нами получен сегмент FIN). Операция считывания данных из сокета не блокируется и возвратит нуль (то есть признак конца файла);
3) сокет является прослушиваемым, и число установленных соединений ненулевое. Функция accept
на прослушиваемом сокете в таком случае обычно не блокируется, хотя в разделе 16.6 мы описываем ситуацию, в которой функция accept может заблокироваться несмотря на наличие установленных соединений;
4) ошибка сокета, ожидающая обработки. Операция чтения на сокете не блокируется и возвратит ошибку (-1) со значением переменной errno
, указывающим на конкретное условие ошибки. Эти ошибки, ожидающие обработки, можно также получить, вызвав функцию getsockopt
с параметром SO_ERROR,
после чего состояние ошибки будет сброшено.
2. Сокет готов для записи, если выполнено одно из следующих условий:
1) количество байтов доступного пространства в буфере отправки сокета больше или равно текущему значению минимального количества данных для буфера отправки сокета илибо сокет является присоединенным, либо сокету не требуется соединения (например, сокет UDP). Это значит, что если мы отключим блокировку для сокета (см. главу 16), операция записи не заблокирует процесс и возвратит положительное значение (например, число байтов, принятых транспортным уровнем). Устанавливать минимальное количество данных мы можем с помощью параметра сокета SO_SNDLOWAT
. По умолчанию это значение равно 2048 для сокетов TCP и UDP;
2) получатель, которому отправляются данные, закрывает соединение. Операция записи в сокет сгенерирует сигнал SIGPIPE
(см. раздел 5.12);
3) ошибка сокета, ожидающая обработки. Операция записи в сокет не блокируется и возвратит ошибку (-1) со значением переменной errno
, указывающей на конкретное условие ошибки. Эти ошибки, ожидающие обработки, можно также получить и сбросить, вызвав функцию getsockopt
с параметром сокета SO_ERROR
.
3. Исключительная ситуация, требующая обработки, может возникнуть на сокете в том случае, если приняты внеполосные данные либо если отметка вне– полосных данных в принимаемом потоке еще не достигнута. (Внеполосные данные описываются в главе 24.)
ПРИМЕЧАНИЕ
Наши определения «готов для чтения» и «готов для записи» взяты непосредственно из макроопределений ядра soreadable и sowritable (которые описываются в [128, с. 530-531]). Аналогично, наше определение «исключительной ситуации» взято из функции soo_select, которая описана там же.
Обратите внимание, что когда происходит ошибка на сокете, функция select
отмечает его готовым как для чтения, так и для записи.
Значения минимального количества данных (low-water mark) для приема и отправки позволяют приложению контролировать, сколько данных должно быть доступно для чтения или сколько места должно быть доступно для записи перед тем, как функция select
сообщит, что сокет готов для чтения или записи. Например, если мы знаем, что наше приложение не может сделать ничего полезного, пока не будет получено как минимум 64 байт данных, мы можем установить значение минимального количества данных равным 64, чтобы функция select
не вывела нас из состояния ожидания, если для чтения готово менее 64 байт.
Пока значение минимального количества данных для отправки в сокете UDP меньше, чем буфер отправки сокета (а такое отношение между ними всегда устанавливается по умолчанию), сокет UDP всегда готов для записи, поскольку соединения не требуется.
В табл. 6.1 суммируются описанные выше условия, при которых сокет становится готовым для вызова функции select
.
Таблица 6.1. Условия, при которых функция select сообщает, что сокет готов для чтения или для записи либо, что необходима обработка исключительной ситуации
Данные для чтения | • | ||
Считывающая половина соединения закрыта | • | ||
Для прослушиваемого сокета готово новое соединение | • | ||
Пространство, доступное для записи | • | ||
Записывающая половина соединения закрыта | • | ||
Ошибка, ожидающая обработки | • | • | |
Внеполосные данные TCP | • |
Ранее мы сказали, что большинство приложений не используют много дескрипторов. Например, редко можно найти приложение, использующее сотни дескрипторов. Но такие приложения существуют, и часто они используют функцию select
для мультиплексирования дескрипторов. Когда функция select
была создана, операционные системы обычно имели ограничение на максимальное число дескрипторов для каждого процесса (этот предел в реализации 4.2BSD составлял 31), и функция select
просто использовала тот же предел. Но современные версии Unix допускают неограниченное число дескрипторов для каждого процесса (часто оно ограничивается только количеством памяти и административными правилами), поэтому возникает вопрос: как же теперь работает функция select
?
Многие реализации имеют объявления, аналогичные приведенному ниже, которое взято из заголовочного файла 4.4BSD
:
/*
Значение 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_ xxx
для динамического размещения больших наборов дескрипторов в памяти и для работы с ними. Однако с точки зрения переносимости не стоит злоупотреблять использованием больших наборов дескрипторов.
Теперь мы можем переписать нашу функцию 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]1
Все исходные коды программ, опубликованные в этой книге, вы можете найти по адресу http://www.piter.com.
[Закрыть]представлен исходный код этой версии функции.
Листинг 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 }
Вызов функции select
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) мы значительно увеличили устойчивость клиента.
К сожалению, наша функция 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.
Обычный способ завершить сетевое соединение – вызвать функцию close
. Но у функции close
есть два ограничения, которых лишена функция shutdown
:
1. Функция close последовательно уменьшает счетчик ссылок дескриптора и закрывает сокет, только если счетчик доходит до нуля. Мы рассматривали это в разделе 4.8. Используя функцию shutdown
, мы можем инициировать обычную последовательность завершения соединения TCP (четыре сегмента, начинающихся с FIN, на рис. 2.5) независимо от значения счетчика ссылок.
2. Функция close
завершает оба направления передачи данных – и чтение, и запись. Поскольку соединение TCP является двусторонним, возможны ситуации, когда нам понадобится сообщить другому концу соединения, что мы закончили отправку, даже если на том конце соединения имеются данные для отправки нам. Это случай, рассмотренный в предыдущем разделе при описании работы нашей функции str_cli
в пакетном режиме. На рис. 6.10 показаны типичные вызовы функций в этом сценарии.
Рис. 6.10. Вызов функции shutdown для закрытия половины соединения TCP
#include
int shutdown(int sockfd, int howto);
Возвращает: 0 в случае успешного выполнения, -1 в случае ошибки
Действие функции зависит от значения аргумента howto
.
■ SHUT_RD
. Закрывается считывающая половина соединения: из сокета больше нельзя считывать данные, и все данные, находящиеся в данный момент в буфере приема сокета, сбрасываются. Процесс больше не может выполнять функции чтения из сокета. Любые данные для сокета TCP, полученные после вызова функции shutdown
с этим аргументом, подтверждаются и «молча» игнорируются.
ПРИМЕЧАНИЕ
По умолчанию все, что записывается в маршрутизирующий сокет (см. главу 17), возвращается как возможный ввод на все маршрутизирующие сокеты узла. Некоторые программы вызывают функцию shutdown со вторым аргументом SHUT_RD, чтобы предотвратить получение подобной копии. Другой способ избежать копирования – отключить параметр сокета SO_USELOOPBACK.
■ SHUT_WR
. Закрывается записывающая половина соединения. В случае TCP это называется половинным закрытием(см. раздел 18.5 [111]). Все данные, находящиеся в данный момент в буфере отправки сокета, будут отправлены, а затем будет выполнена обычная последовательность действий по завершению соединения TCP. Как мы отмечали ранее, закрытие записывающей половины соединения выполняется независимо от того, является ли значение в счетчике ссылок дескриптора сокета положительным или нет. Процесс теряет возможность записывать данные в сокет.
■ SHUT_RDWR
. Закрываются и читающая, и записывающая половины соединения. Это эквивалентно двум вызовам функции shutdown
: сначала с аргументом SHUT_RD
, затем – с аргументом SHUT_WR
.
В табл. 7.4 приведены все возможные сценарии, доступные процессу при вызове функций shutdown
и close
. Действие функции close зависит от значения параметра сокета SO_LINGER
.
ПРИМЕЧАНИЕ
Три константы SHUT_xxx определяются в спецификации POSIX. Типичные значения аргумента howto, с которыми вы встретитесь, – это 0 (закрытие читающей половины), 1 (закрытие записывающей половины) и 2 (закрытие обеих половин).