UNIX: разработка сетевых приложений — страница 18 из 42

Неблокируемый ввод-вывод

16.1. Введение

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

1. Операции ввода: функции

read
,
readv
,
recv
,
recvfrom
и
recvmsg
. Если мы вызываем одну из этих функций ввода для блокируемого сокета TCP (а по умолчанию такой сокет является блокируемым) и в приемном буфере сокета отсутствуют данные, то сокет вызывает переход в спящее состояние на то время, пока не придут какие-нибудь данные. Поскольку TCP является протоколом байтового потока, из этого состояния мы выйдем, когда придет «хоть сколько- нибудь» данных: это может быть одиночный байт, а может быть и целый сегмент данных TCP. Если мы хотим ждать до тех пор, пока не будет доступно определенное фиксированное количество данных, мы вызываем нашу функцию
readn
(см. листинг 3.9) или задаем флаг
MSG_WAITALL
(см. табл. 14.1). Поскольку UDP является протоколом дейтаграмм, то если приемный буфер блокируемого сокета UDP пуст, мы переходим в состояние ожидания и находимся в нем до тех пор, пока не придет дейтаграмма UDP.

В случае неблокируемого сокета при невозможности удовлетворить условию операции ввода (как минимум 1 байт данных для сокета TCP или целая дейтаграмма для сокета UDP) возврат происходит немедленно с ошибкой

EWOULDBLOCK
.

2. Операции вывода: функции

write
,
writev
,
send
,
sendto
, и
sendmsg
. В отношении сокета TCP в разделе 2.9 мы сказали, что ядро копирует данные из буфера приложения в буфер отправки сокета. Если для блокируемого сокета недостаточно места в буфере отправки, процесс переходит в состояние ожидания до тех пор, пока место не освободится.

В случае неблокируемого сокета TCP при недостатке места в буфере отправки завершение происходит немедленно с ошибкой

EWOULDBLOCK
. Если в буфере отправки сокета есть место, возвращаемое значение будет представлять количество байтов, которое ядро смогло скопировать в буфер (это называется частичным копированием — short count).

В разделе 2.9 мы также сказали, что на самом деле буфера отправки UDP не существует. Ядро только копирует данные приложения и перемещает их вниз по стеку, добавляя к данным заголовки UDP и IP. Следовательно, операция вывода на блокируемом сокете UDP (каким он является по умолчанию) никогда не заблокируется.

3. Прием входящих соединений: функция

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

Если функция

accept
вызывается для неблокируемого сокета и новое соединение недоступно, возвращается ошибка
EWOULDBLOCK
.

4. Инициирование исходящих соединений: функция

connect
для TCP. (Вспомните, что функция connect может использоваться с UDP, но она не вызывает создания «реального» соединения — она лишь заставляет ядро сохранить IP-адрес и номер порта собеседника.) В разделе 2.5 мы показали, что установление соединения TCP включает трехэтапное рукопожатие и что функция connect не возвращает управление, пока клиент не получит сегмент ACK или SYN. Это значит, что функция TCP
connect
всегда блокирует вызывающий процесс как минимум на время обращения (RTT) к серверу.

Если функция

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

ПРИМЕЧАНИЕ

Традиционно System V возвращала для неблокируемой операции ввода-вывода, которую невозможно выполнить, ошибку EAGAIN, в то время как Беркли-реализации возвращали ошибку EWOULDBLOCK. Еще больше дело запутывается тем, что согласно POSIX.1 используется EAGAIN, в то время как в POSIX.1g определено, что используется EWOULDBLOCK. К счастью, большинство систем (включая SVR4 и 4.4BSD) определяют один и тот же код для этих двух ошибок (проверьте свой системный заголовочный файл ), поэтому не важно, какой из них использовать. В нашем тексте мы используем ошибку EWOULDBLOCK, как определяется в POSIX.

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

connect
.

16.2. Неблокируемые чтение и запись: функция str_cli (продолжение)

Мы снова возвращаемся к нашей функции

str_cli
, которую мы обсуждали в разделах 5.5 и 6.4. Последняя ее версия, задействующая функцию
select
, продолжает использовать блокируемый ввод-вывод. Например, если в стандартном устройстве ввода имеется некоторая строка, мы читаем ее с помощью функции
fgets
и затем отправляем серверу с помощью функции
writen
. Но вызов функции
writen
может вызвать блокирование процесса, если буфер отправки сокета полон. В то время как мы заблокированы в вызове функции
writen
, данные могут быть доступны для чтения из приемного буфера сокета. Аналогично, когда строка ввода доступна из сокета, мы можем заблокироваться в последующем вызове функции
fputs
, если стандартный поток вывода работает медленнее, чем сеть. Наша цель в данном разделе — создать версию этой функции, использующую неблокируемый ввод-вывод. Блокирование будет предотвращено, благодаря чему в это время мы сможем сделать еще что-то полезное.

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

read
и
write
. Это даст возможность отказаться от функций стандартной библиотеки ввода-вывода с неблокируемыми дескрипторами, так как их применение может привести к катастрофическим последствиям.

Мы работаем с двумя буферами: буфер to содержит данные, направляющиеся из стандартного потока ввода к серверу, а буфер

fr
— данные, приходящие от сервера в стандартный поток вывода. На рис. 16.1 представлена организация буфера
to
и указателей в буфере.

Рис. 16.1. Буфер, содержащий данные из стандартного потока ввода, идущие к сокету

Указатель

toiptr
указывает на следующий байт, в который данные могут быть считаны из стандартного потока ввода. Указатель
tooptr
указывает на следующий байт, который должен быть записан в сокет. Число байтов, которое может быть считано из стандартного потока ввода, равно
&to[MAXLINE]
минус
toiptr
. Как только значение
tooptr
достигает
toiptr
, оба указателя переустанавливаются на начало буфера.

На рис. 16.2 показана соответствующая организация буфера

fr
. В листинге 16.1[1] представлена первая часть функции.

Рис. 16.2. Буфер, содержащий данные из сокета, идущие к стандартному устройству вывода

Листинг 16.1. Функция str_cli: первая часть, инициализация и вызов функции

