В очередной заметке нашего гуру Игоря Облакова, ведущего постоянную рубрику, посвященную деталям функционирования ОС Unix и Unix-подобных систем, речь пойдет о механизме сигналов.
И тут бац - телефон. Абсолютно не сообразуясь с вашими желаниями и т.п. - асинхронно с какими-либо действиями (вы ведь не поднимали постоянно трубку - не звонят ли?). Именно в таком стиле и действует механизм сигналов в операционной системе Unix.

В Unix имеется достаточно много способов для синхронизации процессов и обмена информацией между ними. В данном случае нас интересует просто обмен сигналами - асинхронный, заметим, обмен сигналами. Типа нарисовал черточку на столбе и смотался. Итак поговорим о такой нестандартной стандартной службе, как передача сигналов.

Почему стандартной? Команду kill вы можете встретить в любом варианте Unix (помните то воодушевление, с которым юниксоиды слушали «набери команду: убить минус девять» в сериале «Никита»). Нестандартной - потому что набор этих сигналов и организация их обработки может быть различной в разных версиях. Как организована работа с сигналами в Linux и других версиях Unix?

Здесь есть о чем поговорить, причем тема для беседы достаточно многогранна. Как и зачем используются сигналы в Unix, какие бывают сигналы, как они порождаются, как распространяются и кому достаются, что происходит с процессом который получил сигнал, что процесс может сделать с сигналом? Вот этими вопросами мы и займемся. Как всегда, мы будем опускать /usr/src/linux в названиях файлов при ссылке на источники (примеры взяты из версии RedHat 6.0 Linux Cyrillic Edition).

