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

Широковещательная передача

20.1. Введение

В этой главе мы расскажем о широковещательной передаче (brodacasting), а в следующей главе — о многоадресной передаче (multicasting). Во всех предыдущих примерах рассматривалась направленная (одноадресная) передача (unicasting), когда процесс общается только с одним определенным процессом. Действительно, TCP работает только с адресами направленной передачи, хотя UDP и символьные сокеты поддерживают и другие парадигмы передачи. В табл. 20.1 представлено сравнение различных видов адресации.


Таблица 20.1. Различные формы адресации

ТипIPv4Ipv6TCPUDPКоличество идентифицируемых интерфейсовКоличество интерфейсов, куда доставляется сообщение
Направленная передачаОдинОдин
Передача наиболее подходящему узлуПока нетНаборОдин из набора
Многоадресная передачаНе обязательноНаборВсе в наборе
Широковещательная передачаВсеВсе

С введением IPv6 к парадигмам адресации добавилась передача наиболее подходящему узлу (anycasting). Ее вариант для IPv4 не получил широкого распространения. Он описан в RFC 1546 [88]. Передача наиболее подходящему узлу для IPv6 определяется в документе RFC 3513 [44]. Этот режим позволяет обращаться к одной (обычно «ближайшей» в некоторой метрике) из множества систем, предоставляющих одинаковые сервисы. Правильная конфигурация системы маршрутизации позволяет узлам пользоваться сервисами передачи наиболее подходящему узлу по IPv4 и IPv6 путем добавления одного и того же адреса в протокол маршрутизации в нескольких местах. Однако RFC 3513 разрешает иметь адреса такого типа только маршрутизаторам; узлы не имеют права предоставлять сервисы передачи наиболее подходящему узлу. На момент написания этой книги интерфейс API для использования адресов передачи наиболее подходящему узлу еще не определен. Архитектура IPv6 в настоящий момент находится на стадии совершенствования, и в будущем узлы, вероятно, получат возможность динамически предоставлять сервисы передачи наиболее подходящему узлу.

Вот наиболее важные положения из табл. 20.1:

■ Поддержка многоадресной передачи не обязательна для IPv4, но обязательна для IPv6.

■ Поддержка широковещательной передачи не обеспечивается в IPv6: любое приложение IPv4, использующее широковещательную передачу, для совместимости с IPv6 должно быть преобразовано так, чтобы использовать вместо широковещательной передачи многоадресную.

■ Широковещательная и многоадресная передачи требуют наличия протокола UDP или символьного IP и не работают с TCP.

Одним из применений широковещательной передачи является поиск сервера в локальной подсети, когда известно, что сервер находится в этой локальной подсети, но его IP-адрес для направленной передачи неизвестен. Иногда эту процедуру называют обнаружением ресурса (resource discovery). Другое применение — минимизация сетевого трафика в локальной сети, когда несколько клиентов взаимодействуют с одним сервером. Можно привести множество примеров интернет-приложений, использующих для этой цели широковещательную передачу. Некоторые из них используют и многоадресную передачу.

■ Протокол разрешения адресов (Address Resolution Protocol, ARP). Это фундаментальная часть IPv4, а не пользовательское приложение. ARP отправляет широковещательный запрос в локальную подсеть, суть которого такова: «Система с IP-адресом a.b.c.d, идентифицируйте себя и сообщите свой аппаратный адрес».

■ Протокол начальной загрузки (Bootstrap Protocol, BOOTP). Клиент предполагает, что сервер находится в локальной подсети, и посылает запрос на широковещательный адрес (часто 255.255.255.255, поскольку клиент еще не знает IP-адреса, маски подсети и адреса ограниченной широковещательной передачи в этой подсети).

■ Протокол синхронизации времени (Network Time Protocol, NTP). В обычном сценарии клиент NTP конфигурируется с IP-адресом одного или более серверов, которые будут использоваться для определения времени, и опрашивает серверы с определенной частотой (с периодом 64 с или больше). Клиент обновляет свои часы, используя сложные алгоритмы, основанные на значении истинного времени (time-of-day), возвращаемом серверами, и величине периода RTT обращения к серверам. Но в широковещательной локальной сети вместо того, чтобы каждый клиент обращался к одному серверу, сервер может отправлять текущее значение времени с помощью широковещательных сообщений каждые 64 с для всех клиентов в локальной подсети, ограничивая тем самым сетевой трафик.

■ Демоны маршрутизации. Наиболее часто используемый демон маршрутизации

routed
распространяет по локальной сети широковещательные сообщения, содержащие таблицу маршрутизации. Это позволяет всем другим маршрутизаторам, соединенным с локальной сетью, получать объявления маршрутизации. При этом в конфигурацию каждого маршрутизатора не обязательно должны входить IP-адреса соседних маршрутизаторов. Это свойство также используется (многие могут отметить, что «используется неправильно») узлами локальной сети, прослушивающими объявления о маршрутизации и изменяющими в соответствии с этим свои таблицы маршрутизации.

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

