Выбрать главу

Чтобы облегчить понимание, мы будем использовать эту терминологию на протяжении всей книги[52].

12.1.2. Простые сигналы

Изначально обработка сигналов была проста. Системный вызов signal() использовался для того, чтобы сообщить ядру, как доставить процессу определенный сигнал.

#include <signal.h>

void * signal(int signum, void *handler);

Здесь signum — это сигнал, который нужно обработать, a handler определяет действия, которое должно быть выполнено при доставке сигнала. Обычно handler — это указатель на функцию-обработчик сигнала, которая не принимает параметров и не возвращает значения. Когда сигнал доставлен процессу, ядро как можно скорее запускает функцию-обработчик. Когда функция возвращает управление, ядро возобновляет выполнение процесса с того места, где он был прерван. Системные инженеры распознают в этом механизме обработки сигналов аналог доставки аппаратных прерываний. Прерывания и сигналы очень похожи и у них возникают сходные проблемы.

Доступно множество номеров сигналов. В табл. 12.1 перечислены все сигналы, поддерживаемые в настоящее время Linux, за исключением сигналов реального времени. Они имеют символические имена, начинающиеся с SIG, и мы будем использовать SIGЧТО-ТО, говоря о каком-то из них.

Параметр handler может иметь два специальных значения — SIG_IGN и SIG_DFL (оба определены в <signal.h>). Если указано SIG_IGN, сигнал игнорируется, SIG_DFL сообщает ядру, что нужно выполнить действие по умолчанию, как правило, уничтожив процесс либо проигнорировав сигнал. Два сигнала — SIGKILL и SIGSTOP — не могут быть перехвачены. Ядро всегда выполняет действие по умолчанию для этих сигналов, соответственно, уничтожая процесс и приостанавливая его.

Функция signal() возвращает предыдущий обработчик сигнала (который мог быть SIG_IGN или SIG_DFL). Обработчики сигналов резервируются при создании новых процессов вызовом fork(), и все сигналы, которые установлены в SIG_IGN, игнорируются и после вызова exec()[53]. Все не игнорируемые сигналы после exec() устанавливаются в SIG_DFL.

Все это выглядит достаточно простым, пока вы не спросите себя: что произойдет, если сигнал SIGЧТО-ТО будет отправлен процессу, который уже исполняет обработчик сигнала для SIGЧТО-ТО.

Очевидно, что должно сделать ядро — так это прервать процесс и запустить обработчик сигнала сначала. Это порождает две проблемы. Первая — обработчик сигнала должен работать правильно, если он вызван тогда, когда уже сам работает. Хотя само по себе это и не сложно, но обработчики сигналов, которые манипулируют общепрограммными ресурсами, такими как глобальные структуры данных или файлы, должны быть написаны очень аккуратно. Функции, которые ведут себя правильно, когда вызваны подобным образом, называются реентерабельными функциями[54].

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

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

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

К сожалению, именно такая модель сигналов используется в ANSI/ISO-стандарте С[55]. Хотя программные интерфейсы надежных сигналов, в которых исправлен этот недостаток, уже широко распространены, стандартизация ненадежных сигналов в ANSI/ISO, видимо, останется навсегда.

12.1.3. Надежные сигналы

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

Однако если процессу отправлен сигнал SIGЧТО-ТО, в то время, как SIGЧТО-ТО уже находится в состоянии ожидания, то в этом случае процессу доставляется только первый из них. У процесса нет никакой возможности узнать, сколько раз один и тот же сигнал был отправлен ему, поскольку множество одинаковых сигналов подряд воспринимаются как один. Обычно это не представляет собой проблему. Поскольку сигнал не несет в себе никакой информации помимо собственно номера сигнала, двойная посылка сигнала за очень короткий отрезок времени может быть воспринята как одиночная, потому если программа примет сигнал только однажды, это не имеет особого значения. Это отличается от варианта с обработкой второго сигнала по умолчанию (что делается при ненадежной схеме обработки сигналов)[56].

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

Хотя BSD представляет адаптированную версию модели сигналов POSIX, комитет по стандартизации POSIX упростил ее для системных вызовов, с тем чтобы модифицировать диспозицию групп сигналов, предлагая новые системные вызовы для оперирования наборами сигналов. Наборы сигналов представлены типом данных sigset_t, и для манипулирования ими предусмотрен набор макросов[57].

12.1.4. Сигналы и системные вызовы

вернуться

52

Эта терминология распространена в большей части литературы по стандартам, включая единую спецификацию Unix (Single Unix Specification).

вернуться

53

Это механизм, используемый для утилиты nohup.

вернуться

54

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

вернуться

55

Вообще-то, не совсем так. Модель обработки сигналов ANSI/ISO С специфицирована немного иначе, чем мы ее представили. Однако она допускает сброс обработчика в значение SIG_DFL перед доставкой сигнала, что делает функции signal() в ASNI/ISO С ненадежными.

вернуться

56

Спецификация POSIX Real Time Signal позволяет некоторые сигналам ставить в очередь, и для сигналов, работающих с ограниченными объемами данных, существенно изменяет эту модель. Сигналы реального времени обсуждаются ближе к концу этой главы.

вернуться

57

Это аналогично типу fd_set, который используется системным вызовом select(), описанным в главе 13.