Можно поговорить о том, что сигналы в Unix имеют достаточно разнообразную природу. Что, например, общего между приостановкой процесса по требованию пользователя и выполнением некорректной операции в программе. Объединяет то обстоятельство, что и то, и другое - исключительная ситуация (к сожалению, слово «исключительная» носит какой-то оттенок типа «почти никогда не происходящий» или «разрушительный» [1].

В принципе выполнение некорректной операции происходит синхронно с потоком команд, однако если мы рассмотрим тривиальный вопрос о том, может ли программа обработки сигнала выполнения некорректной операции в программе предполагать, что эта операция «имела место», то окажемся в некотором затруднении. В принципе, да, данная программа должна вызываться в ответ на выполнение в программе некорректной операции (кто-то пытался, например, испортить стек передачей большой строки в ответ на логичный вызов gets с буфером в стеке и возврат из процедуры привел к возврату в никуда). С другой стороны, обработчик сигнала должен вызываться просто в ответ на приход данного сигнала. Кто же это вам обещал, что данный сигнал может быть порожден только вашей программой, а не командой kill c некоторого терминала? Никто не мешает, например, в целях отладки, когда непосредственно отлаживаться нет сил и хотелось бы сохранить core подвисшего процесса «на память», послать данному процессу сигнал ошибки операции, адресации и т.п., а потом в тиши проанализировать память процесса.

Как вы видите, как всегда не очень понятно, что делать с «исключительными» ситуациями. К сожалению, даже Страуструп [1] не пожелал в своей книге разговаривать на тему обработки асинхронных исключительных ситуаций. Ясно, что здесь имеются некоторые потенциальные сложности. Например, что будет, если в программе обработки используется явное или неявное выделение буфера для выдачи данных диагностики на экран? Теперь сделаем предположение, что сигнал пришел в момент выделения памяти под какую-либо другую структуру в программе. Что будет? Как вы понимаете, проблема заключается в том, что два «параллельных» процесса лезут к одним и тем же данным - структурам выделения памяти. Предсказать результат в общем случае затруднительно - все зависит от организации алгоритмов и т.п. Ясно, что аналогичная проблема может возникнуть и с другими структурами. Здесь прослеживается полная аналогия с проблемами написания драйверов и обработкой прерываний. Обработчик прерываний не должен позволять себе слишком много, а система должна блокировать все прерывания на момент выполнения критичного кода.

У проблемы есть и «третья» сторона. А что будет, если установить режим игнорирования на самом деле разрушительных сигналов (код команды, ошибка адресации и т.п.)? Ясно, что если после порождения сигнала вернуть управление программе назад, могут возникнуть, скажем мягко «некоторые сложности», так как формальный адрес возврата - и есть сама ошибочная инструкция.

Как используются сигналы в Unix? Ясно, что не только для того, чтобы иметь возможность вызвать функцию kill и убить процесс. Одна из целей использования состоит именно в оповещении процесса о наступлении некоторого события. Например, оповещение демонов о том, что произошли какие-либо изменения в конфигурационных файлах. К этой серии, например, относятся процессы inetd (с сигналом SIGHUP) или X-сервер/шрифт-сервер (сигнал SIGUSR1). Ясно, что заставлять

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

Как, в принципе, процесс может обрабатывать сигналы? Обычно имеются следующие возможности: обработка сигнала «по умолчанию», игнорирование сигнала, вызов конкретной процедуры для обработки сигнала, откладывание момента обработки сигнала. Режим умолчания для сигналов может быть: прекращение работы, прекращение работы с порождением файла core, приостановка выполнения процесса, игнорирование сигнала. При этом для двух сигналов, то есть SIGKILL и SIGSTOP (если он есть) невозможно установить какого-либо способа обработки, они всегда действуют «по умолчанию» соответственно прекращая или приостанавливая работу процесса.

Иногда указание способа обработки имеет и побочный эффект. Например, как заметил один из читателей (A. Ragin), я, утверждая, что в Unix действует принцип никто не забыт, ничто не забыто, слегка лукавил. На самом деле в некоторых из реализаций ОС Unix (с элементами System V, но не в Linux) имеется возможность «отказаться» от своих сыновей. Для этого достаточно установить игнорирование сигнала завершения выполнения сыновей - SIGCLD (signal(SIGCLD, SIG_IGN)). В этом случае завершение выполнения сыновей данного процесса не приводит к образованию зомби в любом случае.

Теперь посмотрим, как система обрабатывает сигналы. Заметим, что сигнал, который посылается конкретному процессу, сначала достается системе и, соответственно, системному режиму работы процесса. А что будет происходить в том случае, если процесс уже выполнял, например, какой-либо системный вызов и просто находится в ожидании какого-либо события (блока, символа, семафора и т.п.). Частично это уже описано в предыдущих номерах журнала. В первую очередь будет сделана попытка гаркнуть «Подъем!» процессу в ухо. Если он не согласен проснуться, то просто перевернется на другой бок (см. предыдущие выпуски рубрики). Обычно же процесс просыпается. Самое интересное начинается дальше... И вот здесь-то наблюдается существенное отличие между различными версиями Unix.

Сначала посмотрим Linux. Каждая подпрограмма ядра должна понимать, что в принципе в момент ее выполнения может появиться какой-либо сигнал. Он может появиться либо благодаря обработке прерываний, либо в момент выполнения подпрограммой вызова того или иного типа sleep. Как уже упоминалось (журнал «Открытые системы» №4 за 1999 год) в ядре Linux имеется специальный вызов interruptable_sleep_on для того, чтобы сообщать системе, что данное ожидание может быть прервано сигналом. После вызова, соответственно, надо определить причину окончания - мы дождались события или сигнала. В последнем случае данное действие прерывается и из процедуры возвращается код ошибки (что-то типа - EINTR). Такой вариант обработки можно, например, видеть в файле: drivers/block/floppy.c («сейчас, сыночек, я закончу форматировать флоп и покажу, что такое многозадачная система»). Такое решение по обработке сигналов требует, чтобы каждый вызов ожидания обрамлялся проверками наличия сигналов. Возможно и другое решение. В некоторых версиях ОС Unix выполнение любого системного вызова начинается с setjmp для запоминания контекста вызова, а прерываемый вариант sleep просто выполняет longjmp (фактически, обычно предусматривается три типа sleep - непрерываемый, прерываемый, прерываемый с проверкой - в стиле Linux). В этом случае обрамление sleep не нужно, но требуется особая аккуратность при работе с ресурсами, еще не известно, что лучше.

Итак, со sleep мы разобрались. А если при выполнении системного вызова нет interruptable_sleep_on, но сигнал появился, кто будет разбираться? Еще более общий вопрос - как вообще далее обрабатываются сигналы? Для ответа на этот вопрос полезно заглянуть в файл arch/i386/kernel/entry.S. В этом файле можно обнаружить код начала и завершения системного вызова. Нас интересует именно завершение - ret_from_syscall. В этом коде можно найти проверку наличия сигналов и вызов процедуры do_signal (kernel/signal.c) — если они есть. Далее последуют вызововы handle_signal и setup_frame (там же). Именно во frame_setup и выбирается требуемый обработчик, заказанный программой. Обратите внимание, что тут же гарантируется выполнение системного вызова sigreturn (см. описание вызова) после окончания обработки сигнала процессом. Для того, чтобы гарантировать корректное выполнение обработчика выполняется ряд подготовительных операций, в частности, блокирование ряда сигналов (например, текущего) на момент работы обработчика или даже отмена обработчика (для signal из System V). Для того чтобы откатить все назад (разблокировать сигналы, вернуть позицию стека, а, возможно, и обработать другие сигналы) как раз и нужен вызов sigreturn. В других версиях Unix выполняются аналогичные действия. При этом иногда встречаются аналоги sigreturn, которые надо вызывать явно, например, в случае использования longjmp при выходе из обработчика сигнала. Еще одно действие, которое может быть выполнено процедурами подобного типа, состоит в перезапуске прерванного системного вызова (также системно-зависимый момент).

Вернемся на уровень процесса. Что позволяет сделать команда (и системный вызов) kill? Разбор аргументов можно посмотреть в файле kerrnel/signal.c в процедуре kill_something_info вызываемой из sys_kill. Возможны следующие аргументы: первый аргумент — сигнал, второй же аргумент в закодированной форме указывает список процессов, которым данный сигнал требуется послать. Подчеркнем, именно список: речь может идти не об одном процессе, а о некотором множестве процессов. Более точно: если указывается значение больше нуля, то это, конечно, именно PID процесса; если это -1, то всем процессам начиная с PID 2 и далее; если 0 или меньше нуля, то соответственно текущей или указанной (в форме -номер) группе процессов. В трех последних случаях сигнал не достается процессу, который породил его. Принадлежность к группе процессов определяется обычно по подключению к конкретному терминалу/псевдотерминалу.

Можно ли определить для конкретного процесса принадлежность к этой самой группе? Обычно ps эту информацию не выводит. Однако можно, посетовав на свою судьбу, попросить получше (задействована последняя версия ps в Linux):

ps -e -o pid,ppid,pgrp,psid,pargs

Что мы увидим? В колонке группы и сеанса в начале списка будут те же значения, что и в колонке pid. Далее в колонке группы будет встречаться значение отличное от pid - pid лидера группы (в начале списка все лидеры), и, соответственно, в колонке сессии - лидера сеанса. Вот именно по этим номерам и происходит выбор при «раздаче» сигналов.

Рассмотрим еще один интересный момент, связанный с передачей сигналов. В одном из предыдущих номеров обсуждался способ «борьбы» с хаотическим запуском короткоживущих команд пользователем путем запуска большого числа процессов от имени данного пользователя. Ясно, что на некотором этапе этой деятельности новые процессы не будут запускаться и цикл может быть разорван. Это метод «спокойного вытеснения», который, конечно, требует времени. Проблема в том, что у нас нет команды типа «kill user». Но никто не мешает «создать» такую команду. Что будет, если мы воспользуемся (как суперпользователь root) сначала командой su, user, а затем kill -9 -1?

После того, как мы «отказались» от трона администратора, наш вызов kill не страшен большинству процессов (а формально он должен быть отправлен всем, см. выше) — у нас просто нет необходимых прав. Жертвами данного сигнала окажутся только процессы данного пользователя. А что если у пользователя уже израсходован лимит процессов? Можем ли мы в этом случае выполнить su user? Ответ положительный. Команда su просто подменит идентификатор пользователя у уже существующего процесса, а это не запрещено. В результате в системе может появиться, к примеру, 257-258 (в Linux «с раздачи» действует ограничение 256) процессов одного пользователя. Другое дело, если вы попытаетесь после su user выдать ps (или воспользоваться su - user). Он уже будет выполняться от имени данного пользователя, а у него лимит исчерпан и ... - ps не пойдет.

Наконец, как выглядит работа с сигналами для многонитевых процессов? Заметим, что в команде и системном вызове kill нельзя указать конкретную нить процесса. Так кому же (какой нити - thread) достанется сигнал, всем или какой-нибудь одной? Ясно, что это достаточно существенный вопрос. В переводных изданиях ответ на этот вопрос вы можете найти, например, в книге Т. Чана «Системное программирование на C++ для Unix». Оказывается, что сигнал, который достается процессу, будет обрабатывать только одна нить - одна из тех, которые не блокировали обработку данного сигнала. Если таких нитей несколько, то система выбирает какую-то из них, руководствуясь «высшими соображениями». Среди этих соображений может быть, например, простой нити на вызове wait. Для определенности вы можете блокировать сигнал для всех нитей кроме одной. Кроме того, имеется возможность обмена сигналами между нитями в рамках одного процесса.

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

kill -l. Он начинается с SIGHUP - сигнал номер 1. А что, раньше ничего не было? Оказывается, есть еще одно допустимое значение - 0. Этот сигнал не приводит к каким-либо последствиям на стороне «освистанного» процесса. Фактически это способ проверить существование данного процесса (тут я, конечно, опять привираю, и речь не только о зомби, которые будут признаны живыми). Правда, тут тоже не надо забывать, что команды:

kill 0 ....

и

kill -0 ....

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

Какие есть еще интересные сигналы? О некоторых из них мы поговорим в следующий раз.

Присылайте свои вопросы и пожелания по адресу: oblakov@bigfoot.com

Литература

[1] Брайан Страуструп, «Язык программирования C++», Издание третье, раздел 14.1.1

Поделитесь материалом с коллегами и друзьями