//nonblock/strclinonb.c

 1 #include "unp.h"


 2 void

 3 str_cli(FILE *fp, int sockfd)

 4 {

 5  int maxfdp1, val, stdineof;

 6  ssize_t n, nwritten;

 7  fd_set rset, wset;

 8  char to[MAXLINE], fr[MAXLINE];

 9  char *toiptr, *tooptr, *friptr, *froptr;


10  val = Fcntl(sockfd, F_GETFL, 0);

11  Fcntl(sockfd, F_SETFL, val | O_NONBLOCK);


12  val = Fcntl(STDIN_FILENO, F_SETFL, 0);

13  Fcntl(STDIN_FILENO, F_SETFL, val | O_NONBLOCK);


14  val = Fcntl(STDOUT_FILENO, F_SETFL, 0);

15  Fcntl(STDOUT_FILENO, F_SETFL, val | O_NONBLOCK);


16  toiptr = tooptr = to; /* инициализация указателей буфера */

17  friptr = froptr = fr;

18  stdineof = 0;


19  maxfdp1 = max(max(STDIN_FILENO, STDOUT_FILENO), sockfd) + 1;

20  for (;;) {

21   FD_ZERO(&rset);

22   FD_ZERO(&wset);

23   if (stdineof == 0 && toiptr <&to[MAXLINE])

24     FD_SET(STDIN_FILENO, &rset); /* чтение из стандартного потока

                                       ввода */

25   if (friptr <&fr[MAXLINE])

26    FD_SET(sockfd, &rset); /* чтение из сокета */

27   if (tooptr != toiptr)

28    FD_SET(sockfd, &wset); /* данные для записи в сокет */

29   if (froptr != friptr)

30    FD_SET(STDOUT_FILENO, &wset); /* данные для записи в стандартный

                                       поток вывода */

31   Select(maxfdp1, &rset, &wset, NULL, NULL);

Установка неблокируемых дескрипторов

10-15
 Все три дескриптора делаются неблокируемыми при помощи функции
fcntl
: сокет в направлении к серверу и от сервера, стандартный поток ввода и стандартный поток вывода.

Инициализация указателей буфера

16-19
 Инициализируются указатели в двух буферах и вычисляется максимальный дескриптор. Это значение, увеличенное на единицу, будет использоваться в качестве первого аргумента функции
select
.

Основной цикл: подготовка к вызову функции select

20
 Как и в случае первой версии этой функции, показанной в листинге 6.2, основной цикл функции содержит вызов функции
select
, за которой следуют отдельные проверки различных интересующих нас условий.

Подготовка интересующих нас дескрипторов

21-30
 Оба набора дескрипторов обнуляются и затем в каждом наборе включается не более двух битов. Если мы еще не прочитали конец файла из стандартного потока ввода и есть место как минимум для 1 байта данных в буфере
to
, то в наборе флагов чтения включается бит, соответствующий стандартному потоку ввода. Если есть место как минимум для 1 байта данных в буфере
fr
, то в наборе флагов чтения включается бит, соответствующий сокету. Если есть данные для записи в сокет в буфере
to
, то в наборе флагов записи включается бит, соответствующий сокету. Наконец если в буфере
fr
есть данные для отправки в стандартный поток вывода, то в наборе флагов записи включается бит, соответствующий этому стандартному потоку.

Вызов функции select

31
 Вызывается функция
select
, ожидающая, когда одно из четырех условий станет истинным. Для этой функции мы не задаем тайм-аута.

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

select
.

Листинг 16.2. Функция str_cli: вторая часть, чтение из стандартного потока ввода или сокета

//nonblock/strclinonb.c

32   if (FD_ISSET(STDIN_FILENO, &rset)) {

33    if ((n = read(STDIN_FILENO, toiptr, &to[MAXLINE] - toiptr)) < 0) {

34     if (errno != EWOULDBLOCK)

35      err_sys("read error on stdin");

36    } else if (n == 0) {

37     fprintf(stderr, "%s: EOF on stdin\n", gf_time());

38     stdineof = 1; /* с stdin все сделано */

39     if (tooptr == toiptr)

40      Shutdown(sockfd, SHUT_WR); /* отсылаем FIN */


41    } else {

42     fprintf(stderr, "%s: read %d bytes from stdin\n", gf_time(),

43      n);

44     toiptr += n; /* только что полученное из функции read число */

45     FD_SET(sockfd, &wset); /* включаем бит в наборе чтения */

46    }

47   }

48   if (FD_ISSET(sockfd, &rset)) {

49    if ((n = read(sockfd, friptr, &fr[MAXLINE] - friptr)) < 0) {

50     if (errno != EWOULDBLOCK)

51      err_sys("read error on socket");


52    } else if (n == 0) {

53     fprintf(stderr, "%s: EOF on socket\n", gf_time());

54     if (stdineof)

55      return; /* нормальное завершение */

56     else

57      err_quit("str_cli: server terminated prematurely");


58    } else {

59     fprintf(stderr, "%s: read %d bytes from socket\n",

60      gf_time(), n);

61     friptr += n; /* только что полученное из функции read число */

62     FD_SЕТ(STDOUT_FILЕNO, &wset); /* включаем бит в наборе

                                        чтения */

63    }

64   }

Чтение из стандартного потока ввода с помощью функции read

32-33
 Если стандартный поток ввода готов для чтения, мы вызываем функцию
read
. Третий ее аргумент — это количество свободного места в буфере
to
.

Обработка ошибки

34-35
 Если происходит ошибка
EWOULDBLOCK
, мы ничего не предпринимаем. Обычно эта ситуация — когда функция
select
сообщает нам о том, что дескриптор готов для чтения, а функция read возвращает ошибку
EWOULDBLOCK
— не должна возникать, но тем не менее мы ее обрабатываем.

Возвращение конца файла функцией read

36-40
 Если функция
read
возвращает нуль, мы закончили со стандартным потоком ввода. Флаг
stdineof
установлен. Если в буфере to больше нет данных для отправки (
tooptr
равно
toiptr
), функция
shutdown
отправляет серверу сегмент FIN. Если в буфере
to
еще есть данные для отправки, сегмент FIN не может быть отправлен до тех пор, пока содержимое буфера не будет записано в сокет.

ПРИМЕЧАНИЕ

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

Возвращение данных функцией read

41-45
 Когда функция read возвращает данные, мы увеличиваем на единицу
toiptr
. Мы также включаем бит, соответствующий сокету, в наборе флагов записи, чтобы позже при проверке этого бита в цикле он был включен и тем самым инициировалась бы попытка записи в сокет с помощью функции
write
.

ПРИМЕЧАНИЕ

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

Чтение из сокета с помощью функции read

48-64
Эти строки кода аналогичны выражению
if
, только что описанному для случая, когда стандартный поток ввода готов для чтения. Если функция
read
возвращает ошибку
EWOULDBLOCK
, ничего не происходит. Если мы встречаем признак конца файла, присланный сервером, это нормально, когда мы уже получили признак конца файла в стандартном потоке ввода. Но иначе это будет ошибкой, означающей преждевременное завершение работы сервера (
Server terminated prematurely
). Если функция
read
возвращает некоторые данные,
friptr
увеличивается на единицу и в наборе флагов записи включается бит для стандартного потока вывода, с тем чтобы попытаться записать туда данные в следующей части функции.

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

Листинг 16.3. Функция str_cli: третья часть, запись в стандартный поток вывода или сокет

//nonblock/strclinonb.c

65   if (FD_ISSET(STDOUT_FILENO, &wset) && ((n = friptr - froptr) > 0)) {

66    if ((nwritten = write(STDOUT_FILENO, froptr, n)) < 0) {

67     if (errno != EWOULDBLOCK)

68      err_sys("write error to stdout");

69    } else {

70     fprintf(stderr, "%s: wrote %d bytes to stdout\n",

71      gf_time(), nwritten);

72     froptr += nwritten; /* только что полученное из функции write

                              число */

73     if (froptr == friptr)

74      froptr = friptr - fr; /* назад к началу буфера */

75    }

76   }


77   if (FD_ISSET(sockfd, &wset) && ((n - toiptr - tooptr) > 0)) {

78    if ((nwritten = write(sockfd, tooptr, n)) < 0) {

79     if (errno != EWOULDBLOCK)

80      err_sys("write error to socket");


81    } else {

82     fprintf(stderr, "%s: wrote %d bytes to socket\n",

83      gf_time(), nwritten);

84     tooptr += nwritten; /* только что полученное из функции write

                              число */

85     if (tooptr == toiptr) {

86      toiptr - tooptr = to; /* назад к началу буфера */

87      if (stdineof)

88       Shutdown(sockfd, SHUT_WR); /* посылаем FIN */

89     }

90    }

91   }

92  }

93 }