20.2. Широковещательные адреса

Если мы обозначим адрес IPv4 в виде

{subnetid, hostid}
, где
subnetid
означает биты, относящиеся к маске сети (или префиксу CIDR), a
hostid
— все остальные биты, мы получим два типа широковещательных адресов. Поле, целиком состоящее из единичных битов, обозначим -1.

1. Широковещательный адрес подсети:

{subnetid, -1}
. Сообщение адресуется на все интерфейсы в заданной подсети. Например, в подсети 192.168.42/24 широковещательным адресом будет 192.168.42.255.

Обычно маршрутизаторы не передают широковещательные сообщения дальше из подсети [128, с. 226-227]. На рис. 20.1 изображен маршрутизатор, соединенный с двумя подсетями 192.168.42/24 и 192.168.123/24.

Рис. 20.1. Передает ли маршрутизатор дальше широковещательное сообщение, направленное в подсеть?

Маршрутизатор получает дейтаграмму IP направленной передачи в подсети 192.168.123/24 с адресом получателя 192.168.42.255 (адрес широковещательной передачи для подсети другого интерфейса). Обычно маршрутизатор не передает дейтаграмму дальше в подсеть 192.168.42/24. У некоторых систем имеется параметр конфигурации, позволяющий передавать широковещательные сообщения, направленные в подсеть (см. приложение Е [111]).

ПРИМЕЧАНИЕ

Пересылка широковещательных сообщений, направленных в подсеть, делает возможным атаки типа «отказ в обслуживании» особого класса, получившего название «усиление» (amplification). Например, отправка эхо-запроса ICMP на широковещательный адрес подсети может привести к получению нескольких ответов. При подмене IP-адреса отправителя это дает возможность загрузить канал жертвы, снизив ее доступность. По этой причине рекомендуется отключать соответствующий параметр настройки маршрутизаторов.

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

2. Локальный широковещательный адрес: {-1,-1} или 255.255.255.255. Дейтаграммы, предназначенные для этого ограниченного адреса, никогда не должны передаваться маршрутизатором.

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

ПРИМЕЧАНИЕ

Адрес 255.255.255.255 предназначен для использования в качестве адреса получателя во время процесса начальной загрузки такими приложениями, как DHCP и BOOTP, которым еще не известен IP-адрес узла.

Возникает вопрос: что делает узел, когда приложение посылает дейтаграмму UDP на адрес 255.255.255.255? Большинство узлов допускают это (если процесс установил параметр сокета SO_BROADCAST) и преобразуют адрес получателя в широковещательный адрес исходящего интерфейса, направленный в подсеть. Для отправки пакета на конкретный адрес 255.255.255.255 часто приходится работать непосредственно с канальным уровнем.

Может появиться другой вопрос: что делает узел с несколькими сетевыми интерфейсами, когда приложение посылает дейтаграмму UDP на адрес 255.255.255.255? Некоторые системы посылают одно широковещательное сообщение с основного интерфейса (с интерфейса, который был сконфигурирован первым) с IP-адресом получателя, равным широковещательному адресу подсети этого интерфейса [128, с. 736]. Другие системы посылают по одной копии дейтаграммы с каждого интерфейса, поддерживающего широковещательную передачу. В разделе 3.3.6 RFC 1122 [10] по этому вопросу не сказано ничего. Однако если приложению нужно отправить широковещательное сообщение со всех интерфейсов, поддерживающих широковещательную передачу, то в целях переносимости оно должно получить конфигурацию интерфейсов (см. раздел 16.6) и выполнить по одному вызову sendto для каждого из них, указав в качестве адреса получателя широковещательный адрес подсети этого интерфейса.

20.3. Направленная и широковещательная передачи

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

Рис. 20.2. Пример направленной передачи дейтаграммы UDP

Адрес подсети Ethernet — 192.168.42/24. 24 разряда адреса относятся к маске сети, а 8 разрядов — к идентификатору узла. Приложение на узле, изображенном слева, вызывает функцию sendto для сокета UDP, отправляя дейтаграмму на адрес 192.168.42.3, порт 7433. Уровень UDP добавляет в начало дейтаграммы заголовок UDP и передает дейтаграмму UDP уровню IP. IP добавляет заголовок IPv4 и определяет исходящий интерфейс. В случае использования сети Ethernet активизируется протокол ARP для определения адреса Ethernet, соответствующего IP-адресу получателя:

08:00:20:03:f6:42
. Затем пакет посылается как кадр Ethernet с 48-разрядным адресом получателя Ethernet. Поле типа кадра Ethernet будет равно
0x0800
, что соответствует пакету IPv4. Тип кадра для пакета IPv6 —
0x86dd
.

Интерфейс Ethernet на узле, изображенном в центре, видит проходящий кадр и сравнивает адрес получателя Ethernet со своим собственным адресом Ethernet (

02:60:8c:2f:4e:00
). Поскольку они не равны, интерфейс игнорирует кадр. Поскольку кадр является кадром направленной передачи, этот узел не тратит на его обработку никаких ресурсов. Интерфейс игнорирует кадр.

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

