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

Пример TCP-соединения клиент-сервер

5.1. Введение

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

1. Клиент считывает строку текста из стандартного потока ввода и отправляет ее серверу.

2. Сервер считывает строку из сети и отсылает эту строку обратно клиенту.

3. Клиент считывает отраженную строку и помещает ее в свой стандартный поток вывода.

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

Рис. 5.1. Простой эхо-клиент и эхо-сервер

Между клиентом и сервером мы показали две стрелки, но на самом деле это одно двустороннее соединение TCP. Функции

fgets
и
fputs
имеются в стандартной библиотеке ввода-вывода, а функции
writen
и
readline
приведены в разделе 3.9.

Мы разрабатываем нашу собственную реализацию эхо-сервера, однако большинство реализаций TCP/IP предоставляют готовый эхо-сервер, работающий как с TCP, так и с UDP (см. раздел 2.12). С нашим собственным клиентом мы также будем использовать и готовый сервер.

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

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

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

В последующих главах код клиента и сервера будет претерпевать многочисленные изменения, по мере того как вы будете больше узнавать о сетевом программировании (см. табл. 1.3 и 1.4).

5.2. Эхо-сервер TCP: функция main

Наши клиент и сервер TCP используют функции, показанные на рис. 4.1. Программа параллельного сервера представлена в листинге 5.1[1].

Листинг 5.1. Эхо-сервер TCP (улучшенный в листинге 5.9)

//tcpcliserv/tcpserv01.с

 1 #include "unp.h"


 2 int

 3 main(int argc, char **argv)

 4 {

 5  int listenfd, connfd;

 6  pid_t childpid;

 7  socklen_t clilen;

 8  struct sockaddr_in cliaddr, servaddr;


 9  listenfd = Socket(AF_INET, SOCK_STREAM, 0);


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

11  servaddr.sin_family = AF_INET;

12  servaddr.sin_addr.s_addr = htonl(INADDR_ANY);

13  servaddr.sin_port = htons(SERV_PORT);


14  Bind(listenfd, (SA*)&servaddr, sizeof(servaddr));


15  Listen(listenfd, LISTENQ);


16  for (;;) {

17   clilen = sizeof(cliaddr);

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


19   if ((childpid = Fork()) == 0) { /* дочерний процесс */

20    Close(listenfd); /* закрываем прослушиваемый сокет */

21    str_echo(connfd); /* обрабатываем запрос */

22    exit(0);

23   }

24   Close(connfd); /* родительский процесс закрывает

                     присоединенный сокет */

25  }

26 }

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

9-15
 Создается сокет TCP. В структуру адреса сокета Интернета записывается универсальный адрес (
INADDR_ANY
) и номер заранее известного порта сервера (
SERV_PORT
, который определен как 9877 в нашем заголовочном файле
unp.h
). В результате связывания с универсальным адресом системе сообщается, что мы примем соединение, предназначенное для любого локального интерфейса в том случае, если система имеет несколько сетевых интерфейсов. Наш выбор номера порта TCP основан на рис. 2.10. Он должен быть больше 1023 (нам не нужен зарезервированный порт), больше 5000 (чтобы не допустить конфликта с динамически назначаемыми портами, которые выделяются многими реализациями, происходящими от Беркли), меньше 49 152 (чтобы избежать конфликта с «правильным» диапазоном динамически назначаемых портов) и не должен конфликтовать ни с одним зарегистрированным портом. Сокет преобразуется в прослушиваемый при помощи функции
listen
.

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

17-18
 Сервер блокируется в вызове функции
accept
, ожидая подключения клиента.

Параллельный сервер

19-24
 Для каждого клиента функция
fork
порождает дочерний процесс, и дочерний процесс обслуживает запрос этого клиента. Как мы говорили в разделе 4.8, дочерний процесс закрывает прослушиваемый сокет, а родительский процесс закрывает присоединенный сокет. Затем дочерний процесс вызывает функцию
str_echo
(см. листинг 5.2) для обработки запроса клиента.

5.3. Эхо-сервер TCP: функция str_echo

Функция

str_echo
, показанная в листинге 5.2, выполняет серверную обработку запроса клиента: считывание строк от клиента и отражение их обратно клиенту.

Листинг 5.2. Функция str_echo: отраженные строки на сокете

//lib/str_echo.c

 1 #include "unp.h"


 2 void

 3 str_echo(int sockfd)

 4 {

 5  ssize_t n;

 6  char buf[MAXLINE];


 7  for (;;) {

 8   if ((n = read(sockfd, buf, MAXLINE)) > 0)

 9    return; /* соединение закрыто с другого конца */


10   Writen(sockfd, line, n);

11  }

12 }

Чтение строки и ее отражение

7-11
 Функция
read
считывает очередную строку из сокета, после чего строка отражается обратно клиенту с помощью функции
writen
. Если клиент закрывает соединение (нормальный сценарий), то при получении клиентского сегмента FIN функция дочернего процесса
read
возвращает нуль. После этого происходит возврат из функции
str_echo
и далее завершается дочерний процесс, приведенный в листинге 5.1.

5.4. Эхо-клиент TCP: функция main

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

main
TCP-клиента.

Листинг 5.3. Эхо-клиент TCP

//tcpcliserv/tcpcli01.c

 1 #include "unp.h"


 2 int

 3 main(int argc, char **argv)

 4 {

 5  int sockfd;

 6  struct sockaddr_in servaddr;


 7  if (argc != 2)

 8   err_quit("usage: tcpcli ");


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


10  bzero(&servaddr. sizeof(servaddr));

11  servaddr.sin_family = AF_INET;

12  servaddr.sin_port = htons(SERV_PORT);

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


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


15  str_cli(stdin, sockfd); /* эта функция выполняет все необходимые

                               действия со стороны клиента */

16  exit(0);

17 }

Создание сокета, заполнение структуры его адреса

9-13
 Создается сокет TCP и структура адреса сокета заполняется IP-адресом сервера и номером порта. IP-адрес сервера мы берем из командной строки, а известный номер порта сервера (
SERV_PORT
) — из нашего заголовочного файла
unp.h
.

Соединение с сервером

14-15
 Функция
connect
устанавливает соединение с сервером. Затем функция
str_cli
(см. листинг 5.4) выполняет все необходимые действия со стороны клиента.

5.5. Эхо-клиент TCP: функция str_cli

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

Листинг 5.4. Функция str_cli: цикл формирования запроса клиента

//lib/str_cli.c

 1 #include "unp.h"


 2 void

 3 str_cli(FILE *fp, int sockfd)

 4 {

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


 6  while (Fgets(sendline, MAXLINE, fp) != NULL) {


 7   Writen(sockfd,. sendline, strlen(sendline));


 8   if (Readline(sockfd, recvline, MAXLINE) == 0)

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


10   Fputs(recvline, stdout);

11  }

12 }

Считывание строки, отправка серверу

6-7
 Функция
fgets
считывает строку текста, а функция
writen
отправляет эту строку серверу.

Считывание отраженной сервером строки, запись в стандартный поток вывода

8-10
 Функция
readline
принимает отраженную сервером строку, а функция
fputs
записывает ее в стандартный поток вывода.

Возврат в функцию main

11-12
 Цикл завершается, когда функция
fgets
возвращает пустой указатель, что означает достижение конца файла или обнаружение ошибки. Наша функция-обертка
Fgets
проверяет наличие ошибки, и если ошибка действительно произошла, прерывает выполнение программы. Таким образом, функция
Fgets
возвращает пустой указатель только при достижении конца файла.

5.6. Нормальный запуск