Запись в стандартный поток вывода с помощью функции write

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

Успешное выполнение функции write

68-74
 Если функция
write
выполняется успешно,
froptr
увеличивается на число записанных байтов. Если указатель вывода стал равен указателю ввода, оба указателя переустанавливаются на начало буфера.

Запись в сокет с помощью функции write

76-90
 Эта часть кода аналогична коду, только что описанному для записи в стандартный поток вывода. Единственное отличие состоит в том, что когда указатель вывода доходит до указателя ввода, не только оба указателя переустанавливаются в начало буфера, но и появляется возможность отправить серверу сегмент FIN.

Теперь мы проверим работу этой функции и операций неблокируемого ввода-вывода. В листинге 16.4 показана наша функция

gf_time
, вызываемая из функции
str_cli
.

Листинг 16.4. Функция gf_time: возвращение указателя на строку времени

//lib/gf_time.c

 1 #include "unp.h"

 2 #include 


 3 char*

 4 gf_time(void)

 5 {

 6  struct timeval tv;

 7  static char str[30];

 8  char *ptr;


 9  if (gettimeofday(&tv, NULL) < 0)

10   err_sys("gettimeofday error");


11  ptr = ctime(&tv.tv_sec);

12  strcpy(str, &ptr[11]);

13  /* Fri Sep 13 00:00:00 1986\n\0 */

14  /* 0123456789012345678901234 5 */

15  snprintf(str + 8, sizeof(str) - 8, ".%06ld", tv.tv_usec);

15  return (str);

17 }

Эта функция возвращает строку, содержащую текущее время с точностью до микросекунд, в таком формате:

12:34:56.123456

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

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

Например, сначала мы запускаем функцию

tcpdump
на нашем узле
solaris
, собирая только сегменты TCP, идущие к порту 7 или от него (эхо-сервер), и сохраняем выходные данные в файле, который называется
tcpd
:

solaris % tcpdump -w tcpd tcp and port 7

Затем мы запускаем клиент TCP на этом узле и указываем сервер на узле

linux
:

solaris % tcpcli02 192.168.1.10 < 2000.lines > out 2> diag

Стандартный поток ввода — это файл

2000.lines
, тот же файл, что мы использовали для листинга 6.2. Стандартный поток вывода перенаправляется в файл
out
, а стандартный поток сообщений об ошибках — в файл
diag
. По завершении мы запускаем:

solaris % diff 2000.lines out

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

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

Листинг 16.5. Отсортированный вывод функции tcpdump и данных диагностики

solaris % tcpdump -r tcpd -N | sort diag -

10:18:34.486392 solaris.33621 > linux.echo: S 1802738644:1802738644(0) win 8760 

10:18:34.488278 linux.echo > solaris.33621: S 3212986316 3212986316(0) ack 1802738645 win 8760 

10:18:34.488490 solaris.33621 > linux.echo: . ack 1 win 8760

10:18:34.491482: read 4096 bytes from stdin

10:18:34.518663 solaris.33621 > linux.echo: P 1461(1460) ack 1 win 8760

10:18:34.519016: wrote 4096 bytes to socket

10:18:34.528529 linux echo > solaris.33621. P 1:1461(1460) ack 1461 win 8760

10:18:34 528785 solaris.33621 > linux.echo: . 1461 2921(1460) ack 1461 win 8760

10:18:34.528900 solaris.33621 > linux echo: P 2921:4097(1176) ack 1461 win 8760

10:18:34.528958 solaris 33621 > linux.echo: ack 1461 win 8760

10:18:34.536193 linux echo: > solaris.33621: . 1461:2921(1460) ack 4097 win 8760

10:18:34.536697 linux.echo: > solaris.33621: P 2921.3509(588) ack 4097 win 8760

10:18.34.544636: read 4096 bytes from stdin 10:18:34.568505: read 3508 bytes from socket

10:18:34.580373 solaris 33621 > linux.echo: . ack 3509 win 8760

10:18:34.582244 linux.echo > solaris.33621: P 3509.4097(588) ack 4097 win 8760

10:18:34.593354: wrote 3508 bytes to stdout

10:18:34.617272 solaris.33621 > linux.echo: P 4097.5557(1460) ack 4097 win 8760

10:18:34.617610 solaris 33621 > linux.echo: P 5557:7017(1460) ack 4097 win 8760

10:18:34.617908 solaris.33621 > linux.echo: P 7017.8193(1176) ack 4097 win 8760

10:18:34.618062: wrote 4096 bytes to socket

10:18:34.623310 linux.echo > solaris.33621: . ack 8193 win 8760

10:18:34.626129 linux.echo > solaris.33621: . 4097.5557(1460) ack 8193 win 8760

10:18:34.626339 solaris.33621 > linux.echo: . ack 5557 win 8760

10:18:34.626611 linux.echo > solaris.33621: P 5557:6145(588) ack 8193 win 8760

10:18:34.628396 linux.echo > solaris.33621: 6145:7605(1460) ack 8193 win 8760

10:18:34.643524: read 4096 bytes from stdin 10:18:34.667305. read 2636 bytes from socket

10:18:34.670324 solaris.33621 > linux echo: . ack 7605 win 8760

10:18:34.672221 linux.echo > solaris.33621: P 7605.8193(588) ack 8193 win 8760

10:18:34.691039: wrote 2636 bytes to stdout

Мы удалили записи (

DF
) из сегментов, отправленных Solaris, означающие, что устанавливается бит DF (он используется для определения величины транспортной MTU).

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

Рис. 16.3. Временная диаграмма событий для примера неблокируемого ввода

На этом рисунке мы не показываем сегменты ACK. Также помните, что если программа выводит сообщение

wrote N bytes to stdout
(записано N байт в стандартное устройство вывода), это означает, что завершилась функция
write
, возможно, заставившая TCP отправить один или более сегментов данных.

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

select
.

Мы можем рассчитать время выполнения нашей неблокируемой версии, используя тот же файл из 2000 строк и тот же сервер (с периодом RTT, равным 175 мс), что и в разделе 6.7. Теперь время оказалось равным 6,9 с по сравнению с 12,3 с в версии из раздела 6.7. Следовательно, неблокируемый ввод-вывод сокращает общее время выполнения этого примера, в котором файл отправляется серверу.

Более простая версия функции str_cli

Неблокируемая версия функции

str_cli
, которую мы только что показали, нетривиальна: около 135 строк кода по сравнению с 40 строками версии, использующей функцию
select
с блокируемым вводом-выводом (см. листинг 6.2), и 20 строками начальной версии, работающей в режиме остановки и ожидания (см. листинг 5.4). Мы знаем, что эффект от удлинения кода в два раза, с 20 до 40 строк оправдывает затраченные усилия, поскольку в пакетном режиме скорость возрастает почти в 30 раз, а применение функции
select
с блокируемыми дескрипторами осуществляется не слишком сложно. Но будут ли оправданы затраченные усилия при написании приложения, использующего неблокируемый ввод-вывод, с учетом усложнения итогового кода? Нет, ответим мы. Если нам необходимо использовать неблокируемый ввод-вывод, обычно бывает проще разделить приложение либо на процессы (при помощи функции
fork
), либо на потоки (см. главу 26).