0x0800
, пакет помещается в очередь ввода IP.

Когда уровень IP обрабатывает пакет, он сначала сравнивает IP-адрес получателя (192.168.42.3) со всеми собственными IP-адресами. (Вспомним, что узел может иметь несколько сетевых интерфейсов. Также вспомним наше обсуждение модели системы с жесткой привязкой (strong end system model) и системы с гибкой привязкой (weak end system model) в разделе 8.8.) Поскольку адрес получателя — это один из собственных IP-адресов узла, пакет принимается.

Затем уровень IP проверяет поле протокола в заголовке IPv4. Его значение для UDP равно 17, поэтому далее дейтаграмма IP передается UDP.

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

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

Теперь мы рассмотрим похожий пример в той же подсети, но при этом приложение будет отправлять дейтаграмму UDP на широковещательный адрес для подсети 192.168.42.255. Этот пример представлен на рис. 20.3.

Рис. 20.3. Пример широковещательной дейтаграммы UDP

Когда узел, изображенный слева, отправляет дейтаграмму, он замечает, что IP-адрес получателя — это широковещательный адрес подсети, и сопоставляет ему адрес Ethernet, состоящий из 48 единичных битов:

ff:ff:ff:ff:ff:ff
. Это заставляет каждый интерфейс Ethernet в подсети получить кадр. Оба узла, изображенные на правой части рисунка, работающие с IPv4, получат кадр. Поскольку тип кадра Ethernet —
0800
, оба узла передают пакет уровню IP. Так как IP-адрес получателя совпадает с широковещательным адресом для каждого из двух узлов, и поскольку поле протокола — 17 (UDP), оба узла передают пакет UDP.

Узел, изображенный справа, передает дейтаграмму UDP приложению, связанному с портом UDP 520. Приложению не нужно выполнять никаких специальных действий, чтобы получить широковещательную дейтаграмму UDP — оно лишь создает сокет UDP и связывает номер приложения порта с сокетом. (Предполагается, как обычно, что связанный IP-адрес —

INADDR_ANY
.)

Но на узле, изображенном в центре, с портом UDP 520 не связано никакое приложение. UDP этого узла игнорирует полученную дейтаграмму. Узел не должен отправлять сообщение ICMP о недоступности порта, поскольку это может вызвать лавину широковещательных сообщений (broadcast storm): ситуацию, в которой множество узлов сети генерируют ответы приблизительно в одно и то же время, в результате чего сеть просто невозможно использовать в течение некоторого времени. Кроме того, не совсем понятно, что должен предпринять получатель сообщения об ошибке: что, если некоторые получатели будут сообщать об ошибках, а другие — нет?

В этом примере мы также показываем дейтаграмму, которую изображенный слева узел доставляет сам себе. Это свойство широковещательных сообщений: по определению широковещательное сообщение идет к каждому узлу подсети, включая отправляющий узел [128, с. 109–110]. Мы также предполагаем, что отправляющее приложение связано с портом, на который оно отправляет дейтаграммы (порт 520), поэтому оно получит копию каждой отправленной им широковещательной дейтаграммы. (Однако в общем случае не требуется, чтобы процесс связывался с портом UDP, на который он отправляет дейтаграммы.)

ПРИМЕЧАНИЕ

В этом примере мы демонстрируем закольцовку, которая осуществляется либо на уровне IP, либо на канальном уровне, создающем копию [128, с. 109-110] и отправляющем ее вверх по стеку протоколов. Сеть могла бы использовать физическую закольцовку, но это может вызвать проблемы в случае сбоев сети (например, линия Ethernet без терминатора).

Этот пример отражает фундаментальную проблему, связанную с широковещательной передачей: каждый узел IPv4 в подсети, даже не выполняющий соответствующего приложения, должен полностью обрабатывать широковещательную дейтаграмму UDP при ее прохождении вверх по стеку протоколов, включая уровень UDP, прежде чем сможет ее проигнорировать. (Вспомните наше обсуждение следом за листингом 8.11). Более того, каждый не-IP-узел в подсети (скажем, узел, на котором работает IPX Novell) должен также получать целый кадр на канальном уровне, перед тем как он сможет проигнорировать этот кадр (в данном случае мы предполагаем, что узел не поддерживает кадры определенного типа — для дейтаграммы IPv4 тип равен

0x0800
). Если приложение генерирует дейтаграммы IP с большой скоростью (например, аудио- или видеоданные), то такая ненужная обработка может серьезно повлиять на остальные узлы подсети. В следующей главе мы увидим, как эта проблема решается с помощью многоадресной передачи.

ПРИМЕЧАНИЕ