Наш небольшой пример использования TCP (около 150 строк кода для двух функций

main
,
str_echo
,
str_cli
,
readline
и
writen
) позволяет понять, как запускаются и завершаются клиент и сервер и, что наиболее важно, как развиваются события, если произошел сбой на узле клиента или в клиентском процессе, потеряна связь в сети и т.д. Только при понимании этих «граничных условий» и их взаимодействия с протоколами TCP/IP мы сможем обеспечить устойчивость клиентов и серверов, которые смогут справляться с подобными ситуациями.

Сначала мы запускаем сервер в фоновом режиме на узле

linux
.

linux % tcpserv01 &

[1] 17870

Когда сервер запускается, он вызывает функции

socket
,
bind
,
listen
и
accept
, а затем блокируется в вызове функции
accept
. (Мы еще не запустили клиент.) Перед тем, как запустить клиент, мы запускаем программу
netstat
, чтобы проверить состояние прослушиваемого сокета сервера.

linux % netstat -a

Active Internet connections (servers and established)

Proto Recv-Q Send-Q Local Address Foreign Address State

tcp        0      0 *:9877        *:*             LISTEN

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

-a
.

Результат совпадает с нашими ожиданиями. Сокет находится в состоянии LISTEN, локальный IP-адрес задан с помощью символа подстановки (то есть является универсальным) и указан локальный порт 9877. Функция

netstat
выводит звездочку для нулевого IP-адреса (
INADDR_ANY
, универсальный адрес) или для нулевого порта.

Затем на том же узле мы запускаем клиент, задав IP-адрес сервера 127.0.0.1. Мы могли бы задать здесь и нормальный адрес сервера (его IP-адрес в сети).

linux % tcpcli01 127.0.0.1

Клиент вызывает функции

socket
и
connect
, последняя осуществляет трехэтапное рукопожатие TCP. Когда рукопожатие TCP завершается, функция connect возвращает управление процессу-клиенту, а функция
accept
— процессу-серверу. Соединение установлено. Затем выполняются следующие шаги:

1. Клиент вызывает функцию

str_cli
, которая блокируется в вызове функции
fgets
, поскольку мы еще ничего не ввели.

2. Когда функция

accept
возвращает управление процессу-серверу, последний вызывает функцию
fork
, а дочерний процесс вызывает функцию
str_echo
. Та вызывает функцию
read
, блокируемую в ожидании получения данных от клиента.

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

accept
и блокируется в ожидании подключения следующего клиента.

У нас имеется три процесса, и все они находятся в состоянии ожидания (блокированы): клиент, родительский процесс сервера и дочерний процесс сервера.

ПРИМЕЧАНИЕ

Мы специально поставили первым пунктом (после завершения трехэтапного рукопожатия) вызов функции str_cli, происходящий на стороне клиента, а затем уже перечислили действия на стороне сервера. Причину объясняет рис. 2.5: функция connect возвращает управление, когда клиент получает второй сегмент рукопожатия. Однако функция accept не возвращает управление до тех пор, пока сервер не получит третий сегмент рукопожатия, то есть пока не пройдет половина периода RTT после завершения функции connect.

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

netstat
отображает теперь две дополнительные строки вывода, соответствующие соединению TCP:

l

inux % netstat -a

Proto Recv-Q Send-Q Local Address   Foreign Address State

tcp        0      0 localhost:9877  localhost:42758 ESTABLISHED

tcp        0      0 localhost:42758 localhost:42758 ESTABLISHED

tcp        0      0 *:9877          *:*             LISTEN

Первая из строк состояния

ESTABLISHED
соответствует дочернему сокету сервера, поскольку локальным портом является порт 9877. Вторая строка
ESTABLISHED
— это клиентский сокет, поскольку локальный порт — порт 42 758. Если мы запускаем клиент и сервер на разных узлах, на узле клиента будет отображаться только клиентский сокет, а на узле сервера — два серверных сокета.

Для проверки состояний процессов и отношений между ними можно также использовать команду

ps
:

linux % ps -t pts/6 -o pid,ppid,tty,stat,args,wchan

PID   PPID  TT    STAT COMMAND    WCHAN

22038 22036 pts/6 S   -bash       wait4

17870 22038 pts/6 S   ./tcpserv01 wait_for_connect

19315 17870 pts/6 S   ./tcpserv01 tcp_data_wait

19314 22038 pts/6 S   ./tcpcli01  127.0.0.1 read_chan

Мы вызвали

ps
с несколько необычным набором аргументов для того, чтобы получить всю необходимую для дальнейшего обсуждения информацию. Мы запустили клиент и сервер из одного окна (
pts/6
, что означает псевдотерминал 6). В колонках
PID
и
PPID
показаны отношения между родительским и дочерним процессами. Можно точно сказать, что первая строка
tcpserv01
соответствует родительскому процессу, а вторая строка
tcpserv01
— дочернему, поскольку PPID дочернего процесса — это PID родительского. Кроме того, PPID родительского процесса совпадает с PID интерпретатора команд (
bash
).

Колонка

STAT
для всех трех сетевых процессов отмечена символом
S
. Это означает, что процессы находятся в состоянии ожидания (sleeping). Если процесс находится в состоянии ожидания, колонка
WCHAN
сообщит нам о том, чем он занят. В Linux значение
wait_for_connect
выводится, если процесс блокируется функцией
accept
или
connect
, значение
tcp_data_wait
— если процесс блокируется при вводе или выводе через сокет, a
read_chan
— если процесс блокируется при терминальном вводе-выводе. Так что для наших трех сетевых процессов значения
WCHAN
выглядят вполне осмысленно.

5.7. Нормальное завершение

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

linux % tcpcli01 127.0.0.1эту строку мы показывали раньше

hello, worldнаш ввод

hello, world отраженная сервером строка

good bye

good bye

^DCtrl+D - наш завершающий символ для обозначения конца файла

Мы вводим две строки, каждая из них отражается, затем мы вводим символ конца файла (EOF)

Ctrl+D
, который завершает работу клиента. Если мы сразу же выполним команду
netstat
, то увидим следующее:

linux % netstat -а | grep 9877

tcp 0 0 *:9877           *:*

tcp 0 0 local host:42758 localhost:9877

Клиентская часть соединения (локальный порт 42 758) входит в состояние TIME_WAIT (см. раздел 2.6), и прослушивающий сервер все еще ждет подключения другого клиента. (В этот раз мы передаем вывод

netstat
программе
grep
, чтобы вывести только строки с заранее известным портом нашего сервера. Но при этом также удаляется строка заголовка.)

Перечислим этапы нормального завершения работы нашего клиента и сервера.

1. Когда мы набираем символ EOF, функция

fgets
возвращает пустой указатель, и функция
str_cli
возвращает управление (см. листинг 5.4).

2. Когда функция

str_cli
возвращает управление клиентской функции
main
(см. листинг 5.3), последняя завершает работу, вызывая функцию
exit
.

3. При завершении процесса выполняется закрытие всех открытых дескрипторов, так что клиентский сокет закрывается ядром. При этом серверу посылается сегмент FIN, на который TCP сервера отвечает сегментом ACK. Это первая половина последовательности завершения работы соединения TCP. На этом этапе сокет сервера находится в состоянии CLOSE_WAIT, а клиентский сокет — в состоянии FIN_WAIT_2 (см. рис. 2.4 и 2.5).

4. Когда TCP сервера получает сегмент FIN, дочерний процесс сервера находится в состоянии ожидания в вызове функции

read
(см. листинг 5.2), а затем функция
read
возвращает нуль. Это заставляет функцию
str_echo
вернуть управление функции
main
дочернего процесса сервера.

5. Дочерний процесс сервера завершается с помощью вызова функции

exit
(см. листинг 5.1).