В листинге 16.6 показана еще одна версия нашей функции

str_cli
, разделяемая на два процесса при помощи функции
fork
.

Эта функция сразу же вызывает функцию

fork
для разделения на родительский и дочерний процессы. Дочерний процесс копирует строки от сервера в стандартный поток вывода, а родительский процесс — из стандартного потока ввода серверу, как показано на рис. 16.4.

Рис. 16.4. Функция str_cli, использующая два процесса

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

Листинг 16.6. Версия функции str_cli, использующая функцию fork

//nonblock/strclifork.c

 1 #include "unp.h"


 2 void

 3 str_cli(FILE *fp, int sockfd)

 4 {

 5  pid_t pid;

 6  char sendline[MAXLINE], recvline[MAXLINE];


 7  if ((pid = Fork()) == 0) { /* дочерний процесс: сервер -> stdout */

 8   while (Readline(sockfd, recvline, MAXLINE) > 0)

 9    Fputs(recvline, stdout);


10   kill(getppid(), SIGTERM); /* в случае, если родительский процесс

                                 все еще выполняется */

11   exit(0);

12  }

13  /* родитель: stdin -> сервер */

14  while (Fgets(sendline, MAXLINE, fp) != NULL)

15   Writen(sockfd, sendline, strlen(sendline));

16  Shutdown(sockfd, SHUT_WR); /* конец файла на stdin, посылаем FIN */

17  pause();

18  return;

19 }

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

shutdown
для отправки сегмента FIN. (Родительский процесс не может вызвать функцию
close
, см. упражнение 16.1.) Но когда это происходит, дочерний процесс должен продолжать копировать от сервера в стандартный поток вывода, пока он не получит признак конца файла на сокете.

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

SIGTERM
, в случае, если родительский процесс еще выполняется (см. упражнение 16.3). Другим способом обработки этой ситуации было бы завершение дочернего процесса, и если родительский процесс все еще выполнялся бы к этому моменту, он получил бы сигнал
SIGCHLD
.

Родительский процесс вызывает функцию

pause
, когда заканчивает копирование, что переводит его в состояние ожидания того момента, когда будет получен сигнал. Даже если родительский процесс не перехватывает никаких сигналов, он все равно переходит в состояние ожидания до получения сигнала
SIGTERM
от дочернего процесса. По умолчанию действие этого сигнала — завершение процесса, что вполне устраивает нас в этом примере. Родительский процесс ждет завершения дочернего процесса, чтобы измерить точное время для этой версии функции
str_cli
. Обычно дочерний процесс завершается после родительского, но поскольку мы измеряем время, используя команду оболочки
time
, измерение заканчивается, когда завершается родительский процесс.

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

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

Сравнение времени выполнения различных версий функции str_cli

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

str_cli
. Для каждой версии мы покажем время, которое потребовалось для ее выполнения, в том числе и для версии, использующей программные потоки (см. листинг 26.1). В каждом случае было скопировано 2000 строк от клиента Solaris к серверу с периодом RTT, равным 175 мс:

■ 354,0 с, режим остановки и ожидания (см. листинг 5.4);

■ 12,3 с, функция

select
и блокируемый ввод-вывод (см. листинг 6.2);

■ 6,9 с, неблокируемый ввод-вывод (см. листинг 16.1);

■ 8,7 с, функция

fork
(см. листинг 16.6);

■ 8,5 с, версия с потоками (см. листинг 26.1).

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

select
. Наша простая версия с применением функции
fork
медленнее версии с неблокируемым вводом- выводом. Тем не менее, учитывая сложность кода неблокируемого ввода-вывода по сравнению с кодом функции
fork
, мы рекомендуем более простой подход.

16.3. Неблокируемая функция connect

Когда сокет TCP устанавливается как неблокируемый, а затем вызывается функция

connect
, она немедленно возвращает ошибку
EINPROGRESS
, однако трехэтапное рукопожатие TCP продолжается. Далее мы с помощью функции
select
проверяем, успешно или нет завершилось установление соединения. Неблокируемая функция connect находит применение в трех случаях:

1. Трехэтапное рукопожатие может наложиться на какой-либо другой процесс. Для выполнения функции

connect
требуется один период обращения RTT (см. раздел 2.5), и это может занять от нескольких миллисекунд в локальной сети до сотен миллисекунд или нескольких секунд в глобальной сети. Это время мы можем провести с пользой, выполняя какой-либо другой процесс.

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

3. Поскольку мы ждем завершения установления соединения с помощью функции

select
, мы можем задать предел времени для функции
select
, что позволит нам сократить тайм-аут для функции
connect
. Во многих реализациях тайм-аут функции connect лежит в пределах от 75 с до нескольких минут. Бывают случаи, когда приложению нужен более короткий тайм-аут, и одним из решений может стать использование неблокируемой функции
connect
. В разделе 14.2 рассматриваются другие способы помещения тайм-аута в операции с сокетами.

Как бы просто ни выглядела неблокируемая функция

connect
, есть ряд моментов, которые следует учитывать.

■ Даже если сокет является неблокируемым, то когда сервер, с которым мы соединяемся, находится на том же узле, обычно установление соединения происходит немедленно при вызове функции

connect
.

■ В Беркли-реализациях (а также POSIX) имеются два следующих правила, относящихся к функции

select
и неблокируемой функции
connect
: во-первых, когда соединение устанавливается успешно, дескриптор становится готовым для записи [128, с. 531], и во-вторых, когда при установлении соединения встречается ошибка, дескриптор становится готовым как для чтения, так и для записи [128, с. 530].

ПРИМЕЧАНИЕ

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

С неблокируемыми функциями

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

16.4. Неблокируемая функция connect: клиент времени и даты

В листинге 16.7 показана наша функция

connect_nonb
, вызывающая неблокируемую функцию
connect
. Мы заменяем вызов функции
connect
, имеющийся в листинге 1.1, следующим фрагментом кода:

if (connect_nonb(sockfd, (SA*)&servaddr, sizeof(servaddr), 0) < 0)

err_sys("connect error");

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

connect
, а четвертый аргумент — это число секунд, в течение которых мы ждем завершения установления соединения. Нулевое значение подразумевает отсутствие тайм- аута для функции
select
; следовательно, для установления соединения TCP ядро будет использовать свой обычный тайм-аут.

Листинг 16.7. Неблокируемая функция connect

//lib/connect_nonb.c

 1 #include "unp.h"


 2 int

 3 connect_nonb(int sockfd, const SA *saptr, socklen_t salen, int nsec)

 4 {

 5  int flags, n, error;

 6  socklen_t len;

 7  fd_set rset, wset;

 8  struct timeval tval;


 9  flags = Fcntl(sockfd, F_GETFL, 0);

10  Fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);


11  error = 0;

12  if ((n = connect(sockfd, saptr, salen)) < 0)

13   if (errno != EINPROGRESS)

14    return (-1);


15  /* Пока соединение устанавливается, мы можем заняться чем-то другим */


16  if (n == 0)

17   goto done; /* функция connect завершилась немедленно */


18  FD_ZERO(&rset);

19  FDSET(sockfd, &rset);

20  wset = rset;

21  tval.tv_sec = nsec;

22  tval.tv_usec = 0;


