Мы используем куки, чтобы пользоваться сайтом было удобно.
Хорошо
to the top
>
>
>
О чем нельзя забывать при работе с POSI…

О чем нельзя забывать при работе с POSIX-сигналами

01 Июн 2022
Автор:

Как и любой другой инструмент, POSIX-сигналы имеют свои правила, как их использовать грамотно, надежно и безопасно. Они испокон веков описаны в самом стандарте POSIX, в стандартах языков программирования, в manpages, однако и по сей день я нередко встречаю связанные с этим грубые ошибки даже в коде опытных разработчиков, что в коммерческих проектах, что в открытых. Поэтому давайте поговорим о важном еще раз (кстати, для новичков в мире разработки ПО: коммитить в открытые проекты исправления явных косяков в обработчиках POSIX-сигналов — прекрасный способ набить руку в опенсорсе и пополнить себе портфолио, благо, проектов с подобными ошибками немало).

0950_POSIX_signals_ru/image1.png

Мы опубликовали и перевели эту статью с разрешения правообладателя. Автор статьи — Zheluddd (email — zheluddd22@gmail.com). Оригинал опубликован на сайте Habr.

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 явно предписывает в обработчиках сигналов функции из строго ограниченного набора и ничего больше.

Список разрешенных функций:

  • 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() - Added in POSIX.1-008 TC
  • 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() следующие:

  • Функция 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. Читайте документацию

0950_POSIX_signals_ru/image2.png

Всё, что я написал выше, там есть. Да и вообще, там есть очень много интересного, полезного и неожиданного, особенно в секциях 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.

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

Популярные статьи по теме


Комментарии (0)

Следующие комментарии next comments
close comment form
close form

Заполните форму в два простых шага ниже:

Ваши контактные данные:

Шаг 1
Поздравляем! У вас есть промокод!

Тип желаемой лицензии:

Шаг 2
Team license
Enterprise license
** Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности
close form
Запросите информацию о ценах
Новая лицензия
Продление лицензии
--Выберите валюту--
USD
EUR
RUB
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
Бесплатная лицензия PVS‑Studio для специалистов Microsoft MVP
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
Для получения лицензии для вашего открытого
проекта заполните, пожалуйста, эту форму
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
Мне интересно попробовать плагин на:
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
check circle
Ваше сообщение отправлено.

Мы ответим вам на


Если вы так и не получили ответ, пожалуйста, проверьте, отфильтровано ли письмо в одну из следующих стандартных папок:

  • Промоакции
  • Оповещения
  • Спам