Для рис. 20.3 мы специально выбрали порт UDP 520. Это порт, используемый демоном routed для обмена пакетами по протоколу информации о маршрутизации (Routing Information Protocol, RIP). Все маршрутизаторы в подсети, использующие RIP, будут отправлять широковещательную дейтаграмму UDP каждые 30 секунд. Если в подсети имеется 200 узлов, в том числе два маршрутизатора, использующих RIP, то 198 узлов должны будут обрабатывать (и игнорировать) эти широковещательные дейтаграммы каждые 30 с, если ни на одном из них не запущен демон routed. Протокол RIP версии 2 использует многоадресную передачу именно для того, чтобы избавиться от этой проблемы.

20.4. Функция dg_cli при использовании широковещательной передачи

Мы еще раз изменим нашу функцию

dg_cli
, на этот раз дав ей возможность отправлять широковещательные сообщения стандартному серверу времени и даты UDP (см. табл. 2.1) и выводить все ответы. Единственное изменение, внесенное нами в функцию
main
(см. листинг 8.3), состоит в изменении номера порта получателя на 13:

servaddr.sin_port = htons(13);

Сначала мы откомпилируем измененную функцию

main
с прежней функцией
dg_cli
из листинга 8.4 и запустим ее на узле
freebsd
:

freebsd % udpcli01 192.168.42.255

hi

sendto error: Permission denied

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

sendto
, и возвращается ошибка
EACCESS
. Мы получаем ошибку, потому что нам не разрешается посылать дейтаграмму на широковещательный адрес получателя, если мы не указали ядру явно, что будем передавать широковещательное сообщение. Мы выполняем это условие, установив параметр сокета
SO_BROADCAST
(см. табл. 7.1).

ПРИМЕЧАНИЕ

Беркли-реализации реализуют эту «защиту от дурака» (sanity check). Однако Solaris 2.5 принимает дейтаграмму, предназначенную для широковещательного адреса, даже если мы не задаем параметр сокета SO_BROADCAST. Стандарт POSIX требует установки параметра сокета SO_BROADCAST для отправки широковещательной дейтаграммы.

В 4.2BSD широковещательная передача была привилегированной операцией, и параметра сокета SO_BROADCAST не существовало. В 4.3BSD этот параметр был добавлен и каждому процессу стало разрешено его устанавливать.

Теперь мы изменим нашу функцию

dg_cli
, как показано в листинге 20.1[1]. Эта версия устанавливает параметр сокета
SO_BROADCAST
и выводит все ответы, полученные в течение 5 с.

Листинг 20.1. Функция dg_cli, осуществляющая широковещательную передачу

//bcast/dgclibcast1.c

 1 #include "unp.h"


 2 static void recvfrom_alarm(int);


 3 void

 4 dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen)

 5 {

 6  int n;

 7  const int on = 1;

 8  char sendline[MAXLINE], recvline[MAXLINE + 1];

 9  socklen_t len;

10  struct sockaddr *preply_addr;


11  preply_addr = Malloc(servlen);


12  Setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, &on, sizeof(on));


13  Signal(SIGALRM, recvfrom_alarm);


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


15   Sendto(sockfd, sendline, strlen(sendline), 0, pservaddr, servlen);


16   alarm(5);


17   for (;;) {

18    len = servlen;

19    n = recvfrom(sockfd, recvline, MAXLINE, 0, preply_addr, &len);

20    if (n < 0) {

21     if (errno == EINTR)

22      break; /* окончание ожидания ответов */

23     else

24      err_sys("recvfrom error");

25    } else {

26     recvline[n] = 0; /* завершающий нуль */

27     printf("from %s: %s",

28      Sock_ntop_host(preply_addr, len), recvline);

29    }

30   }

31  }

32  free(preply_addr);

33 }


34 static void

35 recvfrom_alarm(int signo)

36 {

37  return; /* прерывание recvfrom() */

38 }

Выделение памяти для адреса сервера, установка параметра сокета

11-13
 Функция
malloc
выделяет в памяти пространство для адреса сервера, возвращаемого функцией
recvfrom
. Устанавливается параметр сокета
SO_BROADCAST
, устанавливается обработчик сигнала
SIGALRM
.

Чтение строки, отправка сокету, чтение всех ответов

14-24
 Следующие два вызова,
fgets
и
sendto
, выполняются так же, как и в предыдущих версиях этой функции. Но поскольку мы посылаем широковещательную дейтаграмму, мы можем получить множество ответов. Мы вызываем в цикле функцию
recvfrom
и выводим все ответы, полученные в течение 5 с. По истечении 5 с генерируется сигнал
SIGALRM
, вызывается наш обработчик сигнала и функция
recvfrom
возвращает ошибку
EINTR
.

Вывод каждого полученного ответа

25-29
 Для каждого полученного ответа мы вызываем функцию
sock_ntop_host
, которая в случае IPv4 возвращает строку, содержащую IP-адрес сервера в точечно-десятичной записи. Эта строка выводится вместе с ответом сервера.

Если мы запустим программу, задав широковещательный адрес подсети 192. 168.42.255, мы увидим следующее:

bsdi % udpcli01 192.168.42.255 hi