23  if ((n = Select(sockfd + 1, &rset, &wset, NULL,

24   nsec ? &tval : NULL)) == 0) {

25   close(sockfd); /* тайм-аут */

26   errno = ETIMEDOUT;

27   return (-1);

28  }

29  if (FD_ISSET(sockfd, &rset) || FD_ISSET(sockfd, &wset)) {

30   len = sizeof(error);

31   if (getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &error, &len) < 0)

32    return (-1); /*в Solaris ошибка, ожидающая обработки */

33  } else

34   err_quit("select error: sockfd not set");


35 done:

36  Fcntl(sockfd, F_SETFL, flags); /* восстанавливаем флаги, задающие статус файла */


37  if (error) {

38   close(sockfd); /* на всякий случай */

39   errno = error;

40   return (-1);

41  }

42  return (0);

43 }

Задание неблокируемого сокета

9-10
 Мы вызываем функцию
fcntl
, которая делает сокет неблокируемым.

11-14
 Мы вызываем неблокируемую функцию
connect
. Ошибка, которую мы ожидаем (
EINPROGRESS
), указывает на то, что установление соединения началось, но еще не завершилось [128, с. 466]. Любая другая ошибка возвращается вызывающему процессу.

Выполнение других процессов во время установления соединения

15
 На этом этапе мы можем делать все, что захотим, ожидая завершения установления соединения.

Проверка немедленного завершения

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

Вызов функции select

18-24
 Мы вызываем функцию
select
и ждем, когда сокет будет готов либо для чтения, либо для записи. Мы обнуляем
rset
, включаем бит, соответствующий
sockfd
в этом наборе дескрипторов и затем копируем
rset
в
wset
. Это присваивание, возможно, является структурным присваиванием, поскольку обычно наборы дескрипторов представляются как структуры. Далее мы инициализируем структуру
timeval
и затем вызываем функцию
select
. Если вызывающий процесс задает четвертый аргумент нулевым (что соответствует использованию тайм-аута по умолчанию), следует задать в качестве последнего аргумента функции
select
пустой указатель, а не структуру
timeval
с нулевым значением (означающим, что мы не ждем вообще).

Обработка тайм-аутов

25-28
 Если функция
select
возвращает нуль, это означает, что время таймера истекло, и мы возвращаем вызывающему процессу ошибку
ETIMEDOUT
. Мы также закрываем сокет, чтобы трехэтапное рукопожатие не продолжалось.

Проверка возможности чтения или записи

29-34
 Если дескриптор готов для чтения или для записи, мы вызываем функцию
getsockopt
, чтобы получить ошибку сокета (
SO_ERROR
), ожидающую обработки. Если соединение завершилось успешно, это значение будет нулевым. Если при установлении соединения произошла ошибка, это значение является значением переменной
errno
, соответствующей ошибке соединения (например,
ECONNREFUSED
,
ETIMEDOUT
и т.д.). Мы также сталкиваемся с нашей первой проблемой переносимости. Если происходит ошибка, Беркли-реализации функции
getsockopt
возвращают нуль, а ошибка, ожидающая обработки, возвращается в нашей переменной
error
. Но в системе Solaris сама функция
getsockopt
возвращает -1, а переменная
errno
при этом принимает значение, соответствующее ошибке, ожидающей обработки. В нашем коде обрабатываются оба сценария.

Восстановление возможности блокировки сокета и завершение

36-42
 Мы восстанавливаем флаги, задающие статус файла, и возвращаемся. Если наша переменная errno имеет ненулевое значение в результате выполнения функции
getsockopt
, это значение хранится в переменной
errno
, и функция возвращает -1.

Как мы сказали ранее, проблемы переносимости для функции

connect
связаны с различными реализациями сокетов и отключения блокировки. Во-первых, возможно, что установление соединения завершится и придут данные для собеседника до того, как будет вызвана функция
select
. В этом случае сокет будет готов для чтения и для записи при успешном выполнении функции, как и при неудачном установленном соединении. В нашем коде, показанном в листинге 16.7, этот сценарий обрабатывается при помощи вызова функции
getsockopt
и проверки на наличие ошибки, ожидающей обработки, для сокета.

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

getsockopt
в листинге 16.7:

1. Вызвать функцию

getpeername
вместо функции
getsockopt
. Если этот вызов окажется неудачным и возвратится ошибка
ENOTCONN
, значит, соединение не было установлено, и чтобы получить ошибку, ожидающую обработки, следует вызвать для сокета функцию
getsockopt
с
SO_ERROR
.

2. Вызвать функцию

read
с нулевым значением аргумента
length
. Если выполнение функции read окажется неудачным, функция connect выполнилась неудачно, и переменная errno из функции
read
при этом указывает на причину неудачной попытки установления соединения. Если соединение успешно установлено, функция
read
возвращает нуль.

3. Снова вызвать функцию

connect
. Этот вызов окажется неудачным, и если ошибка —
EISCONN
, сокет уже присоединен, а значит, первое соединение завершилось успешно.

К сожалению, неблокируемая функция

connect
— это одна из самых сложных областей сетевого программирования с точки зрения переносимости. Будьте готовы к проблемам совместимости, особенно с более ранними реализациями. Более простой технологией является создание потока (см. главу 26) для обработки соединения.

Прерванная функция connect

Что происходит, если наш вызов функции

connect
на обычном блокируемом сокете прерывается, скажем, перехваченным сигналом, прежде чем завершится трехэтапное рукопожатие TCP? Если предположить, что функция
connect
не перезапускается автоматически, то она возвращает ошибку
EINTR
. Но мы не можем снова вызвать функцию connect, чтобы добиться завершения установления соединения. Это приведет к ошибке
EADDRINUSE
.

Все, что требуется сделать в этом сценарии, — вызвать функцию

select
, так, как мы делали в этом разделе для неблокируемой функции
connect
. Тогда функция
select
завершится, если соединение успешно устанавливается (делая сокет доступным для записи) или если попытка соединения неудачна (сокет становится доступен для чтения и для записи).

16.5. Неблокируемая функция connect: веб-клиент

Первое практическое использование неблокируемой функции

connect
относится к веб-клиенту Netscape (см. раздел 13.4 [112]). Клиент устанавливает соединение HTTP с веб-сервером и попадает на домашнюю страницу. На этой странице часто присутствуют ссылки на другие веб-страницы. Вместо того чтобы получать последовательно по одной странице за один раз, клиент может получить сразу несколько страниц, используя неблокируемые функции
connect
. На рис. 16.5 показан пример установления множества параллельных соединений. Сценарий, изображенный слева, показывает все три соединения, устанавливаемые одно за другим. Мы считаем, что первое соединение занимает 10 единиц времени, второе — 15, а третье — 4, что в сумме дает 29 единиц времени.

Рис. 16.5. Установление множества параллельных соединений

В центре рисунка показан сценарий, при котором мы выполняем два параллельных соединения. В момент времени 0 запускаются первые два соединения, а когда первое из них устанавливается, мы запускаем третье. Общее время сократилось почти вдвое и равно 15, а не 29 единицам времени, но учтите, что это идеальный случай. Если параллельные соединения совместно используют общий канал связи (допустим, клиент использует модем для соединения с Интернетом), то каждое из этих соединений конкурирует с другими за обладание ограниченными ресурсами этого канала связи, и время установления каждого соединения может возрасти. Например, время 10 может дойти до 15, 15 — до 20, а время 4 может превратиться в 6. Тем не менее общее время будет равно 21 единице, то есть все равно меньше, чем в последовательном сценарии.

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

