runtime: deliver signals under the threads scheduler when blocked on I/O - #5530
runtime: deliver signals under the threads scheduler when blocked on I/O#55300pcom wants to merge 2 commits into
Conversation
…e sleeps Under the threads scheduler there is no cooperative idle loop, so checkSignals() — which resumes the parked os/signal signal_recv goroutine — was only ever reached from sleepTicks(). That means a signal (e.g. SIGINT/Ctrl+C) was only noticed while some goroutine happened to be inside time.Sleep. A program blocked purely on I/O, channels, mutexes or timers (time.NewTicker uses the timer queue, not sleepTicks) would never observe the signal at all. Start a dedicated signal-watcher thread the first time a signal is enabled, gated to the threads scheduler (!hasScheduler && hasParallelism). It blocks on signalFutex and calls checkSignals() on wake, mirroring the signal half of waitForEvents() that the cooperative scheduler runs from its idle loop. Other schedulers are unaffected (the start is a compile-time no-op for them). Verified: a channel/Accept-blocked program with no time.Sleep now receives SIGINT, and the skycoin daemon (previously unkillable with Ctrl+C under TinyGo) now shuts down cleanly on SIGINT, both idle and during active block sync. (cherry picked from commit ada7ab6)
| // signalFutex and resumes the signal-receiving goroutine (signal_recv) whenever | ||
| // a signal arrives, decoupling signal delivery from sleepTicks(). It mirrors the | ||
| // signal half of waitForEvents(), which the threads scheduler never calls. | ||
| func signalWatcher() { |
There was a problem hiding this comment.
This is called as a Go routine with no exit condition. I know that waitForEvents() already has the same issue, but it would be pretty nice to have a way for a cleaner exit.
|
Thanks for the review — addressed in 7243c59. You're right that a goroutine with no exit condition isn't great, so the watcher now has a lifetime rather than running forever. It exists only to serve enabled signals, so that is what bounds it: The shutdown itself sets a flag, bumps the futex value, and wakes it. The bump matters as much as the wake — Two details worth flagging:
Verified with a program that blocks on a channel and never on |
Review feedback: the watcher was started as a goroutine with no exit condition. It blocked on a futex forever, so a program that finished with signals kept a thread parked on one for the rest of its life. Nothing observable broke — the thread is idle and process exit tears it down — but a loop with no way out is a property worth not having. The watcher exists only to serve enabled signals, so that is now its lifetime: enabledSignals tracks the set os/signal wants delivered, the last signal_disable/signal_ignore stops the thread, and a later signal_enable starts a fresh one. Stopping sets the flag, bumps the futex value and wakes it. The bump matters as much as the wake: Wait(0) returns immediately when the futex is already non-zero, which closes the window between the store and a watcher that is about to sleep. On the way out the watcher resets the futex to 0 so the next one can block on it. Verified with a program that blocks on a channel (never time.Sleep, so delivery can only come from the watcher): the signal arrives, signal.Stop lets the thread exit, and a later signal.Notify starts a new watcher that delivers again.
7243c59 to
e626202
Compare
|
Correction to my previous comment: I said I'd used CAS loops because Amended in e626202 to use One readability note on the stop path: |
Problem
Under the
threadsscheduler (the default on Linux/macOS), a program blocked purely on I/O, channels or mutexes never observes an OS signal. For example, a server that doessignal.Notify(c, os.Interrupt); <-cwhile its goroutines are blocked on network I/O ignores Ctrl+C indefinitely and has to be killed.The cause:
checkSignals()— which resumes the parkedos/signalsignal_recvgoroutine — is only ever reached fromsleepTicks()(inruntime_unix.go). So a signal is only noticed while some goroutine happens to be insidetime.Sleep.time.NewTicker/time.Aftergo through the timer queue (timerRunner), notsleepTicks, so a program that blocks on I/O/channels can ignore SIGINT indefinitely.The cooperative and multicore schedulers don't have this problem because they call
checkSignals()from their idle loop (waitForEvents). The threads scheduler has no such loop, so nothing consumessignalFutexand resumessignal_recv.Fix
Start a dedicated signal-watcher thread the first time a signal is enabled, gated to the threads scheduler (
!hasScheduler && hasParallelism, which is true only there). It blocks onsignalFutexand callscheckSignals()on wake — mirroring the signal half ofwaitForEvents(). It is a compile-time no-op for every other scheduler: the cooperative/cores schedulers already handle signals from their idle loop, and thenonescheduler has no goroutines.Testing
A channel/
Accept-blocked program with notime.Sleepanywhere ignores SIGINT before this change and exits cleanly after it. Also verified against a real network daemon that blocks on I/O: SIGINT now triggers graceful shutdown, both idle and under load.