from 192 168.42 2: Sat Aug 2 16.42.45 2003

from 192.168.42.1: Sat Aug 2 14.42.45 2003

from 192.168.42.3: Sat Aug 2 14.42.45 2003

hello

from 192.168.42.3: Sat Aug 2 14.42.57 2003

from 192.168.42.2: Sat Aug 2 16.42.57 2003

from 192.168.42.1: Sat Aug 2 14.42.57 2003

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

Все системы сообщают одно и то же время, поскольку на них используется NTP (Network Time Protocol — протокол синхронизации времени).

Фрагментация IP-пакетов и широковещательная передача

В Беркли-ядрах фрагментация широковещательных дейтаграмм запрещена. Если размер IP-дейтаграммы, посылаемой на широковещательный адрес, превышает размер MTU исходящего интерфейса, возвращается ошибка

EMSGSIZE
[128, с. 233–234]. Эта стратегия впервые появилась в 4.2BSD. На самом деле нет никаких технических препятствий для фрагментирования широковещательных дейтаграмм, но широковещательная передача сама по себе связана со значительной нагрузкой на сеть, поэтому не стоит дополнительно увеличивать эту нагрузку, используя фрагментацию.

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

bsdi % udpcli01 192.168.42.255 < 2000line

sendto error: Message too long

ПРИМЕЧАНИЕ

Это ограничение реализовано в AIX, FreeBSD и MacOS. Linux, Solaris и HP-UX фрагментируют дейтаграммы, отправленные на широковещательный адрес. Однако в целях переносимости приложение, которому нужно сделать широковещательный запрос, должно определять MTU для интерфейса, через который будет отправлено сообщение, при помощи параметра SIOCGIPMTU функции ioctl, после чего вычесть размер заголовков IP и транспортного протокола. Альтернативный подход: выбрать типичное значение MTU (например, 1500 для Ethernet) и использовать его в качестве константы.

20.5. Ситуация гонок

Ситуация гонок (race condition) обычно возникает, когда множество процессов получают доступ к общим для них данным, но корректность результата зависит от порядка выполнения процессов. Поскольку порядок выполнения процессов в типичных системах Unix зависит от множества факторов, которые могут меняться от запуска к запуску, иногда результат корректен, а иногда — нет. Наиболее сложным для отладки типом гонок является такой, когда результат получается некорректным только изредка. Более подробно о ситуациях гонок мы поговорим в главе 26, когда будем обсуждать взаимные исключения (mutex) и условные переменные (condition variables). При программировании потоков всегда возникают проблемы с ситуациями гонок, поскольку значительное количество данных является общим для всех потоков (например, все глобальные переменные).

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

Чтобы понять эту проблему, рассмотрим пример. Ситуация гонок возникает при выполнении программы из листинга 20.1. Потратьте несколько минут и посмотрите, сможете ли вы ее обнаружить. (Подсказка: в каком месте программы мы можем находиться, когда доставляется сигнал?) Вы можете также инициировать ситуацию гонок следующим образом: изменить аргумент функции

alarm
с 5 на 1 и добавить вызов
sleep(1)
сразу же после
printf
.

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

alarm
равным 1 с. Мы блокируемся в вызове функции
recvfrom
, а затем для нашего сокета приходит первый ответ, вероятно, в течение нескольких миллисекунд. Ответ возвращается функцией
recvfrom
, но затем мы входим в спящее состояние на одну секунду. Принимаются остальные ответы и помещаются в приемный буфер сокета. Но пока мы находимся в спящем состоянии, время таймера
alarm
истекает и генерируется сигнал
SIGALRM
. При этом вызывается наш обработчик сигнала, затем он возвращает управление и прерывает функцию
sleep
, в которой мы блокированы. Далее мы повторяем цикл и читаем установленные в очередь ответы с паузой в одну секунду каждый раз, когда выводится ответ. Прочитав все ответы, мы снова блокируемся в вызове функции
recvfrom
, однако таймер уже не работает. Мы окажемся навсегда заблокированы в вызове функции
recvfrom
. Фундаментальная проблема здесь в том, что наша цель — обеспечить прерывание блокирования в функции
recvfrom
обработчиком сигнала, однако сигнал может быть доставлен в любое время, и наша программа в момент доставки сигнала может находиться в любом месте бесконечного цикла
for
.

Теперь мы проанализируем четыре различных варианта решения этой проблемы: одно некорректное и три различных корректных решения.

Блокирование и разблокирование сигнала

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

for
. Эта версия представлена в листинге 20.2.

Листинг 20.2. Блокирование сигналов при выполнении в цикле for (некорректное решение)

//bcast/dgclibcast3.c

 1 #include "unp.h"


 2 static void recvfrom_alarm(int);


 3 void

 4 dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen)

 5 {

 6  int n;

 7  const int on = 1;

 8  char sendline[MAXLINE], recvline[MAXLINE + 1];

 9  sigset_t sigset_alrm;

10  socklen_t len;

11  struct sockaddr *preply_addr;


12  preply_addr = Malloc(servlen);


13  Setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, &on, sizeof(on));