При работе с веб-клиентами первое соединение устанавливается само по себе, за ним следуют соединения по ссылкам, обнаруженным в данных от первого соединения. Мы показываем это на рис. 16.6.

Рис. 16.6. Установление первого соединения, а затем множества параллельных соединений

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

Поскольку мы выполняем несколько неблокируемых функций

connect
одновременно, мы не можем использовать нашу функцию
connect_nonb
, показанную в листинге 16.7, так как она не завершается, пока соединение не установлено. Вместо этого мы отслеживаем множество соединений самостоятельно.

Наша программа считывает около 20 строк с веб-сервера. Мы задаем в качестве аргументов командной строки максимальное число параллельных соединений, имя узла сервера, а затем каждое из имен файлов, получаемых с сервера. Типичное выполнение нашей программы выглядит так:

solaris % web % www.foobar.com / image1.gif image2.gif \

 image3.gif image4.gif image5.gif \

 image6.gif image7.gif

Аргументы командной строки задают три одновременных соединения, имя узла сервера, имя файла домашней страницы (

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

Это большой пример, поэтому мы будем показывать его частями. В листинге 16.8 представлен наш заголовочный файл

web.h
, который включен во все файлы.

Листинг 16.8. Заголовок web.h

//nonblock/web.h

 1 #include "unp.h"


 2 #define MAXFILES 20

 3 #define SERV "80" /* номер порта или имя службы */


 4 struct file {

 5  char *f_name; /* имя файла */

 6  char *f_host; /* имя узла или адрес IPv4/IPv6 */

 7  int  f_fd;    /* дескриптор */

 8  int  f_flags; /* F_xxx определены ниже */

 9 } file[MAXFILES];


10 #define F_CONNECTING 1 /* connect() в процессе выполнения */

11 #define F_READING 2 /* соединение установлено; происходит считывание */

12 #define F_DONE 4 /* все сделано */


13 #define GET_CMD "GET %s HTTP/1.0\r\n\r\n"


14 /* глобальные переменные */

15 int nconn, nfiles, nlefttoconn, nlefttoread, maxfd;

16 fd_set rset, wset;


17 /* прототипы функций */

18 void home_page(const char*, const char*);

19 void start_connect (struct file*);

20 void write_get_cmd(struct file*);

Задание структуры file

2-13
 Программа считывает некоторое количество (не более
MAXFILES
) файлов с веб-сервера. Структура
file
содержит информацию о каждом файле: его имя (копируется из аргумента командной строки), имя узла или IP-адрес сервера, с которого читается файл, дескриптор сокета, используемый для этого файла, и набор флагов, которые указывают, что мы делаем с этим файлом (устанавливаем соединение для получения файла или считываем файл).

Определение глобальных переменных и прототипов функций

14-20
 Мы определяем глобальные переменные и прототипы для наших функций, которые мы вскоре опишем.

Листинг 16.9. Первая часть программы одновременного выполнения функций connect: глобальные переменные и начало функции main

//nonblock/web.c

 1 #include "web.h"


 2 int

 3 main(int argc, char **argv)

 4 {

 5  int i, fd, n, maxnconn, flags, error;

 6  char buf[MAXLINE];

 7  fd_set rs, ws;


 8  if (argc < 5)

 9   err_quit("usage: web <#conns> ...");

10  maxnconn = atoi(argv[1]);


11  nfiles = min(argc - 4, MAXFILES);

12  for (i = 0; i < nfiles; i++) {

13   file[i].f_name = argv[i + 4];

14   file[i].f_host = argv[2];

15   file[i].f_flags = 0;

16  }

17  printf("nfiles = %d\n", nfiles);


18  home_page(argv[2], argv[3]);


19  FD_ZERO(&rset);

20  FD_ZERO(&wset);

21  maxfd = -1;

22  nlefttoread = nlefttoconn = nfiles;

23  nconn = 0;

Обработка аргументов командной строки

11-17
 Структуры
file
заполняются соответствующей информацией из аргументов командной строки.

Чтение домашней страницы

18
 Функция
home_page
, которую мы показываем в следующем листинге, создает соединение TCP, посылает команду серверу и затем читает домашнюю страницу. Это первое соединение, которое выполняется самостоятельно, до того как мы начнем устанавливать параллельные соединения.

Инициализация глобальных переменных

19-23
 Инициализируются два набора дескрипторов, по одному для чтения и для записи.
maxfd
— это максимальный дескриптор для функции
select
(который мы инициализируем значением -1, поскольку дескрипторы неотрицательны),
nlefttoread
 — число файлов, которые осталось прочитать (когда это значение становится нулевым, чтение заканчивается),
nlefttoconn
— это количество файлов, для которых пока еще требуется соединение TCP, a
nconn
— это число соединений, открытых в настоящий момент (оно никогда не может превышать первый аргумент командной строки).

В листинге 16.10 показана функция

home_page
, вызываемая один раз, когда начинается выполнение функции main.

Листинг 16.10. Функция home_page

//nonblock/home_page.c

 1 #include "web.h"


 2 void

 3 home_page(const char *host, const char *fname)

 4 {

 5  int fd, n;

 6  char line[MAXLINE];


 7  fd = Tcp_connect(host, SERV); /* блокируемая функция connect() */


 8  n = snprintf(line, sizeof(line), GET_CMD, fname);

 9  Writen(fd, line, n);


10  for (;;) {

11   if ((n = Read(fd, line, MAXLINE)) == 0)

12    break; /* сервер закрыл соединение */


13   printf("read %d bytes of home page\n", n);

14   /* обрабатываем полученные данные */

15  }

16  printf("end-of-file on home page\n");

17  Close(fd);

18 }

Установление соединения с сервером

7
 Наша функция
tcp_connect
устанавливает соединение с сервером.

Отправка команды HTTP серверу, чтение ответа

8-17
 Запускается команда HTTP
GET
для домашней страницы (часто обозначается символом
/
). Читается ответ (с ответом мы в данном случае ничего не делаем), и соединение закрывается.

Следующая функция,

start_connect
, показанная в листинге 16.11, инициирует вызов неблокируемой функции connect.

Листинг 16.11. Инициирование неблокируемой функции connect

//nonblock/start_connect.c

 1 #include "web.h"


 2 void

 3 start_connect(struct file *fptr)

 4 {

 5  int fd, flags, n;

 6  struct addrinfo *ai;


 7  ai = Host_serv(fptr->f_host, SERV, 0, SOCK_STREAM);


 8  fd = Socket(ai->ai_family; ai->ai_socktype, ai->ai_protocol);

 9  fptr->f_fd = fd;

10  printf("start_connect for %s, fd %d\n", fptr->f_name, fd);


11  /* отключаем блокирование сокета */

12  flags = Fcntl(fd, F_GETFL, 0);

13  Fcntl(fd, F_SETFL, flags | O_NONBLOCK);


14  /* инициируем неблокируемое соединение с сервером */

15  if ((n = connected, ai->ai_addr, ai->ai_addrlen)) < 0) {

16   if (errno != EINPROGRESS)

17    err_sys("nonblocking connect error");

18   fptr->f_flags = F_CONNECTING;

19   FD_SET(fd, &rset); /* включаем дескриптор сокета в наборе чтения

                           и записи */

20   FD_SET(fd, &wset);

21   if (fd > maxfd)

22    maxfd = fd;


23  } else if (n >= 0) /* соединение уже установлено */

24   write_get_cmd(fptr); /* отправляем команду GET серверу */

25 }

Создание сокета, отключение блокировки сокета

7-13
 Мы вызываем нашу функцию
host_serv
для поиска и преобразования имени узла и имени службы. Она возвращает указатель на массив структур
addrinfo
. Мы используем только первую структуру. Создается сокет TCP, и он становится неблокируемым.

Вызов неблокируемой функции connect

14-22
 Вызывается неблокируемая функция
connect
, и флагу файла присваивается значение
F_CONNECTING
. Включается дескриптор сокета и в наборе чтения, и в наборе записи, поскольку функция
select
будет ожидать любого из этих условий как указания на то, что установление соединения завершилось. При необходимости мы также обновляем значение
maxfd
.

Обработка завершения установления соединения

23-24
 Если функция
connect
успешно завершается, значит, соединение уже установлено, и функция
write_get_cmd
(она показана в следующем листинге) посылает команду серверу.

Мы делаем сокет неблокируемым для функции

connect
, но никогда не переустанавливаем его в блокируемый режим, заданный по умолчанию. Это нормально, поскольку мы записываем в сокет только небольшое количество данных (команда GET следующей функции) и считаем, что эти данные занимают значительно меньше места, чем имеется в буфере отправки сокета. Даже если из-за установленного флага отсутствия блокировки при вызове функции
write
происходит частичное копирование, наша функция
writen
обрабатывает эту ситуацию. Если оставить сокет неблокируемым, это не повлияет на последующее выполнение функций
read
, потому что мы всегда вызываем функцию
select
для определения того момента, когда сокет станет готов для чтения.

В листинге 16.12 показана функция

write_get_cmd
, посылающая серверу команду HTTP GET.

Листинг 16.12. Отправка команды HTTP GET серверу

//nonblock/write_get_cmd.c

 1 #include "web.h"


 2 void

 3 write_get_cmd(struct file *fptr)

 4 {

 5  int n;

 6  char line[MAXLINE];


 7  n = snprintf(line, sizeof(line), GET_CMD, fptr->f_name);

 8  Writen(fptr->f_fd, line, n);

 9  printf("wrote %d bytes for %s\n", n, fptr->f_name);


10  fptr->f_flags = F_READING; /* сброс F_CONNECTING */

11  FD_SET(fptr->f_fd, &rset); /* прочитаем ответ сервера */

12  if (fptr->f_fd > maxfd)

13   maxfd = fptr->f_fd;

14 }

Создание команды и ее отправка

7-9
 Команда создается и пишется в сокет.

Установка флагов

10-13
 Устанавливается флаг
F_READING
, при этом также сбрасывается флаг
F_CONNECTING
(если он установлен). Это указывает основному циклу, что данный дескриптор готов для ввода. Также включается дескриптор в наборе чтения, и при необходимости обновляется значение
maxfd
.

Теперь мы возвращаемся в функцию

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

Можем ли мы инициировать другое соединение?

24-35
 Если мы не дошли до заданного предела одновременных соединений и есть дополнительные соединения, которые нужно установить, мы ищем еще не обработанный файл (на него указывает нулевое значение
f_flags
) и вызываем функцию
start_connect
для инициирования соединения. Число активных соединений увеличивается на единицу (
nconn
), а число соединений, которые нужно установить, на единицу уменьшается (
nlefttoconn
).

Функция select: ожидание событий

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

Листинг 16.13. Основной цикл функции main

//nonblock/web.c

24  while (nlefttoread > 0) {

25   while (nconn < maxnconn && nlefttoconn > 0) {

26    /* find a file to read */

27    for (i =0; i < nfiles; i++)

28     if (file[i].f_flags == 0)

29      break;

30    if (i == nfiles)

31     err_quit("nlefttoconn = %d but nothing found", nlefttoconn);

32    start_connect(&file[i]);

33    nconn++;

34    nlefttoconn--;

35   }


36   rs = rset:

37   ws = wset;

38   n = Select(maxfd + 1, &rs, &ws, NULL, NULL);

39   for (i = 0; i < nfiles; i++) {

40    flags = file[i].f_flags;

41    if (flags == 0 || flags & F_DONE)

42     continue;

43    fd = file[i].f_fd;

44    if (flags & F_CONNECTING &&

45     (FD_ISSET(fd, &rs) || FD_ISSET(fd, &ws))) {

46     n = sizeof(error);

47     if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &error, &n) < 0 ||

48      error != 0) {

49      err_ret("nonblocking connect failed

50       for %s", file[i].f_name);

51     }

52     /* соединение установлено */

53     printf("connection established for %s\n", file[i].f_name);

54     FD_CLR(fd, &wset); /* отключаем запись в этот сокет */

55     write_get_cmd(&file[i]); /* передаем команду GET */


56    } else if (flags & F_READING && FD_ISSET(fd, &rs)) {

57     if ((n = Read(fd, buf, sizeof(buf))) == 0) {

58      printf("end-of-file on %s\n", file[i].f_name);

59      Close(fd);

60      file[i].f_flags = F_DONE; /* сбрасывает флаг F_READING */

61      FD_CLR(fd, &rset);

62      nconn--;

63      nlefttoread--;

64     } else {

65      printf("read %d bytes from %s\n", n, file[i].f_name);

66     }

67    }

68   }

69  }

