Как и любой другой инструмент, POSIX-сигналы имеют свои правила, как их использовать грамотно, надежно и безопасно. Они испокон веков описаны в самом стандарте POSIX, в стандартах языков программирования, в manpages, однако и по сей день я нередко встречаю связанные с этим грубые ошибки даже в коде опытных разработчиков, что в коммерческих проектах, что в открытых. Поэтому давайте поговорим о важном еще раз (кстати, для новичков в мире разработки ПО: коммитить в открытые проекты исправления явных косяков в обработчиках POSIX-сигналов — прекрасный способ набить руку в опенсорсе и пополнить себе портфолио, благо, проектов с подобными ошибками немало).
1. Набор доступных вызовов из обработчика сигнала строго ограничен
Начнем с самого важного. Что происходит, когда процесс получает сигнал? Обработчик сигнала может быть вызван в любом из потоков процесса, для которого этот конктретный сигнал (например, SIGINT) не отмечен как заблокированный (blocked). Если таких потоков несколько, то ядро выбирает один из них — чаще всего, это будет основной поток программы, но это не гарантировано, и не стоит на это рассчитывать. Ядро создает на специальный фрейм на стеке, который, во-первых, нужен для непосредственно работы функции-обработчика сигнала, а во-вторых, в него сохраняются данные, необходимые для продолжения работы, такие как значения регистра счетчика команд (program counter register, адрес, с которого будет продолжено выполнение кода), специфичные для архитектуры регистры, которые необходимы для продолжения выполнения выполнявшегося кода, текущую маску сигналов потока, и т.д. После этого непосредственно в этом потоке вызывается функция-обработчик сигнала.
О чем это говорит? О том, что выполнение любого потока (который не заблокирован для обработки нашего сигнала) может быть прервано в любой момент. Абсолютно любой. Даже посреди выполнения любой функции, любого системного вызова. А теперь представим, если этот вызов у нас имеет какое-то статическое, глобальное или thread-local внутреннее состояние, например, буфер, какие-то флаги, мьютекс, или что-либо еще, то вызов функции еще раз, когда она еще не закончила работу, может привести к совершенно непредсказуемым результатам. В компьютерных науках про такую функцию говорят, что она non-reentrant (нереентерабельна).
Пусть мы используем какую-нибудь функцию из stdio.h, например, всем известную printf(). Она использует внутри статически выделенный буфер данных вместе со счетчиками и индексами, которые хранят объем данных и текущую позицию в буфере. Обновляется все это не атомарно, и если вдруг в момент выполнения printf() в каком-нибудь потоке мы поймаем сигнал и запустим его обработчик, который тоже вызовет printf(), то эта функция будет работать с некорректным внутренним состоянием, что в лучшем случае приведет просто к неправильному результату, а в худшем случае уронит всю программу в segmentation fault.
Другой пример: на большинстве платформ malloc() и free() не реентерабельны, потому что они используют внутри статическую структуру данных, в которой хранится, какие блоки памяти свободны. Проблема усугубляется тем, что malloc()/free() могут неявно использоваться в глубине других библиотечных функций, и об этом вы можете даже не подозревать.
Поэтому существует такое понятие, как async-signal-safety. А именно, стандарт POSIX явно предприсывает в обработчиках сигналов функции из строго ограниченного набора и ничего больше.
Список разрешенных функций
Function Notes
abort() Added in POSIX.1-001 TC1
accept()
access()
aio_error()
aio_return()
aio_suspend()
alarm()
bind()
cfgetispeed()
cfgetospeed()
cfsetispeed()
cfsetospeed()
chdir()
chmod()
chown()
clock_gettime()
close()
connect()
creat()
dup()
dup()
execl() Added in POSIX.1-008;
execle()
execv() Added in POSIX.1-008
execve()
_exit()
_Exit()
faccessat() Added in POSIX.1-008
fchdir() Added in POSIX.1-008 TC1
fchmod()
fchmodat() Added in POSIX.1-008
fchown()
fchownat() Added in POSIX.1-008
fcntl()
fdatasync()
fexecve() Added in POSIX.1-008
ffs() Added in POSIX.1-008 TC
fork()
fstat()
fstatat() Added in POSIX.1-008
fsync()
ftruncate()
futimens() Added in POSIX.1-008
getegid()
geteuid()
getgid()
getgroups()
getpeername()
getpgrp()
getpid()
getppid()
getsockname()
getsockopt()
getuid()
htonl() Added in POSIX.1-008 TC
htons() Added in POSIX.1-008 TC
kill()
link()
linkat() Added in POSIX.1-008
listen()
longjmp() Added in POSIX.1-008 TC;
lseek()
lstat()
memccpy() Added in POSIX.1-008 TC
memchr() Added in POSIX.1-008 TC
memcmp() Added in POSIX.1-008 TC
memcpy() Added in POSIX.1-008 TC
memmove() Added in POSIX.1-008 TC
memset() Added in POSIX.1-008 TC
mkdir()
mkdirat() Added in POSIX.1-008
mkfifo()
mkfifoat() Added in POSIX.1-008
mknod() Added in POSIX.1-008
mknodat() Added in POSIX.1-008
ntohl() Added in POSIX.1-008 TC
ntohs() Added in POSIX.1-008 TC
open()
openat() Added in POSIX.1-008
pause()
pipe()
poll()
posix_trace_event()
pselect()
pthread_kill() Added in POSIX.1-008 TC1
pthread_self() Added in POSIX.1-008 TC1
pthread_sigmask() Added in POSIX.1-008 TC1
raise()
read()
readlink()
readlinkat() Added in POSIX.1-008
recv()
recvfrom()
recvmsg()
rename()
renameat() Added in POSIX.1-008
rmdir()
select()
sem_post()
send()
sendmsg()
sendto()
setgid()
setpgid()
setsid()
setsockopt()
setuid()
shutdown()
sigaction()
sigaddset()
sigdelset()
sigemptyset()
sigfillset()
sigismember()
siglongjmp() Added in POSIX.1-008 TC;
signal()
sigpause()
sigpending()
sigprocmask()
sigqueue()
sigset()
sigsuspend()
sleep()
sockatmark() Added in POSIX.1-001 TC
socket()
socketpair()
stat()
stpcpy() Added in POSIX.1-008 TC
stpncpy() Added in POSIX.1-008 TC
strcat() Added in POSIX.1-008 TC
strchr() Added in POSIX.1-008 TC
strcmp() Added in POSIX.1-008 TC
strcpy() Added in POSIX.1-008 TC
strcspn() Added in POSIX.1-008 TC
strlen() Added in POSIX.1-008 TC
strncat() Added in POSIX.1-008 TC
strncmp() Added in POSIX.1-008 TC
strncpy() Added in POSIX.1-008 TC
strnlen() Added in POSIX.1-008 TC
strpbrk() Added in POSIX.1-008 TC
strrchr() Added in POSIX.1-008 TC
strspn() Added in POSIX.1-008 TC
strstr() Added in POSIX.1-008 TC
strtok_r() Added in POSIX.1-008 TC
symlink()
symlinkat() Added in POSIX.1-008
tcdrain()
tcflow()
tcflush()
tcgetattr()
tcgetpgrp()
tcsendbreak()
tcsetattr()
tcsetpgrp()
time()
timer_getoverrun()
timer_gettime()
timer_settime()
times()
umask()
uname()
unlink()
unlinkat() Added in POSIX.1-008
utime()
utimensat() Added in POSIX.1-008
utimes() Added in POSIX.1-008
wait()
waitpid()
wcpcpy() Added in POSIX.1-008 TC
wcpncpy() Added in POSIX.1-008 TC
wcscat() Added in POSIX.1-008 TC
wcschr() Added in POSIX.1-008 TC
wcscmp() Added in POSIX.1-008 TC
wcscpy() Added in POSIX.1-008 TC
wcscspn() Added in POSIX.1-008 TC
wcslen() Added in POSIX.1-008 TC
wcsncat() Added in POSIX.1-008 TC
wcsncmp() Added in POSIX.1-008 TC
wcsncpy() Added in POSIX.1-008 TC
wcsnlen() Added in POSIX.1-008 TC
wcspbrk() Added in POSIX.1-008 TC
wcsrchr() Added in POSIX.1-008 TC
wcsspn() Added in POSIX.1-008 TC
wcsstr() Added in POSIX.1-008 TC
wcstok() Added in POSIX.1-008 TC
wmemchr() Added in POSIX.1-008 TC
wmemcmp() Added in POSIX.1-008 TC
wmemcpy() Added in POSIX.1-008 TC
wmemmove() Added in POSIX.1-008 TC
wmemset() Added in POSIX.1-008 TC
write()
Обратите внимание, что список функций отличается между разными версиями стандарта POSIX, причем изменения могут происходить в обе стороны. Например, fpathconf(), pathconf() и sysconf() в стандарте 2001 года считались безопасными, а в стандарте 2008 года уже перестали. fork() пока что относится к безопасным функциям, но есть планы удалить его из списка в следущих версиях стандарта по ряду причин.
А теперь самое главное. Внимательный глаз заметит, что в этом списке функций нет ни printf(), ни syslog(), ни malloc(). Вообще нет. Соответственно, использовать их и всё, что в теории может использовать их внутри себя, в обработчике сигналов нельзя. В std::cout и std::cerr в C++ писать тоже нельзя, эти операции тоже нереентерабельны.
Среди функций стандартной библиотеки языка C очень многие функции тоже нереентерабельны, например, почти все функции из <stdio.h>, многие функции из <string.h>, ряд функций из <stdlib.h> (некоторые, правда, напротив явно есть в списке разрешенных). Впрочем, стандарт языка C явно запрещает вызывать в обработчиках сигналов практически всё из стандартной библиотеки, кроме abort(), _Exit(), quick_exit() и самого signal():
ISO/IEC 9899:2011 §7.14.1.1 The signal function
5. If the signal occurs other than as the result of calling the
abort
orraise
function, the behavior is undefined if ... the signal handler calls any function in the standard library other than theabort
function, the_Exit
function, thequick_exit
function, or thesignal
function with the first argument equal to the signal number corresponding to the signal that caused the invocation of the handler.
Так что если вам уж очень сильно хочется что-то вывести в консольку из обработчика сигналов, можно сделать это старым дедовским методом:
#include <unistd.h>
...
write(1,"Hello World!", 12);
Но вообще, хороший практикой (кстати, явно рекомендуемой в документации libc) будет делать обработчики сигналов как можно более простыми и короткими: в идеале они должны вообще сводиться к установке переменной-флага, которая будет обрабатываться в основном цикле программы или в специально выделенном для этого потоке. Правда, с этим тоже не все так просто, об этом в следущем пункте.
2. Используйте только volatile sig_atomic_t или atomic-типы в качестве флагов
Смотрим тот же пункт из стандарта языка C:
ISO/IEC 9899:2011 §7.14.1.1 The signal function
5. If the signal occurs other than as the result of calling the
abort
orraise
function, the behavior is undefined if the signal handler refers to any object with static or thread storage duration that is not a lock-free atomic object other than by assigning a value to an object declared asvolatile sig_atomic_t
В современных стандартах C++ упомянуто примерно то же самое. Логика тут точно такая же, как в предыдущем пункте: поскольку обработчик сигнала может быть вызван в абсолютно любой момент, важно, чтобы не-локальные переменные, с которыми вы в нем имеете дело, во-первых обновлялись атомарно (в противном случае при прерывании в неудачный момент есть риск получить в них некорректное содержимое), а во-вторых, поскольку с точки зрения выполняемой функции они изменяются "чем-то другим", важно чтобы обращения к ним не оптимизировались компилятором (иначе компилятор может решить, что между итерациями цикла изменение значения переменной невозможно и вообще выкинет эту проверку, либо поместит переменную в регистр процессора для оптимизации). Поэтому в качестве статических/глобальных флагов, изменяемых из обработчика сигнала, можно использовать или atomic-типы (при условии, что на вашей платформе они точно lock-free), либо специально созданный для этого тип sig_atomic_t со спецификатором volatile.
И боже упаси вас блокировать в обработчике сигналов какой-нибудь мьютекс, также используемый в остальной части программы или в хендлерах других сигналов — это самый прямой путь к дедлоку. Поэтому о conditional variables в качестве флагов можно тоже забыть.
3. Сохраняйте errno
Тут все просто: Если в обработчике сигнала вы вызываете какую-либо функцию, которая теоретически может изменить глобальную переменную errno, сохраняйте текущее значение errno в начале обработчика сигнала куда-нибудь, и восстанавливайте обратно в конце. Иначе вы можете сломать какой-либо внешний код, проверяющий этот самый errno.
4. Помните, что поведение signal() может сильно отличаться в разных ОС и даже в разных версиях одной ОС
Начнем с того, что у signal() есть весомый плюс: он входит в стандарт языка Си, а вот sigaction() — это уже чисто POSIX-штука. С другой стороны, поведение signal() может довольно сильно отличаться в разных ОС, и более того, в интернете встречаются упоминания, что поведение signal() может отличаться даже при разных версиях ядра Linux.
Для начала, немного истории.
В оригинальных системах UNIX, когда вызывался обработчик сигнала, ранее установленный с помощью signal(), обработчик сбрасывался на SIG_DFL, и система не блокировала доставку последующих экземпляров сигнала (в наше время это эквивалентно вызову sigaction() с флагами SA_RESETHAND | SA_NODEFER). Иными словами, получили сигнал, обработали -> обработчик сбросился на стандартный, и поэтому закончив обработку полученного сигнала мы должны были не забыть вызвать signal() еще раз и снова установить вместо стандартного обработчика нашу функцию. В System V было то же самое. Это было плохо, потому что следущий сигнал мог быть послан и доставлен процессу еще раз до того, как обработчик успел восстановить себя. Более того, быстрая доставка одного и того же сигнала могла привести к рекурсивным вызовам обработчика.
В BSD улучшили эту ситуацию, там когда сигнал получен, обработчики сигнала не сбрасываются на стандартные. Но это было не единственное изменение в поведении: там еще обработка всех последующих экземпляров этого сигнала блокируется на время обработки первого из них. Кроме того, некоторые блокирующие системные вызовы (типа read() или wait()) автоматически перезапускаются, если их прерывает обработчик сигнала. Семантика BSD эквивалентна вызову sigaction() с флагом SA_RESTART.
В Linux же ситуация следующая:
-
Системный вызов ядра signal() обеспечивает семантику System V.
-
По умолчанию в glibc 2 и новее функция-оболочка signal() не вызывает системный вызов ядра. Вместо этого он вызывает sigaction(), используя флаги, обеспечивающие семантику BSD. Это поведение по умолчанию обеспечивается до тех пор, пока определен макрос _BSD_SOURCE в glibc 2.19 и ранее или _DEFAULT_SOURCE в glibc 2.19 и новее. Если такой макрос не определен, то signal() предоставляет семантику System V. По умолчанию он определен :)
Итак, основные различия между signal() и sigaction() следущие:
-
Функция signal() во многих реализациях не блокирует поступление других сигналов во время выполнения текущего обработчика; sigaction() в зависимости от флагов может блокировать другие сигналы, пока не вернется текущий обработчик.
-
Системный вызов signal() (без учета оберток типа libc) по умолчанию на многих платформах сбрасывает обработчик сигнала обратно на SIG_DFL почти для всех сигналов. К чему это может привести, описано выше.
-
Итого, поведение signal() варьируется в зависимости от платформы, системы и даже сборки libc — и стандарты допускают такие вариации. Короче говоря, при использовании signal() никто вам ничего не гарантирует. sigaction() гораздо более предсказуем.
Поэтому во избежании нежданчиков и проблем с переносимостью, рекомендация не использовать signal(), а предпочитать вместо него sigaction() в новом коде дана прямым текстом в The Open Group Base Specification.
5. Аккуратнее с fork() и execve()
Дочерний процесс, созданный с помощью fork(), наследует установленные обработчики сигналов своего родителя. Во время execve() обработчики сигналов сбрасывается на дефолтные, а вот настройки заблокированных (blocked) сигналов остаются неизменными для свежезапущенного процесса. Поэтому если вы, например, в родителе заигнорили SIGINT, SIGUSR1, или еще что-нибудь, а запущенный процесс рассчитывает на них, то это может привести к интересным эффектам.
6. Еще пара мелочей
Если для процессу отправлено несколько стандартных (не realtime) сигналов, порядок, в котором они доставятся вашему процессу, может быть любым.
Стандартные сигналы не ставятся в очередь. Если несколько экземпляров стандартного сигнала были посланы вашему процессу, пока этот сигнал заблокирован, то только один экземпляр сигнала будет помечен как ожидающий (и сигнал будет доставлен только один раз, когда он разблокирован).
7. Читайте документацию
Всё, что я написал выше, там есть. Да и вообще, там есть очень много интересного, полезного и неожиданного, особенно в секциях Portability, Bugs и Known issues.
Например, мне очень нравится описание функции getlogin()/cuserid():
Sometimes it does not work at all, because some program messed up the utmp file. Often, it gives only the first 8 characters of the login name.
и дальше еще прекрасное:
Nobody knows precisely what cuserid() does; avoid it in portable programs.
На этом все. Безбажного вам кода!
Автор: K. just K.