14  Sigemptyset(&sigset_alrm);

15  Sigaddset(&sigset_alrm, SIGALRM);


16  Signal(SIGALRM, recvfrom_alarm);


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

18   Sendto(sockfd, sendline, strlen(sendline), 0, pservaddr, servlen);

19   alarm(5);

20   for (;;) {

21    len = servlen;

22    Sigprocmask(SIG_UNBLOCK, &sigset_alrm, NULL);

23    n = recvfrom(sockfd, recvline, MAXLINE, 0, preply_addr, &len);

24    Sigprocmask(SIG_BLOCK, &sigset_alrm, NULL);

25    if (n < 0) {

26     if (errno == EINTR)

27      break; /* окончание ожидания ответа */

28     else

29      err_sys("recvfrom error");

30    } else {

31     recvline[n] = 0; /* завершающий нуль */

32     printf("from %s: %s",

33     Sock_ntop_host(preply_addr, len), recvline);

34    }

35   }

36  }

37  free(preply_addr);

38 }


39 static void

40 recvfrom_alarm(int signo)

41 {

42  return; /* выход из recvfrom() */

43 }

Объявление набора сигналов и инициализация

14-15
 Мы объявляем набор сигналов, инициализируем его как пустой набор (
sigemptyset
) и включаем бит, соответствующий сигналу
SIGALRM
(
sigaddset
).

Разблокирование и блокирование сигнала

21-24
 Перед вызовом функции
recvfrom
мы разблокируем сигнал (с тем, чтобы он мог быть доставлен, пока наша программа блокирована), а затем блокируем его, как только завершается функция
recvfrom
. Если сигнал генерируется (истекает время таймера), когда сигнал блокирован, то ядро запоминает этот факт, но доставить сигнал (то есть вызвать наш обработчик) не может, пока сигнал не будет разблокирован. В этом состоит принципиальная разница между генерацией сигнала и его доставкой. В главе 10 [110] предоставлена более подробная информация обо всех аспектах обработки сигналов POSIX.

Если мы откомпилируем и запустим эту программу, нам будет казаться, что она работает нормально, но все программы, порождающие ситуацию гонок, большую часть времени работают без каких-либо проблем! Проблема остается: разблокирование сигнала, вызов функции

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

Вариантом решения может быть установка глобального флага при доставке сигнала его обработчиком:

recvfrom_alarm(int signo) {

 had_alarm = 1;

 return;

}

Флаг сбрасывается в 0 каждый раз, когда вызывается функция

alarm
. Наша функция
dg_cli
проверяет этот флаг перед вызовом функции
recvfrom
и не вызывает ее, если флаг ненулевой.

for (;;) {

 len = servlen;

 Sigprocmask(SIG_UNBLOCK, &sigset_alrm, NULL);

 if (had_alarm == 1)

  break;

 n = recvfrom(sockfd, recvline, MAXLINE, 0, preply_addr, &len);

Если сигнал был сгенерирован во время его блокирования (после предыдущего возвращения из функции

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

Блокирование и разблокирование сигнала с помощью функции pselect

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

pselect
(см. раздел 6.9), как показано в листинге 20.3.

Листинг 20.3. Блокирование и разблокирование сигналов с помощью функции pselect

//bcast/dgclibcast4.с

 1 #include "unp.h"


 2 static void recvfrom_alarm(int);


 3 void

 4 dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen)

 5 {

 6  int n;

 7  const int on = 1;

 8  char sendline[MAXLINE], recvline[MAXLINE + 1];

 9  fd_set rset;

10  sigset_t sigset_alrm, sigset_empty;

11  socklen_t len;

12  struct sockaddr *preply_addr;


13  preply_addr = Malloc(servlen);


14  Setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, &on, sizeof(on));


15  FD_ZERO(&rset);


16  Sigemptyset(&sigset_empty);

17  Sigemptyset(&sigset_alrm);

18  Sigaddset(&sigset_alrm, SIGALRM);


19  Signal(SIGALRM, recvfrom_alarm);


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

21   Sendto(sockfd, sendline, strlen(sendline), 0, pservaddr, servlen);


22   Sigprocmask(SIG_BLOCK, &sigset_alrm, NULL);

23   alarm(5);

24   for (;;) {

25    FD_SET(sockfd, &rset);

26    n = pselect(sockfd + 1, &rset, NULL, NULL, NULL, &sigset_empty);

27    if (n < 0) {

28     if (errno == EINTR)

29      break;

30     else

31      err_sys("pselect error");

32    } else if (n != 1)

33    err_sys("pselect error; returned %d", n);


34    len = servlen;

35    n = Recvfrom(sockfd, recvline, MAXLINE, 0, preply_addr, &len);

36    recvline[n] = 0; /* завершающий нуль */

37    printf("from %s: %s",

38    Sock_ntop_host(preply_addr, len), recvline);

39   }

40  }

41  free(preply_addr);