70  exit(0);

71 }

Обработка всех готовых дескрипторов

39-55
 Теперь мы анализируем каждый элемент массива структур
file
, чтобы определить, какие дескрипторы нужно обрабатывать. Если установлен флаг
F_CONNECTING
и дескриптор включен либо в наборе чтения, либо в наборе записи, неблокируемая функция
connect
завершается. Как мы говорили при описании листинга 16.7, мы вызываем функцию
getsockopt
, чтобы получить ожидающую обработки ошибку для сокета. Если значение ошибки равно нулю, соединение успешно завершилось. В этом случае мы выключаем дескриптор в наборе флагов записи и вызываем функцию
write_get_cmd
для отправки запроса HTTP серверу.

Проверка, есть ли у дескриптора данные

56-67
 Если установлен флаг
F_READING
и дескриптор готов для чтения, мы вызываем функцию
read
. Если соединение было закрыто другим концом, мы закрываем сокет, устанавливаем флаг
F_DONE
, выключаем дескриптор в наборе чтения и уменьшаем число активных соединений и общее число соединений, требующих обработки.

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

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

Эффективность одновременных соединений

Каков выигрыш в эффективности при установлении множества одновременных соединений? В табл. 16.1 показано время, необходимое для выполнения определенной задачи, которая состоит в том, чтобы получить от веб-сервера домашнюю страницу и девять картинок. Время обращения RTT для данного соединения с сервером равно приблизительно 150 мс. Размер домашней страницы — 4017 байт, а средний размер девяти файлов с изображениями составил 1621 байт. Размер сегмента TCP равен 512 байт. Для сравнения мы также представляем в этой таблице значения для многопоточной версии данной программы, которую мы создаем в разделе 26.9.


Таблица 16.1. Время выполнения задания для разного количества одновременных соединений в разных версиях программы