6. Все открытые дескрипторы в дочернем процессе сервера закрываются. Закрытие присоединенного сокета дочерним процессом вызывает отправку двух последних сегментов завершения соединения TCP: FIN от сервера клиенту и ACK от клиента (см. рис. 2.5). На этом этапе соединение полностью завершается. Клиентский сокет входит в состояние TIME_WAIT.

7. Другая часть завершения процесса относится к сигналу

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

linux % ps -t pts/6 -o pid,ppid,tty,stat,args,wchan

PID   PPID  TT    STAT COMMAND     WCHAN

22038 22036 pts/6 S    -bash       read_chan

17870 22038 pts/6 S    ./tcpserv01 wait_for_connect

19315 17870 pts/6 Z    [tcpserv01  

Теперь дочерний процесс находится в состоянии

Z
(зомби).

Процессы-зомби нужно своевременно удалять, а это требует работы с сигналами Unix. Поэтому в следующем разделе мы сделаем обзор управления сигналами, а затем продолжим рассмотрение нашего примера.

5.8. Обработка сигналов POSIX

Сигнал — это уведомление процесса о том, что произошло некое событие. Иногда сигналы называют программными прерываниями (software interrupts). Подразумевается, что процесс не знает заранее о том, когда придет сигнал.

Сигналы могут посылаться в следующих направлениях:

■ одним процессом другому процессу (или самому себе);

■ ядром процессу.

Сигнал

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

Для каждого сигнала существует определенное действие (action или dispositionхарактер). Действие, соответствующее сигналу, задается с помощью вызова функции

sigaction
(ее описание следует далее) и может быть выбрано тремя способами:

1. Мы можем предоставить функцию, которая вызывается при перехвате определенного сигнала. Эта функция называется обработчиком сигнала (signal handler), а действие называется перехватыванием сигнала (catching). Сигналы

SIGKILL
и
SIGSTOP
перехватить нельзя. Наша функция вызывается с одним целочисленным аргументом, который является номером сигнала, и ничего не возвращает. Следовательно, прототип этой функции имеет вид:

void handler(int signo);

Для большинства сигналов вызов функции

sigaction
и задание функции, вызываемой при получении сигнала, — это все, что требуется для обработки сигнала. Но дальше вы увидите, что для перехватывания некоторых сигналов, в частности
SIGIO
,
SIGPOLL
и
SIGURG
, требуются дополнительные действия со стороны процесса.

2. Мы можем игнорировать сигнал, если действие задать как

SIG_IGN
. Сигналы
SIGKILL
и
SIGSTOP
не могут быть проигнорированы.

3. Мы можем установить действие для сигнала по умолчанию, задав его как

SIG_DFL
. Действие сигнала по умолчанию обычно заключается в завершении процесса по получении сигнала, а некоторые сигналы генерируют копию области памяти процесса в его текущем каталоге (так называемый дампcore dump). Есть несколько сигналов, для которых действием по умолчанию является игнорирование. Например,
SIGCHLD
и
SIGURG
(посылается по получении внеполосных данных, см. главу 24) — это два сигнала, игнорируемых по умолчанию, с которыми мы встретимся в тексте.

Функция signal

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

sigaction
. Однако это достаточно сложно, поскольку один аргумент этой функции — это структура, для которой необходимо выделение памяти и заполнение. Поэтому проще задать действие сигнала с помощью функции
signal
. Первый ее аргумент — это имя сигнала, а второй — либо указатель на функцию, либо одна из констант
SIG_IGN
и
SIG_DFL
. Но функция
signal
существовала еще до появления POSIX.1, и ее различные реализации имеют разную семантику сигналов с целью обеспечения обратной совместимости. В то же время POSIX четко диктует семантику при вызове функции
sigaction
. Это обеспечивает простой интерфейс с соблюдением семантики POSIX. Мы включили эту функцию в нашу собственную библиотеку вместе функциями
err_XXX
и функциями-обертками, которые мы используем для построения всех наших программ. Она представлена в листинге 5.5. Функция-обертка
Signal
здесь не показана, потому что ее вид не зависит от того, какую именно функцию
signal
она должна вызывать.

Листинг 5.5. Функция signal, вызывающая функцию POSIX sigaction

//lib/signal.c

 1 #include "unp.h"


 2 Sigfunc*

 3 signal(int signo, Sigfunc *func)

 4 {

 5  struct sigaction act, oact;


 6  act.sa_handler = func;

 7  sigemptyset(&act.sa_mask);

 8  act.sa_flags = 0;

 9  if (signo == SIGALRM) {

10 #ifdef SA_INTERRUPT

11   act.sa_flags |= SA_INTERRUPT; /* SunOS 4.x */

12 #endif

13  } else {

14 #ifdef SA_RESTART

15   act.sa_flags |= SA_RESTART; /* SVR4, 44BSD */

16 #endif

17  }

18  if (sigaction(signo, &act, &oact) < 0)

19   return (SIG_ERR);

20  return (oact.sa_handler);

21 }

Упрощение прототипа функции при использовании typedef

2-3
 Обычный прототип для функции
signal
усложняется наличием вложенных скобок:

void (*signal(int signo, void (*func)(int)))(int);

Чтобы упростить эту запись, мы определяем тип

Sigfunc
в нашем заголовочном файле
unp.h
следующим образом:

typedef void Sigfunc(int);

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

void
). Тогда прототип функции выглядит следующим образом:

Sigfunc *signal(int signo, Sigfunc *func);

Указатель на функцию, являющуюся обработчиком сигнала, — это второй аргумент функции и в то же время возвращаемое функцией значение.

Установка обработчика

6
 Элемент
sa_handler
структуры
sigaction
устанавливается равным аргументу
func
функции
signal
.

Установка маски сигнала для обработчика

7
 POSIX позволяет нам задавать набор сигналов, которые будут блокированы при вызове обработчика сигналов. Любой блокируемый сигнал не может быть доставлен процессу. Мы устанавливаем элемент
sa_mask
равным пустому набору. Это означает, что во время работы обработчика дополнительные сигналы не блокируются. POSIX гарантирует, что перехватываемый сигнал всегда блокирован, пока выполняется его обработчик.

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

8-17
 Флаг
SA_RESTART
не является обязательным, и если он установлен, то системный вызов, прерываемый этим сигналом, будет автоматически снова выполнен ядром. (В продолжении нашего примера мы более подробно поговорим о прерванных системных вызовах.) Если перехватываемый сигнал не является сигналом
SIGALRM
, мы задаем флаг
SA_RESTART
, если таковой определен. (Причина, по которой сигнал
SIGALRM
обрабатывается отдельно, состоит в том, что обычно цель его генерации - ввести ограничение по времени в операцию ввода-вывода, как показано в листинге 14.2. В этом случае мы хотим, чтобы блокированный системный вызов был прерван сигналом.) Более ранние системы, особенно SunOS 4.x, автоматически перезапускают прерванный системный вызов по умолчанию и затем определяют флаг
SA_INTERRUPT
. Если этот флаг задан, мы устанавливаем его при перехвате сигнала
SIGALRM
.

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

18-20
 Мы вызываем функцию
sigaction
, а затем возвращаем старое действие сигнала как результат функции
signal
.

В книге мы везде используем функцию

signal
из листинга 5.5.

Семантика сигналов POSIX

Сведем воедино следующие моменты, относящиеся к обработке сигналов в системе, совместимой с POSIX.

■ Однажды установленный обработчик сигналов остается установленным (в более ранних системах обработчик сигналов удалялся каждый раз по выполнении).

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

sa_mask
, переданном функции
sigaction
при установке обработчика, также блокируются. В листинге 5.5 мы устанавливаем
sa_mask
равным пустому набору, что означает, что никакие сигналы, кроме перехватываемого, не блокируются.

