355 500 произведений, 25 200 авторов.

Электронная библиотека книг » Уильям Ричард Стивенс » UNIX: разработка сетевых приложений » Текст книги (страница 14)
UNIX: разработка сетевых приложений
  • Текст добавлен: 17 сентября 2016, 20:42

Текст книги "UNIX: разработка сетевых приложений"


Автор книги: Уильям Ричард Стивенс


Соавторы: Эндрю М. Рудофф,Билл Феннер

Жанр:

   

ОС и Сети


сообщить о нарушении

Текущая страница: 14 (всего у книги 88 страниц) [доступный отрывок для чтения: 32 страниц]

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

Термином медленный системный вызов( 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 terminatedn", 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.


    Ваша оценка произведения:

Популярные книги за неделю