42 }


43 static void

44 recvfrom_alarm(int signo)

45 {

46  return; /* просто прерываем recvfrom() */

47 }

22-23
 Мы блокируем сигнал
SIGALRM
и вызываем функцию
pselect
. Последний аргумент этой функции — указатель на нашу переменную
sigset_empty
, являющуюся набором сигналов, в котором нет блокированных сигналов (все сигналы разблокированы). Функция
pselect
сохранит текущую маску сигналов (которая блокирует
SIGALRM
), проверит заданные дескрипторы, заблокируется при необходимости с маской сигналов, установленной в пустой набор, но перед завершением функции маска сигналов процесса будет переустановлена в исходное значение, которое она имела при вызове функции
pselect
. Ключ к пониманию функции
pselect
в том, что установка маски сигналов, проверка дескрипторов и переустановка маски сигнала — это атомарные операции по отношению к вызывающему процессу.

34-38
 Если наш сокет готов для чтения, мы вызываем функцию
recvfrom
, зная, что она не заблокируется.

Как мы упоминали в разделе 6.9, функция

pselect
— относительно новая среди других, описываемых спецификацией POSIX. Из всех систем, показанных на рис. 1.7, эту функцию поддерживают только FreeBSD и Linux. Тем не менее в листинге 20.4 представлена простая, хотя и некорректная ее реализация. Мы приводим ее здесь, несмотря на некорректность, чтобы продемонстрировать три стадии решения: установку маски сигнала в значение, заданное вызывающей функцией, с сохранением текущей маски, проверку дескрипторов и переустановку маски сигнала.

Листинг 20.4. Простая некорректная реализация функции pselect

//lib/pselect.c

 9 #include "unp.h"


10 int

11 pselect(int nfds, fd_set *rset, fd_set *wset, fd_set *xset,

12  const struct timespec *ts, const sigset_t *sigmask)

13  {

14  int n;

15  struct timeval tv;

16  sigset_t savemask;


17  if (ts != NULL) {

18   tv.tv_sec = ts->tv_sec;

19   tv.tv_usec = ts->tv_nsec / 1000; /* наносекунды -> микросекунды */

20  }

21  sigprocmask(SIG_SETMASK, sigmask, &savemask); /* маска вызывающего

                                                     процесса */

22  n = select(nfds, rset, wset, xset., (ts == NULL) ? NULL : &tv);

23  sigprocmask(SIG_SETMASK, &savemask, NULL); /* восстанавливаем

                                                  исходную маску */


24  return (n);

25 }

Использование функций sigsetjmp и siglongjmp

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

siglongjmp
. Этот метод называется нелокальным оператором goto (nonlocal goto), поскольку мы можем использовать его для перехода из одной функции в другую. В листинге 20.5 проиллюстрирована эта технология.

Листинг 20.5. Вызов функций sigsetjmp и siglongjmp из обработчика сигнала

//bcast/dgclibcast5.c

 1 #include "unp.h"

 2 #include 


 3 static void recvfrom_alarm(int);

 4 static sigjmp_buf jmpbuf;


 5 void

 6 dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen)

 7 {

 8  int n;

 9  const int on = 1;

10  char sendline[MAXLINE], recvline[MAXLINE + 1];

11  socklen_t len;

12  struct sockaddr *preply_addr;


13  preply_addr = Malloc(servlen);


14  Setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, &on, sizeof(on));


15  Signal(SIGALRM, recvfrom_alarm);


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


17   Sendto(sockfd, sendline, strlen(sendline), 0, pservaddr, servlen);


18   alarm(5);

19   for (;;) {

20    if (sigsetjmp(jmpbuf, 1) != 0)

21     break;

22    len = servlen;

23    n = Recvfrom(sockfd, recvline, MAXLINE, 0, preply_addr, &len);

24    recvline[n] = 0; /* null terminate */

25    printf("from %s: %s",

26     Sock_ntop_host(preply_addr, len), recvline);

27   }

28  }

29  free(preply_addr);

30 }


31 static void

32 recvfrom_alarm(int signo)

33 {

34  siglongjmp(jmpbuf, 1);

35 }

Размещение буфера перехода в памяти

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

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

20-23
 Когда мы вызываем функцию
sigsetjmp
непосредственно из нашей функции
dg_cli
, она устанавливает буфер перехода и возвращает нуль. Мы продолжаем работать дальше и вызываем функцию
recvfrom
.

Обработка сигнала SIGALRM и вызов функции siglongjmp

31-35
 Когда сигнал доставлен, мы вызываем функцию
siglongjmp
. Это заставляет
sigsetjmp
в функции
dg_cli
возвратить значение, равное второму аргументу (1), который должен быть ненулевым. Это приведет к завершению цикла
for
в функции
dg_cli
.

Использование функций