■ Если сигнал генерируется один или несколько раз, пока он блокирован, то обычно после разблокирования он доставляется только один раз, то есть по умолчанию сигналы Unix не устанавливаются в очередь. Пример мы рассмотрим в следующем разделе. Стандарт POSIX реального времени 1003.1b определяет набор надежных сигналов, которые помещаются в очередь, но в этой книге мы их не используем.

■ Существует возможность выборочного блокирования и разблокирования набора сигналов с помощью функции

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

5.9. Обработка сигнала SIGCHLD

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

init
), что позволяет унаследовать дочерние процессы и сбросить их (то есть процесс
init
будет ждать (
wait
) их завершения, благодаря чему будут удалены зомби). Некоторые системы Unix в столбце
COMMAND
выводят для зомбированных процессов значение
.

Обработка зомбированных процессов

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

fork
для дочерних процессов, необходимо с помощью функции
wait
дождаться их завершения, чтобы они не превратились в зомби. Для этого мы устанавливаем обработчик сигналов для перехватывания сигнала
SIGCHLD
и внутри обработчика вызываем функцию
wait
. (Функции
wait
и
waitpid
мы опишем в разделе 5.10.) Обработчик сигналов мы устанавливаем с помощью вызова функции

Signal(SIGCHLD, sig_chld);

в листинге 5.1, после вызова функции

listen
. (Необходимо сделать это до вызова функции
fork
для первого дочернего процесса, причем только один раз.) Затем мы определяем обработчик сигнала — функцию
sig_chld
, представленную в листинге 5.6.

Листинг 5.6. Версия обработчика сигнала SIGCHLD, вызывающая функцию wait (усовершенствованная версия находится в листинге 5.8)

//tcpcliserv/sigchldwait.с

 1 #include "unp.h"


 2 void

 3 sig_chld(int signo)

 4 {

 5  pid_t pid;

 6  int stat;


 7  pid = wait(&stat);

 8  printf("child terrmnated\n", pid);

 9  return;

10 }

ВНИМАНИЕ

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

В системах System V и Unix 98 дочерний процесс не становится зомби, если процесс задает действие SIG_IGN для SIGCHLD. К сожалению, это верно только для System V и Unix 98. В POSIX прямо сказано, что такое поведение этим стандартом не предусмотрено. Переносимый способ обработки зомби состоит в том, чтобы перехватывать сигнал SIGCHLD и вызывать функцию wait или waitpid.

Если мы откомпилируем в Solaris 9 программу, представленную в листинге 5.1, вызывая функцию

Signal
с нашим обработчиком
sig_chld
, и будем использовать функцию
signal
из системной библиотеки (вместо нашей версии, показанной в листинге 5.5), то получим следующее:

solaris % tcpserv02 &запускаем сервер в фоновом режиме

[2] 16939

solaris % tcpcli01 127.0.0.1затем клиент

hi there набираем эту строку

hi there и она отражается сервером

^D       вводим символ конца файла

child 16942 terminated функция printf из обработчика сигнала выводит эту строку

accept error: Interrupted system call но функция main преждевременно прекращает выполнение

Последовательность шагов в этом примере такова:

1. Мы завершаем работу клиента, вводя символ EOF. TCP клиента посылает сегмент FIN серверу, и сервер отвечает сегментом ACK.

2. Получение сегмента FIN доставляет EOF ожидающей функции

readline
дочернего процесса. Дочерний процесс завершается.

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

accept
, когда доставляется сигнал
SIGCHLD
. Функция
sig_chld
(наш обработчик сигнала) выполняется, функция
wait
получает PID дочернего процесса и статус завершения, после чего из обработчика сигнала вызывается функция
printf
. Обработчик сигнала возвращает управление.

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

accept
), ядро заставляет функцию
accept
возвратить ошибку
EINTR
(прерванный системный вызов). Родительский процесс не обрабатывает эту ошибку корректно (см. листинг 5.1), поэтому функция
main
преждевременно завершается.

Цель данного примера — показать, что при написании сетевых программ, перехватывающих сигналы, необходимо получать информацию о прерванных системных вызовах и обрабатывать их. В этом специфичном для Solaris 2.5 примере функция

signal
из стандартной библиотеки С не осуществляет автоматический перезапуск прерванного вызова, то есть флаг
SA_RESTART
, установленный нами в листинге 5.5, не устанавливается функцией signal из системной библиотеки. Некоторые другие системы автоматически перезапускают прерванный системный вызов. Если мы запустим тот же пример в 4.4BSD, используя ее библиотечную версию функции
signal
, ядро перезапустит прерванный системный вызов и функция
accept
не возвратит ошибки. Одна из причин, по которой мы определяем нашу собственную версию функции
signal
и используем ее далее, — решение этой потенциальной проблемы, возникающей в различных операционных системах (см. листинг 5.5).

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

return
для наших обработчиков сигналов (см. листинг 5.6), даже если функция ничего не возвращает (
void
), чтобы этот оператор напоминал нам о возможности прерывания системного вызова при возврате из обработчика.

Обработка прерванных системных вызовов

Термином медленный системный вызов (slow system call), введенным при описании функции

accept
, мы будем обозначать любой системный вызов, который может быть заблокирован навсегда. Такой системный вызов может никогда не завершиться. В эту категорию попадает большинство сетевых функций. Например, нет никакой гарантии, что вызов функции
accept
сервером когда-нибудь будет завершен, если нет клиентов, которые соединятся с сервером. Аналогично, вызов нашим сервером функции
read
(из
readline
) в листинге 5.2 никогда не возвратит управление, если клиент никогда не пошлет серверу строку для отражения. Другие примеры медленных системных вызовов — чтение и запись в случае программных каналов и терминальных устройств. Важным исключением является дисковый ввод-вывод, который обычно завершается возвращением управления вызвавшему процессу (в предположении, что не происходит фатальных аппаратных ошибок).

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

EINTR
. Некоторые ядра автоматически перезапускают некоторые прерванные системные вызовы. Для обеспечения переносимости программ, перехватывающих сигналы (большинство параллельных серверов перехватывает сигналы SIGCHLD), следует учесть, что медленный системный вызов может возвратить ошибку EINTR. Проблемы переносимости связаны с написанными выше словами «могут» и «некоторые» и тем фактом, что поддержка флага POSIX
SA_RESTART
не является обязательной. Даже если реализация поддерживает флаг
SA_RESTART
, не все прерванные системные вызовы могут автоматически перезапуститься. Например, большинство реализаций, происходящих от Беркли, никогда автоматически не перезапускают функцию
select
, а некоторые из этих реализаций никогда не перезапускают функции
accept
и
recvfrom
.

Чтобы обработать прерванный вызов функции

accept
, мы изменяем вызов функции
accept
, приведенной в листинге 5.1, в начале цикла
for
следующим образом:

for (;;) {

 clilen = sizeof(cliaddr);

 if ((connfd = accept(listenfd, (SA*)&cliaddr, &clilen)) < 0) {

  if (errno == EINTR)

   continue; /* назад в for() */

  else

   err_sys("accept error");

 }

Обратите внимание, что мы вызываем функцию

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

В этой части кода мы сами перезапускаем прерванный системный вызов. Это допустимо для функции

accept
и таких функций, как
read
,
write
,
select
и
open
. Но есть функция, которую мы не можем перезапустить самостоятельно, — это функция
connect
. Если она возвращает ошибку
EINTR
, мы не можем снова вызвать ее, поскольку в этом случае немедленно возвратится еще одна ошибка. Когда функция connect прерывается перехваченным сигналом и не перезапускается автоматически, нужно вызвать функцию
select
, чтобы дождаться завершения соединения (см. раздел 16.3).

5.10. Функции wait и waitpid

В листинге 5.7 мы вызываем функцию

wait
для обработки завершенного дочернего процесса.

#include 


pid_t wait(int *statloc);

pid_t waitpid(pid_t pid, int *statloc, int options);

Обе функции возвращают ID процесса в случае успешного выполнения, -1 в случае ошибки

Обе функции, и

wait
, и
waitpid
, возвращают два значения. Возвращаемое значение каждой из этих функций — это идентификатор завершенного дочернего процесса, а через указатель
statloc
передается статус завершения дочернего процесса (целое число). Для проверки статуса завершения можно вызвать три макроса, которые сообщают нам, что произошло с дочерним процессом: дочерний процесс завершен нормально, уничтожен сигналом или только приостановлен программой управления заданиями (job-control). Дополнительные макросы позволяют получить состояние выхода дочернего процесса, а также значение сигнала, уничтожившего или остановившего процесс. В листинге 15.8 мы используем макроопределения
WIFEXITED
и
WEXITSTATUS
.

Если у процесса, вызывающего функцию

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

Функция

waitpid
предоставляет более гибкие возможности выбора ожидаемого процесса и его блокирования. Прежде всего, в аргументе
pid
задается идентификатор процесса, который мы будем ожидать. Значение -1 говорит о том, что нужно дождаться завершения первого дочернего процесса. (Существуют и другие значения идентификаторов процесса, но здесь они нам не понадобятся.) Аргумент
options
позволяет задавать дополнительные параметры. Наиболее общеупотребительным является параметр
WNOHANG
: он сообщает ядру, что не нужно выполнять блокирование, если нет завершенных дочерних процессов.

Различия между функциями wait и waitpid

Теперь мы проиллюстрируем разницу между функциями

wait
и
waitpid
, используемыми для сброса завершенных дочерних процессов. Для этого мы изменим код нашего клиента TCP так, как показано в листинге 5.7. Клиент устанавливает пять соединений с сервером, а затем использует первое из них (
sockfd[0]
) в вызове функции
str_cli
. Несколько соединений мы устанавливаем для того, чтобы породить от параллельного сервера множество дочерних процессов, как показано на рис. 5.2.

Рис. 5.2. Клиент, установивший пять соединений с одним и тем же параллельным сервером

Листинг 5.7. Клиент TCP, устанавливающий пять соединений с сервером

/

/tcpcliserv/tcpcli04.c

 1 #include "unp.h"


 2 int

 3 main(int argc, char **argv)

 4 {

 5  int i, sockfd[5];

 6  struct sockaddr_in servaddr;


 7  if (argc != 2)

 8   err_quit("usage: tcpcli ");


 9  for (i = 0; i < 5; i++) {

10   sockfd[i] = 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[i], (SA*)&servaddr, sizeof(servaddr));

16  }


17  str_cli(stdin, sockfd[0]); /* эта функция выполняет все необходимые

                               действия для формирования запроса клиента */


18  exit(0);

19 }

