9.6 Обработка сигналов
Сигналы операционной системы асинхронны и низкоуровневы: сигнал может прервать любой поток в произвольный момент, а набор действий, допустимых в обработчике сигнала, крайне ограничен. Типичный же запрос разработчика на Go — подключить канал к SIGINT через signal.Notify и выполнить корректное завершение по его приходу. Задача рантайма — выстроить мост между этими двумя реальностями: преобразовать непредсказуемый асинхронный сигнал в событие, которое горутина сможет безопасно обработать. Каждое проектное решение на этом мосту диктуется одним жёстким ограничением: что допустимо делать внутри контекста обработчика сигнала. Понимая это ограничение, всю остальную механику данного раздела можно воспринимать как её следствия.
9.6.1 Безопасность в контексте асинхронного сигнала: обработчику разрешено почти ничего
Обработчик сигнала выполняется в прерванном, неопределённом контексте. В этот момент прерванный поток может удерживать мьютекс malloc, находиться в середине незавершённого обновления структуры данных или перезаписывать метаданные аллокатора. Если обработчик снова вызовет malloc или попытается захватить тот же мьютекс, произойдёт взаимная блокировка либо чтение незавершённого состояния с последующим сбоем. Поэтому POSIX предписывает, что обработчик сигнала вправе вызывать только async-signal-safe-функции — весьма короткий список (см. signal-safety(7)): системные вызовы write, _exit, sigaction, которые не затрагивают ни мьютексы, ни аллокатор, в него входят, тогда как malloc, printf, большинство блокировок pthread и практически весь стандарт C — нет.
Это ограничение порождает железное правило, которого придерживается каждый рантайм с поддержкой сигналов:
Выполнять в обработчике лишь то, что абсолютно необходимо, откладывая реальную обработку в безопасное место.
Именно так возникла классическая техника — self-pipe trick (описанная У. Р. Стивенсом в APUE): обработчик только записывает один байт в заранее созданный канал; основной цикл событий (select/poll) следит за другим концом канала, и «пришёл сигнал» сводится к обычному событию «канал готов к чтению», после чего обработка продолжается в неограниченном контексте. Позднее Linux встроил эту технику в ядро через signalfd(2), позволив потреблять сигналы непосредственно через epoll в виде файлового дескриптора.
Go применяет вариант той же идеи, заменяя «канал» на lock-free очередь внутри рантайма (9.6.4). Обоснование вполне в духе Go: каждая доставка через self-pipe требует системного вызова write, тогда как обработка сигналов в Go тесно связана со шедулером и сборщиком мусора, поэтому использование атомарного конечного автомата в рамках процесса для «записи бита и пробуждения ожидающего» дешевле многократного входа и выхода из ядра и обеспечивает более точный контроль. Следующие подразделы прослеживают, как это железное правило реализуется в обработчике, альтернативном стеке, очереди и выделенной горутине.
9.6.2 gsignal: безопасный стек, сопровождающий обработчик
Первое следствие железного правила — обеспечение обработчика выделенным стеком. Сигнал может прийти именно в тот момент, когда стек пользовательской горутины почти исчерпан; если обработчик будет выполняться на этом же стеке, велик риск переполнения. Хуже того, стеки Go являются растущими (14.6), а само увеличение стека требует аллокации и захвата мьютекса — именно тех операций, которые в обработчике запрещены. Решением служит sigaltstack(2) из POSIX: для потока заранее регистрируется альтернативный стек сигнала, и ядро автоматически переключается на него при доставке сигнала в обработчик.
Go выделяет каждому M специальную горутину gsignal, стек которой является альтернативным стеком сигнала данного M. gsignal создаётся на этапе mcommoninit через mpreinit и, помимо g0 (9.3), является первой горутиной каждого M. Она не участвует в планировании, не имеет goid в смысле пользовательского кода и существует исключительно для «переноса обработки сигналов»:
|
|
После того как M входит в mstart, функция minit вызывает minitSignalStack, которая через sigaltstack регистрирует gsignal.stack в качестве альтернативного стека сигнала для данного потока. Здесь предусмотрена тонкая деталь для cgo: если поток уже имел альтернативный стек, установленный не-Go кодом C (случай обратного вызова в Go из не-Go потока), рантайм не перезаписывает его, а принимает существующий стек в качестве стека gsignal и восстанавливает его в unminit. Управление каждым M собственной копией «на каком стеке обрабатываются сигналы» — это именно то предусловие, которое гарантирует, что обработка сигналов и планирование горутин не мешают друг другу.
9.6.3 Обработчик только ставит в очередь; горутина диспетчеризует
После подготовки стека следующим шагом является установка обработчика и разветвление потока управления при приходе сигнала. На фазе initsig Go устанавливает единую точку входа для каждого интересующего его сигнала. Обратите внимание: ядро вызывает не sighandler напрямую, а ассемблерный трамплин sigtramp. При приходе сигнала ядро переходит в sigtramp с использованием соглашения о вызовах C; тот сохраняет контекст, переключается в мир рантайма Go, затем вызывает sigtrampgo, который переключает текущую g на gsignal данного M и наконец входит в настоящий sighandler. Этот слой трамплина необходим, поскольку ядро ничего не знает об абстракции g/m/p языка Go, и кто-то должен сначала «перевести» среду выполнения.
|
|
sigfwdgo — ключевой механизм сосуществования с нативными библиотеками: не каждый сигнал должен обрабатываться Go. Если для какого-либо сигнала пользовательская C-библиотека ранее зарегистрировала свой обработчик, Go переадресует управление туда, а не присваивает сигнал себе. К этому вопросу мы вернёмся в 9.6.5.
Фактическое разветвление происходит внутри sighandler. Определив тип сигнала, он направляет его по трём путям:
flowchart TD
OS[Операционная система доставляет сигнал] --> TR["sigtramp (ассемблерный трамплин)<br/>трансляция соглашения о вызовах, переключение на стек gsignal"]
TR --> SH["sighandler<br/>разветвление на альтернативном стеке gsignal"]
SH -->|"синхронный сигнал<br/>SIGSEGV / SIGFPE / SIGBUS"| PANIC["preparePanic: формирование вызова sigpanic<br/>преобразование в восстанавливаемую панику"]
SH -->|SIGURG| PREE["doSigPreempt: инициация асинхронного вытеснения (см. 9.7)"]
SH -->|SIGPROF| PROF["sigprof: взятие образца PC для pprof"]
SH -->|"требует пользовательской обработки<br/>SIGINT / SIGTERM ..."| Q["sigsend: запись в lock-free очередь и немедленный возврат"]
Q --> SG["выделенная горутина, заблокированная в signal_recv, извлекает сигнал"]
SG --> CH["доставка в канал, зарегистрированный через signal.Notify"]
CH --> USER["пользователь обрабатывает его с помощью select"]
Первый путь — синхронные сигналы. SIGSEGV (разыменование нулевого указателя), SIGFPE (деление на ноль) и SIGBUS генерируются непосредственно в результате незаконной операции текущего потока, находясь в прямой причинно-следственной связи с прерванным кодом. Рантайм не позволяет процессу молча завершиться аварийно, а преобразует их в паники Go: preparePanic переписывает стек и PC в точке прерывания так, чтобы выглядело, будто «точка, вызвавшая ошибку, вызвала sigpanic»; когда обработчик возвращается и управление переходит обратно к пользовательской g, из sigpanic выбрасывается ошибка рантайма, которую можно перехватить через recover. Решение зависит от флага _SigPanic в sigtable и принимается лишь тогда, когда сигнал действительно пришёл от ядра (а не от пользовательского kill) и прерванным объектом является именно пользовательская g; в противном случае рантайм принудительно завершает процесс с выводом трассировки стека:
|
|
Второй путь — сигналы для внутреннего использования рантайма, обрабатываемые на месте. SIGPROF является источником таймерного сэмплирования в pprof (16 Инструментирование и наблюдаемость): обработчик фиксирует PC в точке прерывания, передаёт его в sigprof для записи и возвращается. SIGURG — носитель асинхронного вытеснения (9.7): обработчик вызывает doSigPreempt для «внедрения запроса вытеснения» в прерванную g и возвращается. Следует отметить, что даже при приходе сигнала вытеснения обработчик не останавливается, а продолжает выполнение остальных ветвей разветвления, поскольку один SIGURG может прийти одновременно с другим сигналом.
Третий путь — сигналы, предназначенные для пользователя, те, что проходят по этому мосту. SIGINT, SIGTERM, SIGHUP и подобные им, несущие флаг _SigNotify или действительно отправленные пользовательским kill, обрабатываются обработчиком исключительно посредством вызова sigsend, который помещает их в lock-free очередь, после чего обработчик немедленно возвращается, не касаясь внутри себя ни каналов, ни мьютексов, ни аллокации. Фактическая доставка возлагается на выделенную горутину на другом конце очереди.
Выбор между этими тремя путями целиком закодирован в битах флагов таблицы sigtable, по одной строке на сигнал:
|
|
9.6.4 sigsend: lock-free очередь и выделенная горутина-получатель
Два конца моста — sigsend (производитель, работающий внутри обработчика) и signal_recv (потребитель, работающий в выделенной горутине), а между ними — глобальная для процесса структура sig. Поскольку сторона производителя находится в рамках ограничений async-signal safety, эта очередь должна быть lock-free и не выполнять аллокацию: вся таблица представляет собой битовую маску фиксированного размера, а конечный автомат управляется атомарными операциями CAS:
|
|
Действия sigsend минимальны: он устанавливает соответствующий бит в sig.mask через CAS, затем с помощью конечного автомата из трёх состояний решает, будить ли получателя. Эти три состояния state являются основой всей синхронизации; они позволяют операциям «записать бит» и «разбудить спящего» корректно взаимодействовать без захвата мьютекса:
|
|
На другом конце очереди signal_recv выполняется в обычной горутине, свободной от ограничений async-signal safety, которой можно спать и быть разбуженной. Сначала она возвращает сигналы из своей локальной копии recv по одному; когда локальная копия опустевает, она переводит состояние в sigReceiving и засыпает на sig.note, ожидая, пока следующий sigsend её не разбудит. Эта схема полностью симметрична:
|
|
Последний этап происходит в пользовательском пространстве и принадлежит пакету os/signal. При первом вызове Notify он лениво запускает горутину с циклом loop, который раз за разом вызывает signal_recv для получения сигналов, а затем process для их диспетчеризации в каналы, зарегистрированные пользователем:
|
|
На этом этапе сигнал, рождённый в асинхронном, ограниченном контексте, превратился в обычный приём из канала. Пользователю достаточно вызвать signal.Notify(ch, os.Interrupt) и затем <-ch, чтобы корректно обработать его привычным select. Обратите внимание: доставка в process является неблокирующей: если канал заполнен, сигнал отбрасывается. Это осознанный компромисс: лучше пропустить сигнал, чем позволить циклу диспетчеризации зависнуть из-за медленного потребителя. Именно поэтому канал для signal.Notify как правило должен быть буферизованным.
9.6.5 Компромиссы в сравнении с другими рантаймами и практический побочный эффект
Отношения между сигналами и управляемыми рантаймами всегда были непростыми, и корень проблемы в следующем: рантайм хочет использовать сигналы, как и пользователь, и нативные библиотеки, однако в рамках процесса для любого сигнала существует лишь один обработчик. JVM также вынуждена захватывать SIGSEGV (для проверки нулевых указателей и ловушки защиты страниц при safepoint polling), SIGBUS и прочие, обеспечивая для этого цепочку сигналов (libjsig): она запоминает обработчик, существовавший до её установки, и пробрасывает управление обратно к нему в случаях, которые не являются её собственными. sigfwdgo в Go (9.6.3) решает ту же проблему, только в обратном направлении: перед установкой собственного обработчика Go сохраняет старый в fwdSig и пробрасывает управление при необходимости. Оба механизма приходят к одному результату разными путями, обеспечивая мирное сосуществование рантайма и нативных библиотек на одном сигнале.
Именно потому что сигналы — дефицитный общий ресурс, Go особенно тщательно выбирает «какой сигнал» использовать для асинхронного вытеснения. В исходном коде рантайма перечислен ряд эвристических условий: сигнал должен по умолчанию пропускаться отладчиками, не использоваться внутренними механизмами libc в смешанных бинарных файлах, допускать ложные срабатывания без ущерба и быть доступным на платформах без сигналов реального времени (например, macOS). SIGUSR1/SIGUSR2 отпадают, поскольку приложения нередко наделяют их реальным смыслом; SIGALRM отпадает, поскольку невозможно определить, сработал ли настоящий таймер. Окончательный выбор — SIGURG: формально он сообщает о внеполосных данных в сокете, которыми почти никто не пользуется, причём сигнал даже не указывает «какой сокет»; он фактически устарел, и даже приложение, которое его использует, обязано допускать ложные срабатывания. Выбор сигнала, «достаточно безвредного для произвольной отправки», — завершающий штрих этого проекта.
Данная совокупность решений влечёт практический побочный эффект, который нередко остаётся незамеченным. С момента введения асинхронного вытеснения в Go 1.14 (9.7) активная программа часто получает SIGURG, и сигнал прерывает выполняемый медленный системный вызов, заставляя его вернуть EINTR. Флаг SA_RESTART в POSIX позволяет некоторым прерванным системным вызовам перезапускаться автоматически, и Go действительно устанавливает его при регистрации обработчика, однако не все системные вызовы поддерживают перезапуск (например, poll и некоторые варианты read). Поэтому корректный код на Go и обёртки пакета syscall, которые он использует, должны уметь распознавать EINTR и повторять вызов. Это ощутимая плата «за возможность вытеснения», и она напоминает нам, что каждое решение в механизме сигналов распространяет своё влияние вплоть до системных вызовов и наблюдаемого пользователем поведения. Выигрыши в производительности и функциональности никогда не достаются бесплатно — они всегда оставляют где-то уголок, требующий внимания.
9.6.6 Итоги
Подводя итог данного раздела: жёсткое ограничение async-signal safety (9.6.1) порождает железное правило «выполнять в обработчике лишь минимум»; проецирование этого правила на отдельные компоненты даёт собственный альтернативный стек gsignal для каждого M (9.6.2), трёхстороннее разветвление sighandler (9.6.3) и lock-free очередь с выделенной горутиной, которая «понижает» пользовательские сигналы до событий в канале (9.6.4). Эта двухэтапная схема в точности совпадает с механизмом асинхронного вытеснения из 9.7: внедрить только минимальный шаг в ограниченном контексте сигнала, а реальную работу перенести на безопасную почву. Усвоив это, асинхронное вытеснение из следующего раздела предстаёт лишь повторным применением той же техники — на этот раз к планированию.
Дополнительная литература
- W. Richard Stevens, Stephen A. Rago. Advanced Programming in the UNIX Environment, 3rd ed. Addison-Wesley, 2013. (Безопасность в контексте асинхронных сигналов, метод self-pipe, авторитетное руководство по обработке сигналов.)
- The Linux man-pages project. signal-safety(7) (список допустимых async-signal-safe функций); sigaltstack(2); signalfd(2). https://man7.org/linux/man-pages/man7/signal-safety.7.html
- The Go Authors. runtime/signal_unix.go, runtime/sigqueue.go (
sighandler,sigtramp/sigtrampgo,sigsend/signal_recv, комментарий к выборуsigPreempt = _SIGURG). https://github.com/golang/go/blob/master/src/runtime/signal_unix.go - The Go Authors. os/signal package documentation and src/os/signal/signal_unix.go (
Notify/loop/process). https://pkg.go.dev/os/signal - The Go Authors. Proposal: Non-cooperative goroutine preemption (#24543, обоснование выбора
SIGURGи механизма асинхронного вытеснения). https://go.googlesource.com/proposal/+/master/design/24543-non-cooperative-preemption - Oracle / OpenJDK. Signal Chaining (libjsig) (промышленная практика совместного использования сигналов управляемым рантаймом и нативными библиотеками). https://docs.oracle.com/en/java/javase/21/docs/specs/man/java.html
- В этой книге: 9.3 Компоненты шедулера, 9.7 Кооперативность и вытеснение, 14.6 Управление стеком.