Pull to refresh

Особенности работы с POSIX-сигналами

Reading time10 min
Views14K

Как и любой другой инструмент, 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 or raise function, the behavior is undefined if ... the signal handler calls any function in the standard library other than the abort function, the _Exit function, the quick_exit function, or the signal 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) будет делать обработчики сигналов как можно более простыми и короткими: например, делать write() в pipe, а в другом потоке (или в основном event loop'е вашей программы) вы будете делать select() для этого pipe'а. Можно вообще ожидать и обрабатывать сигналы в специально выделенном для этого потоке (через sigwait(), заранее позаботившись о правильной маске). Или самый простой вариант: обработчик сигнала вообще сведется к установке переменной-флага, которая будет обрабатываться в основном цикле программы. Правда, с переменными-флагами тоже не все так просто, об этом в следущем пункте.

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 or raise 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 as volatile 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() следущие:

  1. Функция signal() во многих реализациях не блокирует поступление других сигналов во время выполнения текущего обработчика; sigaction() в зависимости от флагов может блокировать другие сигналы, пока не вернется текущий обработчик.

  2. Системный вызов signal() (без учета оберток типа libc) по умолчанию на многих платформах сбрасывает обработчик сигнала обратно на SIG_DFL почти для всех сигналов. К чему это может привести, описано выше.

  3. Итого, поведение 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.

На этом все. Безбажного вам кода!

Tags:
Hubs:
+64
Comments18

Articles

Change theme settings