Когда клиент завершает работу, все открытые дескрипторы автоматически закрываются ядром (мы не вызываем функцию close

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

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

Рис. 5.3. Клиент завершает работу, закрывая все пять соединений и завершая все пять дочерних процессов

Сначала мы запускаем сервер в фоновом режиме, а затем — новый клиент. Наш сервер, показанный в листинге 5.1, несколько модифицирован — теперь в нем вызывается функция

signal
для установки обработчика сигнала
SIGCHLD
, приведенного в листинге 5.6.

linux % tcpserv03 &

[1] 20419

linux % tcpcli04 206.62.226.35

hello мы набираем эту строку

hello и она отражается сервером

^Dмы набираем символ конца файла

child 20426 terminated выводится сервером

Первое, что мы можем заметить, — данные выводит только одна функция

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

PID TTY TIME CMD

20419 pts/6 00:00:00 tcpserv03

20421 pts/6 00:00:00 tcpserv03 

20422 pts/6 00:00:00 tcpserv03 

20423 pts/6 00:00:00 tcpserv03 

Установки обработчика сигнала и вызова функции

wait
из этого обработчика недостаточно для предупреждения появления зомби. Проблема состоит в том, что все пять сигналов генерируются до того, как выполняется обработчик сигнала, и вызывается он только один раз, поскольку сигналы Unix обычно не помещаются в очередь. Более того, эта проблема является недетерминированной. В приведенном примере с клиентом и сервером на одном и том же узле обработчик сигнала выполняется один раз, оставляя четыре зомби. Но если мы запустим клиент и сервер на разных узлах, то обработчик сигналов, скорее всего, выполнится дважды: один раз в результате генерации первого сигнала, а поскольку другие четыре сигнала приходят во время выполнения обработчика, он вызывается повторно только один раз. При этом остаются три зомби. Но иногда в зависимости от точного времени получения сегментов FIN на узле сервера обработчик сигналов может выполниться три или даже четыре раза.

Правильным решением будет вызвать функцию

waitpid
вместо
wait
. В листинге 5.8 представлена версия нашей функции
sigchld
, корректно обрабатывающая сигнал
SIGCHLD
. Эта версия работает, потому что мы вызываем функцию
waitpid
в цикле, получая состояние любого из дочерних процессов, которые завершились. Необходимо задать параметр
WNOHANG
: это указывает функции
waitpid
, что не нужно блокироваться, если существуют выполняемые дочерние процессы, которые еще не завершились. В листинге 5.6 мы не могли вызвать функцию
wait
в цикле, поскольку нет возможности предотвратить блокирование функции
wait
при наличии выполняемых дочерних процессов, которые еще не завершились.

В листинге 5.9 показана окончательная версия нашего сервера. Он корректно обрабатывает возвращение ошибки

EINTR
из функции
accept
и устанавливает обработчик сигнала (листинг 5.8), который вызывает функцию
waitpid
для всех завершенных дочерних процессов.

Листинг 5.8. Окончательная (корректная) версия функции sig_chld, вызывающая функцию waitpid

//tcpcliserv/sigchldwaitpid.c


 1 #include "unp.h"


 2 void

 3 sig_chld(int signo)

 4 {

 5  pid_t pid;

 6  int stat;


 7  while ((pid = waitpid(-1, &stat, WNOHANG)) >0)

 8   printf("child %d terminated\n", pid);

 9  return;

10 }

Листинг 5.9. Окончательная (корректная) версия TCP-сервера, обрабатывающего ошибку EINTR функции accept

//tcpcliserv/tcpserv04.c

 1 #include "unp.h"


 2 int

 3 main(int argc, char **argv)

 4 {

 5  int listenfd, connfd;

 6  pid_t childpid;

 7  socklen_t clilen;

 8  struct sockaddr_in cliaddr, servaddr;

 9  void sig_chld(int);


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


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

12  servaddr.sin_family = AF_INET;

13  servaddr.sin_addr.s_addr = htonl(INADDR_ANY);

14  servaddr.sin_port = htons(SERV_PORT);


15  Bind(listenfd, (SA*)&servaddr, sizeof(servaddr));


16  Listen(listenfd, LISTENQ);

17  Signal(SIGCHLD, sig_chld); /* нужно вызвать waitpid() */


18  for (;;) {

19   clilen = sizeof(cliaddr);

20   if ((connfd = accept(listenfd, (SA*)&cliaddr, &clilen)) < 0) {

21    if (errno == EINTR)

22     continue; /* назад к for() */

23    else

24     err_sys("accept error");

25   }

26   if ((childpid = Fork()) == 0) { /* дочерний процесс */

27    Close(listenfd); /* закрываем прослушиваемый сокет */

28    str_echo(connfd); /* обрабатываем запрос */

29    exit(0);

30   }

31   Close(connfd); /* родитель закрывает присоединенный сокет */

32  }

33 }

Целью этого раздела было продемонстрировать три сценария, которые могут встретиться в сетевом программировании.

1. При выполнении функции

fork
, порождающей дочерние процессы, следует перехватывать сигнал
SIGCHLD
.

2. При перехватывании сигналов мы должны обрабатывать прерванные системные вызовы.

3. Обработчик сигналов

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

Окончательная версия нашего сервера TCP (см. листинг 5.9) вместе с обработчиком сигналов

SIGCHLD
в листинге 5.8 обрабатывает все три сценария.

5.11. Прерывание соединения перед завершением функции accept

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

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

Рис. 5.4. Получение сегмента RST для состояния соединения ESTABLISHED перед вызовом функции accept

Трехэтапное рукопожатие TCP завершается, устанавливается соединение, а затем TCP клиента посылает сегмент RST. На стороне сервера соединение ставится в очередь в ожидании вызова функции

accept
, и в это время сервер получает сегмент RST. Спустя некоторое время процесс сервера вызывает функцию
accept
.

К сожалению, принцип обработки прерванного соединения зависит от реализации. Реализации, происходящие от Беркли, обрабатывают прерванное соединение полностью внутри ядра, и сервер никогда не узнает об этом. Большинство реализаций SVR4, однако, возвращают процессу ошибку, и эта ошибка зависит от реализации. При этом переменная errno принимает значение

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

ПРИМЕЧАНИЕ

Этот сценарий очень просто имитировать. Запустите сервер, который должен вызвать функции socket, bind и listen, а затем перед вызовом функции accept переведите сервер на короткое время в состояние ожидания. Пока процесс сервера находится в состоянии ожидания, запустите клиент, который вызовет функции socket и connect. Как только функция connect завершится, установите параметр сокета SO_LINGER, чтобы сгенерировать сегмент RST (который мы описываем в разделе 7.5 и демонстрируем в листинге 16.14), и завершите процессы.

ПРИМЕЧАНИЕ

В [128] описана обработка этой ошибки в Беркли-ядрах (Berkeley-derived kernels), которые никогда не передают ее процессу. Обработка RST с вызовом функции tcp_close представлена в [128, с. 964]. Эта функция вызывает функцию in_pcbdetach [128, с. 897], которая, в свою очередь, вызывает функцию sofree [128, с. 719]. Функция sofree [128, с. 473] обнаруживает, что сокет все еще находится в очереди полностью установленных соединений прослушиваемого сокета. Она удаляет этот сокет из очереди и освобождает сокет. Когда сервер, наконец, вызовет функцию accept, он не сможет узнать, что установленное соединение было удалено из очереди.

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

select
и прослушиваемым сокетом в нормальном режиме блокирования.

5.12. Завершение процесса сервера

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

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

2. Мы находим идентификатор дочернего процесса сервера и уничтожаем его с помощью программы

kill
. Одним из этапов завершения процесса является закрытие всех открытых дескрипторов в дочернем процессе. Это вызывает отправку сегмента FIN клиенту, и TCP клиента отвечает сегментом ACK. Это первая половина завершения соединения TCP.

3. Родительскому процессу сервера посылается сигнал

SIGCHLD
, и он корректно обрабатывается (см. листинг 5.9).

4. С клиентом ничего не происходит. TCP клиента получает от TCP сервера сегмент FIN и отвечает сегментом ACK, но проблема состоит в том, что клиентский процесс блокирован в вызове функции

fgets
в ожидании строки от терминала.

5.  Запуск программы

netstat
на этом шаге из другого окна на стороне клиента показывает состояние клиентского сокета:

linux % netstat -a | grep 9877

tcp 0 0 *:9877          *:*            LISTEN

tcp 0 0 localhost:9877  localhost:9877 FIN_WAIT2

tcp 1 0 localhost.43604 localhost:9877 CLOSE_WAIT

Как видите, согласно рис. 2.4, осуществилась половина последовательности завершения соединения TCP.

6. Мы можем снова ввести строку на стороне клиента. Вот что происходит на стороне клиента (начиная с шага 1):

linux % tcpcli01 127.0.0.1запускаем клиент

helloпервая строка, которую мы ввели

hello она корректно отражается

 теперь мы уничтожаем (kill) дочерний процесс

 сервера на узле сервера

another lineзатем мы вводим следующую строку на стороне клиента

str_cli: server terminated prematurely

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

str_cli
вызывает функцию
writen
, и TCP клиента отправляет данные серверу. TCP это допускает, поскольку получение сегмента FIN протоколом TCP клиента указывает только на то, что процесс сервера закрыл свой конец соединения и больше не будет отправлять данные. Получение сегмента FIN не сообщает протоколу TCP клиента, что процесс сервера завершился (хотя в данном случае он завершился). Мы вернемся к этому вопросу в разделе 6.6, когда будем говорить о половинном закрытии TCP.

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

tcpdump
.

7. Однако процесс клиента не увидит сегмента RST, поскольку он вызывает функцию

readline
сразу же после вызова функции
writen
, и
readline
сразу же возвращает 0 (признак конца файла) по причине того, что на шаге 2 был получен сегмент FIN. Наш клиент не предполагает получать признак конца файла на этом этапе (см. листинг 5.3), поэтому он завершает работу, сообщая об ошибке
Server terminated prematurely
(Сервер завершил работу преждевременно).

ПРИМЕЧАНИЕ

Этапы описанной последовательности также зависят от синхронизации времени. Вызов readline на стороне клиента может произойти до получения им пакета RST от сервера, но может произойти и после. Если readline вызывается до получения RST, происходит то, что мы описали выше (клиент считывает символ конца файла). Если же первым будет получен пакет RST, функция readline возвратит ошибку ECONNRESET (соединение сброшено собеседником).

8. Когда клиент завершает работу (вызывая функцию

err_quit
в листинге 5.4), все его открытые дескрипторы закрываются.

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

fgets
, когда сегмент FIN приходит на сокет. Клиент в действительности работает с двумя дескрипторами — дескриптором сокета и дескриптором ввода пользователя, и поэтому он должен блокироваться при вводе из любого источника (сейчас в функции
str_cli
он блокируется при вводе только из одного источника). Обеспечить подобное блокирование — это одно из назначений функций
select
и
poll
, о которых рассказывается в главе 6. Когда в разделе 6.4 мы перепишем функцию
str_cli
, то как только мы уничтожим с помощью программы
kill
дочерний процесс сервера, клиенту будет отправлено уведомление о полученном сегменте FIN.

5.13. Сигнал SIGPIPE

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

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

Применяется следующее правило: когда процесс производит запись в сокет, получивший сегмент RST, процессу посылается сигнал

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

Если процесс либо перехватывает сигнал и возвращается из обработчика сигнала, либо игнорирует сигнал, то операция записи возвращает ошибку

EPIPE
.

ПРИМЕЧАНИЕ

Часто задаваемым вопросом (FAQ) в Usenet является такой: как получить этот сигнал при первой, а не при второй операции записи? Это невозможно. Как следует из приведенных выше рассуждений, первая операция записи выявляет сегмент RST, а вторая — сигнал. Если запись в сокет, получивший сегмент FIN, допускается, то запись в сокет, получивший сегмент RST, является ошибочной.

Чтобы увидеть, что происходит с сигналом

SIGPIPE
, изменим код нашего клиента так, как показано в листинге 5.10.

Листинг 5.10. Функция str_cli, дважды вызывающая функцию writen

//tcpcliserv/str_cli11.c

 1 #include "unp.h"


 2 void

 3 str_cli(FILE *fp, int sockfd)

 4 {

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


 6  while (Fgets(sendline, MAXLINE, fp) != NULL) {


 7   Writen(sockfd, sendline, 1);

 8   sleep(1);

 9   Writen(sockfd, sendline + 1, strlen(sendline) - 1);

10   if (Readline(sockfd, recvline, MAXLINE) == 0)

11    err_quit("str_cli, server terminated prematurely");


12   Fputs(recvline, stdout);

13  }

14 }

7-9
 Все изменения, которые мы внесли, — это повторный вызов функции
writen
: сначала в сокет записывается первый байт данных, за этим следует пауза в 1 с и далее идет запись остатка строки. Наша цель — выявить сегмент RST при первом вызове функции
writen
и генерировать сигнал
SIGPIPE
при втором вызове.

Если мы запустим клиент на нашем узле Linux, мы получим:

linux % tcpcli11 127.0.0.1

hi thereмы вводим эту строку

hi there    и она отражается сервером

здесь       мы завершаем дочерний процесс сервера

byeзатем мы вводим эту строку

Broken pipe это сообщение выводится интерпретатором

Мы запускаем клиент, вводим одну строку, видим, что строка отражена корректно, и затем завершаем дочерний процесс сервера на узле сервера. Затем мы вводим другую строку (

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

Рекомендуемый способ обработки сигнала

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

5.14. Сбой на узле сервера

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

События развиваются следующим образом:

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

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

writen
(см. листинг 5.3) и отправляется протоколом TCP клиента как сегмент данных. Затем клиент блокируется в вызове функции
readline
в ожидании отраженного ответа.

3. Если мы понаблюдаем за сетью с помощью программы

tcpdump
, то увидим, что TCP клиента последовательно осуществляет повторные передачи сегмента данных, пытаясь получить сегмент ACK от сервера. В разделе 25.11 [128] показан типичный образец повторных передач TCP: реализации, происходящие от Беркли, делают попытки передачи сегмента данных 12 раз, ожидая около 9 мин перед прекращением попыток. Когда TCP клиента наконец прекращает попытки ретрансляции (считая, что узел сервера за это время не перезагружался или что он все еще недоступен, если на узле сервера сбоя не было, но он был недоступен по сети), клиентскому процессу возвращается ошибка. Поскольку клиент блокирован в вызове функции
readline
, она и возвращает эту ошибку. Если на узле сервера произошел сбой, и на все сегменты данных клиента не было ответа, будет возвращена ошибка
ETIMEDOUT
. Но если некий промежуточный маршрутизатор определил, что узел сервера был недоступен, и ответил сообщением ICMP о недоступности получателя, клиент получит либо ошибку
EHOSTUNREACH
, либо ошибку
ENETUNREACH
.

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

readline
, о чем рассказывается в разделе 14.2.

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

SO_KEEPALIVE
в разделе 7.5.

5.15. Сбой и перезагрузка на узле сервера

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

Как было сказано в предыдущем разделе, если клиент не посылает данные серверу, то он не узнает о произошедшем на узле сервера сбое. (При этом считается, что мы не используем параметр сокета

SO_KEEPALIVE
.) События развиваются следующим образом:

1. Мы запускаем сервер, затем — клиент, и вводим строку для проверки установленного соединения. Получаем ответ сервера.

2. Узел сервера выходит из строя и перезагружается.

3. Мы вводим строку на стороне клиента, которая посылается как сегмент данных TCP на узел сервера.

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

5. Наш клиент блокирован в вызове функции

readline
, когда приходит сегмент RST, заставляющий функцию
readline
возвратить ошибку
ECONNRESET
.

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

SO_KEEPALIVE
или некоторых функций, проверяющих наличие связи в клиент-серверном соединении).

5.16. Выключение узла сервера

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

Когда система Unix выключается, процесс

init
обычно посылает всем процессам сигнал
SIGTERM
(мы можем перехватить этот сигнал), ждет в течение некоторого фиксированного времени (часто от 5 до 20 с), а затем посылает сигнал
SIGKILL
(который мы перехватить не можем) всем еще выполняемым процессам. Это дает всем выполняемым процессам короткое время для завершения работы. Если мы не завершили выполнение процесса, это сделает сигнал
SIGKILL
. При завершении процесса закрываются все открытые дескрипторы, а затем мы проходим ту же последовательность шагов, что описывалась в разделе 5.12. Там же было отмечено, что в нашем клиенте следует использовать функцию
select
или
poll
, чтобы клиент определил завершение процесса сервера, как только оно произойдет.

5.17. Итоговый пример TCP

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

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

Рис. 5.5. TCP-соединение клиент-сервер с точки зрения клиента

Как мы отмечали в разделе 4.10, клиент может получить два локальных значения, выбранных ядром, вызвав функцию

getsockname
после установления соединения.

На рис. 5.6 показаны те же четыре значения, но с точки зрения сервера.

Рис. 5.6. TCP-соединение клиент-сервер с точки зрения сервера

Локальный порт (заранее известный порт сервера) задается функцией

bind
. Обычно сервер также задает в этом вызове универсальный IP-адрес, хотя может и ограничиться получением соединений, предназначенных для одного определенного локального интерфейса путем связывания с IP-адресом, записанным без символов подстановки (то есть не универсального). Если сервер связывается с универсальным IP-адресом на узле с несколькими сетевыми интерфейсами, он может определить локальный IP-адрес (указываемый как адрес отправителя в исходящих пакетах) при помощи вызова функции
getsockname
после установления соединения (см. раздел 4.10). Два значения удаленного адреса возвращаются серверу при вызове функции accept. Как мы отмечали в разделе 4.10, если сервером, вызывающим функцию accept, выполняется с помощью функции exec другая программа, то эта программа может вызвать функцию
getpeername
, чтобы при необходимости определить IP-адрес и порт клиента.

5.18. Формат данных

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

Пример: передача текстовых строк между клиентом и сервером

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

main
наших клиента и сервера остаются прежними, как и функция
str_cli
. Меняется только функция
str_echo
, что мы показываем в листинге 5.11.

Листинг 5.11. Функция str_echo, суммирующая два числа

//tcpcliserv/str_echo08.c

 1 #include "unp.h"


 2 void

 3 str_echo(int sockfd)

 4 {

 5  long arg1, arg2;

 6  ssize_t n;

 7  char line[MAXLINE];


 8  for (;;) {

 9   if ((n = Readline(sockfd, line, MAXLINE)) == 0)

10    return; /* соединение закрывается удаленным концом */


11   if (sscanf(line, "%ld%ld", &arg1, &arg2) == 2)

12    snprintf(line, sizeof(line), "%ld\n", arg1 + arg2);

13   else

14    snprintf(line, sizeof(line), "input error\n");

15   n = strlen(line);

16   Writen(sockfd, line, n);

17  }

18 }

11-14
 Мы вызываем функцию
sscanf
, чтобы преобразовать два аргумента из текстовых строк в целые числа типа
long
, а затем функцию
snprintf
для преобразования результата в текстовую строку.

Эти клиент и сервер работают корректно вне зависимости от порядка байтов на их узлах.

Пример: передача двоичных структур между клиентом и сервером

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

long
(см. табл. 1.5).

Функции

main
наших клиента и сервера не изменяются. Мы определяем одну структуру для двух аргументов, другую структуру для результата и помещаем оба определения в наш заголовочный файл
sum.h
, представленный в листинге 5.12. В листинге 5.13 показана функция
str_cli
.

Листинг 5.12. Заголовочный файл sum.h

//tcpcliserv/sum.h

1 struct args {

2  long arg1;

3  long arg2;

4 };


5 struct result {

6  long sum;

7 };

Листинг 5.13. Функция str_cli, отправляющая два двоичных целых числа серверу

//tcpcliserv/str_cli09.c

 1 #include "unp.h"

 2 #include "sum.h"


 3 void

 4 str_cli(FILE *fp, int sockfd)

 5 {

 6  char sendline[MAXLINE];

 7  struct args args;

 8  struct result result;


 9  while (Fgets(sendline, MAXLINE, fp) != NULL) {


10   if (sscanf(sendline, "%ld%ld", &args.arg1, &args.arg2) != 2) {

11    printf("invalid input, %s", sendline);

12    continue;

13   }

14   Writen(sockfd, &args, sizeof(args));

15   if (Readn(sockfd, &result, sizeof(result)) == 0)

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


17   printf("%ld\n", result.sum);

18  }

19 }

10-14
 Функция
sscanf
преобразует два аргумента из текстовых строк в двоичные. Мы вызываем функцию
writen
для отправки структуры серверу.

15-17
 Мы вызываем функцию
readn
для чтения ответа и выводим результат с помощью функции
printf
.

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

str_echo
.

Листинг 5.14. Функция str_echo, складывающая два двоичных целых числа

//tcpcliserv/str_echo09.c

 1 #include "unp.h"

 2 #include "sum.h"


 3 void

 4 str_echo(int sockfd)

 5 {

 6  ssize_t n;

 7  struct args args;

 8  struct result result;


 9  for (;;) {

10   if ((n = Readn(sockfd, &args, sizeof(args))) == 0)

11    return; /* соединение закрыто удаленным концом */


12   result.sum = args.arg1 + args.arg2;

13   Writen(sockfd, &result, sizeof(result));

14  }

15 }

9-14
 Мы считываем аргументы при помощи вызова функции
readn
, вычисляем и запоминаем сумму и вызываем функцию
writen
для отправки результирующей структуры обратно.

Если мы запустим клиент и сервер на двух машинах с аналогичной архитектурой, например на двух компьютерах SPARC, все будет работать нормально:

solaris % tcpcli09 12.106.32.254

11 22мы вводим эти числа

33    а это ответ сервера

-11 -44

-55

Но если клиент и сервер работают на машинах с разными архитектурами, например, сервер в системе FreeBSD на SPARC, в которой используется обратный порядок байтов (big-endian), а клиент — в системе Linux на Intel с прямым порядком байтов (little-endian), результат будет неверным:

linux % tcpcli09 206.168.112.96

1 2мы вводим эти числа

3         и сервер дает правильный ответ

-22 -77потом мы вводим эти числа

-16777314 и сервер дает неверный ответ

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

1. Различные реализации хранят двоичные числа в различных форматах. Наиболее характерный пример — прямой и обратный порядок байтов, описанный в разделе 3.4.

2. Различные реализации могут хранить один и тот же тип данных языка С по- разному. Например, большинство 32-разрядных систем Unix используют 32 бита для типа

long
, но 64-разрядные системы обычно используют 64 бита для того же типа данных (см. табл. 1.5). Нет никакой гарантии, что типы
short
,
int
или
long
имеют какой-либо определенный размер.

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

Есть два общих решения проблемы, связанной с различными форматами данных:

1. Передавайте все численные данные как текстовые строки. Это то, что мы делали в листинге 5.11. При этом предполагается, что у обоих узлов один и тот же набор символов.

2. Явно определяйте двоичные форматы поддерживаемых типов данных (число битов и порядок байтов) и передавайте все данные между клиентом и сервером в этом формате. Пакеты удаленного вызова процедур (Remote Procedure Call, RPC) обычно используют именно эту технологию. В RFC 1832 [109] описывается стандарт представления внешних данных (External Data Representation, XDR), используемый с пакетом Sun RPC.

5.19. Резюме

Первая версия наших эхо-клиента и эхо-сервера содержала около 150 строк (включая функции

readline
и
writen
), но многие ее детали пришлось модифицировать. Первой проблемой, с которой мы столкнулись, было превращение дочерних процессов в зомби, и для обработки этой ситуации мы перехватывали сигнал
SIGCHLD
. Затем наш обработчик сигнала вызывал функцию
waitpid
, и мы показали, что должны вызывать именно эту функцию вместо более старой функции
wait
, поскольку сигналы Unix не помещаются в очередь. В результате мы рассмотрели некоторые подробности обработки сигналов POSIX, аза дополнительной информацией по этой теме вы можете обратиться к [110, глава 10].

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

select
или
poll
, позволяющие ожидать готовности любого из множества дескрипторов вместо блокирования при обращении к одному дескриптору.

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

SO_KEEPALIVE
.

В нашем простом примере происходил обмен текстовыми строками, и поскольку от сервера не требовалось просматривать отражаемые им строки, все работало нормально. Передача численных данных между клиентом и сервером может привести к ряду новых проблем, что и было продемонстрировано.

Упражнения

1. Создайте сервер TCP на основе листингов 5.1 и 5.2 и клиент TCP на основе листингов 5.3 и 5.4. Запустите сервер, затем запустите клиент. Введите несколько строк, чтобы проверить, что клиент и сервер работают. Завершите работу клиента, введя символ конца файла, и заметьте время. Используйте программу

netstat
на узле клиента для проверки того, что клиентский конец соединения проходит состояние TIME_WAIT. Запускайте
netstat
примерно каждые 5 с, чтобы посмотреть, когда закончится состояние TIME_WAIT. Каково время MSL для вашей реализации?

2. Что происходит с нашим соединением клиент-сервер, если мы запускаем клиент и подключаем к стандартному потоку ввода двоичный файл?

3. В чем разница между нашим соединением клиент-сервер и использованием клиента Telnet для взаимодействия с нашим эхо-сервером?

4. В нашем примере в разделе 5.12 мы проверили, что первые два сегмента завершения соединения (сегмент FIN от сервера, на который затем клиент отвечает сегментом ACK) отправляются, при просмотре состояний сокета с помощью программы

netstat
. Происходит ли обмен двумя последними сегментами (FIN от клиента, на который затем сервер отвечает сегментом ACK)? Если да, то когда? Если нет, то почему?

5. Что произойдет с примером, рассмотренным в разделе 5.14, если между шагами 2 и 3 мы перезапустим сервер на узле сервера?

6. Чтобы проверить, что происходит с сигналом

SIGPIPE
в разделе 5.13, измените листинг 5.3 следующим образом. Напишите обработчик сигнала для
SIGPIPE
, который будет просто выводить сообщение и возвращать управление. Установите этот обработчик сигнала перед вызовом функции
connect
. Измените номер порта сервера на 13 (порт сервера времени и даты). Когда соединение установится, с помощью функции
sleep
войдите в состояние ожидания на 2 с, с помощью функции
write
запишите несколько байтов в сокет, проведите в состоянии ожидания (
sleep
) еще 2 с и с помощью функции
write
запишите еще несколько байтов. Запустите программу. Что происходит?

7. Что произойдет на рис. 5.5, если IP-адрес узла сервера, заданный клиентом при вызове функции

connect
, является IP-адресом, связанным с крайним правым канальным уровнем на стороне сервера, а не IP-адресом, связанным с крайним левым канальным уровнем?

8. В нашем примере эхо-сервера, осуществляющего сложение двух целых чисел (см. листинг 5.14), когда клиент и сервер принадлежат системам с различным порядком байтов, для небольших положительных чисел получается правильный ответ, но для небольших отрицательных чисел ответ неверен. Почему? (Подсказка: нарисуйте схему обмена значениями через сокет, аналогичную рис. 3.4.)

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

htonl
, а сервер затем вызывает функцию
ntohl
для каждого аргумента перед сложением и выполняет аналогичное преобразование результата?

10. Что произойдет в листинге 5.13 и 5.14, если в качестве узла клиента используется компьютер SPARC, где данные типа

long
занимают 32 бита, а в качестве узла сервера — Digital Alpha, где данные типа
long
занимают 64 бита? Изменится ли что-либо, если клиент и сервер поменяются местами?

11. На рис. 5.5 указано, что IP-адрес клиента выбирается IP на основе маршрутизации. Что это значит?

Глава 6