Количество одновременных соединенийЗатраченное время (в секундах), отсутствие блокированияЗатраченное время (в секундах), использование потоков
16,06,3
24,14,2
33,03,1
42,83,0
52,52,7
62,42,5
72,32,3
82,22,3
92,02,3
ПРИМЕЧАНИЕ

Мы показали пример использования одновременных соединений, поскольку он служит хорошей иллюстрацией применения неблокируемого ввода-вывода, а также потому, что в данном случае эффективность применения одновременных соединений может быть измерена. Это свойство также используется в популярном приложении — веб-браузере Netscape. В этой технологии могут появиться некоторые «подводные камни», если сеть перегружена. В главе 21 [111] подробно описываются алгоритмы TCP, называемые алгоритмами медленного старта (slow start) и предотвращения перегрузки сети (congestion avoidance). Когда от клиента к серверу устанавливается множество соединений, то взаимодействие между соединениями на уровне TCP отсутствует. То есть если на одном из соединений происходит потеря пакета, другие соединения с тем же сервером не получают соответствующего уведомления, и вполне возможно, что другие соединения вскоре также столкнутся с потерей пакетов, пока не замедлятся. По этим дополнительным соединениям будет продолжаться отправка слишком большого количества пакетов в уже перегруженную сеть. Эта технология также увеличивает нагрузку на сервер.

Максимальное увеличение эффективности происходит при трех одновременных соединениях (время уменьшается вдвое), а при четырех и более одновременных соединениях прирост производительности значительно меньше.

16.6. Неблокируемая функция accept

Как было сказано в главе 6, функция

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

К сожалению, существует определенная проблема, связанная со временем, способная запутать нас [34]. Чтобы увидеть эту проблему, изменим код нашего эхо- клиента TCP (см. листинг 5.3) таким образом, чтобы после установления соединения серверу отсылался сегмент RST. В листинге 16.14 представлена новая версия.

Листинг 16.14. Эхо-клиент TCP, устанавливающий соединение и посылающий серверу сегмент RST

//nonblock/tcpcli03.c

 1 #include "unp.h"


 2 int

 3 main(int argc, char **argv)

 4 {

 5  int sockfd;

 6  struct linger ling;

 7  struct sockaddr_in servaddr;


 8  if (argc != 2)

 9   err_quit("usage: tcpcli ");

10  sockfd = Socket(AF_INET, SOCK_STREAM, 0);


11  bzero(&servaddr, sizeof(servaddr));

12  servaddr.sin_family = AF_INET;

13  servaddr.sin_port = htons(SERV_PORT);

14  Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);


15  Connect(sockfd, (SA*)&servaddr, sizeof(servaddr));


16  ling.l_onoff = 1; /* для отправки сегмента RST при закрытии соединения */

17  ling.l_linger = 0;

18  Setsockopt(sockfd, SOL_SOCKET, SO_LINGER, &ling, sizeof(ling));

19  Close(sockfd);


20  exit(0);

21 }

Установка параметра сокета SO_LINGER

16-19
 Как только соединение устанавливается, мы задаем параметр сокета
SO_LINGER
, устанавливая флаг
l_onoff
в единицу и обнуляя время
l_linger
. Как утверждалось в разделе 7.5, это вызывает отправку RST на сокете TCP при закрытии соединения. Затем с помощью функции
close
мы закрываем сокет.

Потом мы изменяем наш сервер TCP, приведенный в листингах 6.3 и 6.4, с тем чтобы после сообщения функции

select
о готовности прослушиваемого сокета для чтения, но перед вызовом функции
accept
наступала пауза. В следующем коде, взятом из начала листинга 6.4, две добавленные строки помечены знаком
+
.

  if (FD_ISSET(listenfd, &rset)) { /* новое соединение */

+  printf("listening socket readable\n");

+  sleep(5);

   clilen = sizeof(cliaddr);

   connfd = Accept(listenfd, (SA*)&cliaddr, &clilen);

Здесь мы имитируем занятый сервер, который не может вызвать функцию

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

В разделе 5.11 мы отмечали, что когда клиент разрывает соединение до того, как сервер вызывает функцию

accept
, в Беркли-реализациях прерванное соединение не возвращается серверу, в то время как другие реализации должны возвращать ошибку
ECONNABORTED
, но часто вместо нее возвращают ошибку
EPROTO
. Рассмотрим Беркли-реализацию.

■ Клиент устанавливает соединение и затем прерывает его, как показано в листинге 16.14.

■ Функция

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

■ После того, как сервер получил сообщение от функции

select
, и прежде, чем была вызвана функция
accept
, прибыл сегмент RST от клиента.

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

■ Сервер вызывает функцию

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

Сервер останется блокированным в вызове функции

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

ПРИМЕЧАНИЕ

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

Чтобы решить эту проблему, нужно соблюдать два следующих правила:

1. Всегда делать прослушиваемый сокет неблокируемым, если мы используем функцию

select
для определения того, готово ли соединение к обработке функцией accept.

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

accept
:
EWOULDBLOCK
(для Беркли-реализаций, когда клиент разрывает соединение),
ECONNABORTED
(для реализаций POSIX, когда клиент разрывает соединение),
EPROTO
(для реализаций SVR4, когда клиент разрывает соединение) и
EINTR
(если перехватываются сигналы).

16.7. Резюме

В примере неблокируемого чтения и записи в разделе 16.2 использовался наш клиент

str_cli
, который мы изменили для применения неблокируемого ввода-вывода на соединении TCP с сервером. Функция
select
обычно используется с неблокируемым вводом-выводом для определения того момента, когда дескриптор станет готов для чтения или записи. Эта версия нашего клиента является самой быстродействующей из всех показанных версией, хотя требует нетривиального изменения кода. Затем мы показали, что проще разделить процесс клиента на две части при помощи функции
fork
. Мы используем ту же технологию при создании потоков в листинге 26.1.

Неблокируемая функция

connect
позволяет нам во время трехэтапного рукопожатия TCP выполнять другие задачи вместо блокирования в вызове функции
connect
. К сожалению, с этими функциями также связана проблема совместимости, так как различные реализации по-разному указывают, успешно ли установлено соединение или произошла ошибка. Мы использовали неблокируемые соединения для создания нового клиента, аналогичного веб-клиенту, открывающему одновременно множество соединений TCP для уменьшения затрат времени при получении нескольких файлов от сервера. Подобное инициирование множества соединений может сократить временные затраты, но также является «недружественным по отношению к сети», поскольку не позволяет воспользоваться алгоритмом TCP, предназначенным для предотвращения перегрузки (congestion avoidance).

Упражнения

1. Обсуждая листинг 16.6, мы отметили, что родительский процесс должен вызвать функцию

shutdown
, а не функцию
close
. Почему?

2. Что произойдет в листинге 16.6, если процесс сервера завершится преждевременно и дочерний процесс получит признак конца файла, но не уведомит об этом родительский процесс?

3. Что произойдет в листинге 16.6, если родительский процесс непредвиденно завершится до завершения дочернего процесса, и дочерний процесс затем считает конец файла на сокете?

4. Что произойдет в листинге 16.7, если мы удалим следующие две строки:

if (n == 0)

 goto done; /* функция connect завершилась немедленно */

5. В разделе 16.3 мы сказали, что возможна ситуация, когда данные для сокета придут раньше, чем завершится функция

connect
. Когда это может случиться?

Глава 17