sigsetjmp
и
siglongjmp
подобным образом гарантирует, что мы не останемся навсегда блокированы в вызове функции
recvfrom
из-за доставки сигнала в неподходящее время. Однако такое решение создает иную потенциальную проблему. Если сигнал доставляется в тот момент, когда функция
printf
осуществляет вывод данных, управление будет передано из
printf
обратно на
sigsetjmp
. При этом в структурах данных
printf
могут возникнуть противоречия. Чтобы предотвратить эту проблему, следует объединить блокирование и разблокирование сигналов, показанное в листинге 20.2, с помощью нелокального оператора
goto
.

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

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

recvfrom
, наш обработчик сигнала при помощи средств IPC (Interprocess Communications — взаимодействие процессов) может сообщить функции
dg_cli
о том, что время таймера истекло. Это аналогично предложению, сделанному нами раньше, когда обработчик сигнала устанавливал глобальную переменную
had_alarm
по истечении времени таймера. Глобальная переменная использовалась как некая разновидность IPC (поскольку она была доступна и нашей функции, и обработчику сигнала). Однако при таком решении наша функция должна была проверять эту переменную, что могло привести к проблемам синхронизации в том случае, когда сигнал доставлялся приблизительно в это же время.

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

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

Листинг 20.6. Использование канала в качестве IPC между обработчиком сигнала и нашей функцией

//bcast/dgclibcast6.c

 1 #include "unp.h"


 2 static void recvfrom_alarm(int);

 3 static int pipefd[2];

 4 void

 5 dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen)

 6 {

 7  int n, maxfdp1;

 8  const int on = 1;

 9  char sendline[MAXLINE], recvline[MAXLINE + 1];

10  fd_set rset;

11  socklen_t len;

12  struct sockaddr *preply_addr;


13  preply_addr = Malloc(servlen);


14  Setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, &on, sizeof(on));


15  Pipe(pipefd);

16  maxfdp1 = max(sockfd, pipefd[0]) + 1;


17  FD_ZERO(&rset);


18  Signal(SIGALRM, recvfrom_alarm);


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

20   Sendto(sockfd, sendline, strlen(sendline), 0, pservaddr, servlen);


21   alarm(5);

22   for (;;) {

23    FD_SET(sockfd, &rset);

24    FD_SET(pipefd[0], &rset);

25    if ((n = select(maxfdp1, &rset, NULL, NULL, NULL)) < 0) {

26     if (errno == EINTR)

27      continue;

28     else

29      err_sys("select error");

30    }


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

32     len = servlen;

33     n = Recvfrom(sockfd, recvline, MAXLINE, 0, preply_addr,

34      &len);

35     recvline[n] = 0; /* null terminate */

36     printf("from %s: %s",

37     Sock_ntop_host(preply_addr, len), recvline);

38    }


39    if (FD_ISSET(pipefd[0], &rset)) {

40     Read(pipefd[0], &n, 1); /* истекшее время */

41     break;

42    }

43   }

44  }

45  free(preply_addr);

46 }


47 static void

48 recvfrom_alarm(int signo)

49 {

50  Write(pipefd[1], "", 1); /* в канал пишется один нулевой байт */

51  return;

52 }

Создание канала

15
 Мы создаем обычный канал Unix. Возвращаются два дескриптора:
pipefd[0]
доступен для чтения, а
pipefd[0]
 — для записи.

ПРИМЕЧАНИЕ

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

Функция select на сокете и считывающем конце канала

23-30
 Мы вызываем функцию
select
и на сокете, и на считывающем конце канала.

47-52
 Когда доставляется сигнал
SIGALRM
, наш обработчик сигналов записывает в канал 1 байт, в результате чего считывающий конец канала становится готовым для чтения. Наш обработчик сигнала также возвращает управление, возможно, прерывая функцию
select
. Следовательно, если функция
select
возвращает ошибку
EINTR
, мы игнорируем эту ошибку, зная, что считывающий конец канала также готов для чтения, что завершит цикл
for
.

Чтение из канала

38-41
 Когда считывающий конец канала готов для чтения, мы с помощью функции read считываем нулевой байт, записанный обработчиком сигнала, и игнорируем его. Но прибытие этого нулевого байта указывает нам на то, что истекло время таймера, и мы с помощью функции
break
выходим из бесконечного цикла
for
.

20.6. Резюме

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

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

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

■ использование функции

pselect
,

■ использование функций

sigsetjmp
и
siglongjmp
,

■ использование средств IPC (обычно канала) между обработчиком сигнала и главным циклом.

Упражнения

1. Запустите клиент UDP, используя функцию

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

2. Поместите несколько функций

printf
в листинг 20.6 после завершения функции
select
, чтобы увидеть, возвращает ли она ошибку или указание на готовность к чтению одного из двух дескрипторов. Возвращает ли ваша система ошибку
EINTR
или сообщение о готовности канала к чтению, когда истекает время таймера
alarm
?

3. Запустите такую программу, как

tcpdump
, если это возможно, и просмотрите широковещательные пакеты в вашей локальной сети (команда
tcpdump ether broadcast
). К каким наборам протоколов относятся эти широковещательные пакеты?

Глава 21