From 369c493cb883535dcf3efaa5e6b5058cb49860a0 Mon Sep 17 00:00:00 2001 From: yohimik Date: Sun, 30 Aug 2026 15:23:31 +0400 Subject: [PATCH] runtime: deliver os/signal notifications under the threads scheduler signal.Notify delivers a signal on hosted linux and macOS only while some other goroutine is in time.Sleep. Both hosts default to -scheduler=threads. A program that installs a handler and then waits on the channel, which is what a command does to stop on SIGINT, waits for ever. The receiving goroutine in os/signal calls signal_recv, which parks itself with task.Pause when nothing is pending. Only checkSignals resumes it, and on the receive path the callers of checkSignals are waitForEvents, the idle hook of the cooperative scheduler, and sleepTicks. With threads there is no scheduler loop, so waitForEvents never runs and only a sleep elsewhere in the program lets a signal through. Give the receiver a wait that works on a thread. signal_recv now blocks on a futex of its own that the handler wakes, with the same 0/1 protocol that the handler already uses on signalFutex. It cannot share signalFutex, because sleepTicks waits on that one too and swaps it back to zero, so a time.Sleep anywhere in the program would take the wakeup of the receiver. The handler only gets an atomic store and a futex wake syscall, which are both safe in a signal handler on an arbitrary thread. The stop-the-world signal of the GC uses a different handler that this does not touch. signalWaitUntilIdle, which signal.Stop and signal.Reset call before they return, had the same problem from the other side. It spun on Gosched, which is a no-op with threads, so it used a core until the receiver emptied the last signal. It now waits on a futex that signal_recv wakes. The cooperative path keeps its behaviour. The two versions live in signal_cooperative.go and signal_threads.go, split on scheduler.threads like the schedulers themselves. testdata/signal.go grows a first phase that waits on the channel and nothing else. Built from the current dev branch on macOS 26.6 arm64 it prints nothing and hangs. With this change it prints the expected output and exits. --- src/runtime/runtime_unix.go | 66 ++++++++--------------------- src/runtime/signal_cooperative.go | 60 +++++++++++++++++++++++++++ src/runtime/signal_threads.go | 69 +++++++++++++++++++++++++++++++ testdata/signal.go | 29 +++++++++---- testdata/signal.txt | 1 + 5 files changed, 169 insertions(+), 56 deletions(-) create mode 100644 src/runtime/signal_cooperative.go create mode 100644 src/runtime/signal_threads.go diff --git a/src/runtime/runtime_unix.go b/src/runtime/runtime_unix.go index 24b208c387..ce548d0ef4 100644 --- a/src/runtime/runtime_unix.go +++ b/src/runtime/runtime_unix.go @@ -4,7 +4,6 @@ package runtime import ( "internal/futex" - "internal/task" "math/bits" "sync/atomic" "tinygo" @@ -414,17 +413,6 @@ func signal_disable(s uint32) { tinygo_signal_disable(s) } -//go:linkname signal_waitUntilIdle os/signal.signalWaitUntilIdle -func signal_waitUntilIdle() { - // Wait until signal_recv has processed all signals. - for receivedSignals.Load() != 0 { - // TODO: this becomes a busy loop when using threads. - // We might want to pause until signal_recv has no more incoming signals - // to process. - Gosched() - } -} - //export tinygo_signal_enable func tinygo_signal_enable(s uint32) @@ -448,48 +436,28 @@ func tinygo_signal_handler(s int32) { // goroutines. signalFutex.WakeAll() } -} -// Task waiting for a signal to arrive, or nil if it is running or there are no -// signals. -var signalRecvWaiter atomic.Pointer[task.Task] - -//go:linkname signal_recv os/signal.signal_recv -func signal_recv() uint32 { - // Function called from os/signal to get the next received signal. - for { - val := receivedSignals.Load() - if val == 0 { - // There are no signals to receive. Sleep until there are. - if signalRecvWaiter.Swap(task.Current()) != nil { - // We expect only a single goroutine to call signal_recv. - runtimeFatal("signal_recv called concurrently") - } - task.Pause() - continue - } + // Wake the goroutine inside os/signal that gives signals to the channels + // of signal.Notify. This is a no-op with the cooperative scheduler, which + // resumes that goroutine from checkSignals instead. + signalRecvWake() +} - // Extract the lowest numbered signal number from receivedSignals. - num := uint32(bits.TrailingZeros32(val)) +// nextReceivedSignal takes the lowest numbered signal out of receivedSignals, +// or returns 0, false when there are none pending. +func nextReceivedSignal() (uint32, bool) { + val := receivedSignals.Load() + if val == 0 { + return 0, false + } - // Atomically clear the signal number from receivedSignals. - receivedSignals.And(^(uint32(1) << num)) + // Extract the lowest numbered signal number from receivedSignals. + num := uint32(bits.TrailingZeros32(val)) - return num - } -} + // Atomically clear the signal number from receivedSignals. + receivedSignals.And(^(uint32(1) << num)) -// Reactivate the goroutine waiting for signals, if there are any. -// Return true if it was reactivated (and therefore the scheduler should run -// again), and false otherwise. -func checkSignals() bool { - if receivedSignals.Load() != 0 { - if waiter := signalRecvWaiter.Swap(nil); waiter != nil { - scheduleTask(waiter) - return true - } - } - return false + return num, true } func waitForEvents() { diff --git a/src/runtime/signal_cooperative.go b/src/runtime/signal_cooperative.go new file mode 100644 index 0000000000..f334979dd5 --- /dev/null +++ b/src/runtime/signal_cooperative.go @@ -0,0 +1,60 @@ +//go:build (darwin || (linux && !baremetal && !wasip1 && !wasm_unknown && !wasip2 && !nintendoswitch)) && !scheduler.threads + +package runtime + +import ( + "internal/task" + "sync/atomic" +) + +// The goroutine inside os/signal that reads signals is an ordinary task here. +// It is parked with task.Pause and resumed from checkSignals below. + +// Task waiting for a signal to arrive, or nil if it is running or there are no +// signals. +var signalRecvWaiter atomic.Pointer[task.Task] + +//go:linkname signal_recv os/signal.signal_recv +func signal_recv() uint32 { + // Function called from os/signal to get the next received signal. + for { + if num, ok := nextReceivedSignal(); ok { + return num + } + + // There are no signals to receive. Sleep until there are. + if signalRecvWaiter.Swap(task.Current()) != nil { + // We expect only a single goroutine to call signal_recv. + runtimeFatal("signal_recv called concurrently") + } + task.Pause() + } +} + +//go:linkname signal_waitUntilIdle os/signal.signalWaitUntilIdle +func signal_waitUntilIdle() { + // Wait until signal_recv has processed all signals. Yielding is enough: + // the scheduler runs signal_recv, which is the only thing that empties + // receivedSignals. + for receivedSignals.Load() != 0 { + Gosched() + } +} + +// Called from the signal handler. The waiting task is resumed by checkSignals +// instead, from the scheduler, so there is nothing to do here. +func signalRecvWake() { +} + +// Reactivate the goroutine waiting for signals, if there are any. +// Return true if it was reactivated (and therefore the scheduler should run +// again), and false otherwise. +func checkSignals() bool { + if receivedSignals.Load() != 0 { + if waiter := signalRecvWaiter.Swap(nil); waiter != nil { + scheduleTask(waiter) + return true + } + } + return false +} diff --git a/src/runtime/signal_threads.go b/src/runtime/signal_threads.go new file mode 100644 index 0000000000..a215b037d0 --- /dev/null +++ b/src/runtime/signal_threads.go @@ -0,0 +1,69 @@ +//go:build (darwin || (linux && !baremetal && !wasip1 && !wasm_unknown && !wasip2 && !nintendoswitch)) && scheduler.threads + +package runtime + +import ( + "internal/futex" +) + +// Futex the receiver in os/signal waits on. Its value is 1 when the handler +// has stored a signal that the receiver did not read yet. +// +// It cannot wait on signalFutex, because sleepTicks waits on that one too and +// swaps it back to zero, which would lose the wakeup of the receiver. +var signalRecvFutex futex.Futex + +// Futex signalWaitUntilIdle waits on. Its value is always zero, so it is only +// a wakeup address. The wait has a timeout because a wake that arrives before +// the wait starts is not remembered. +var signalIdleFutex futex.Futex + +// How long signalWaitUntilIdle blocks before rechecking on its own. +const signalIdlePoll = 1e6 // 1ms, in nanoseconds + +//go:linkname signal_recv os/signal.signal_recv +func signal_recv() uint32 { + // Function called from os/signal to get the next received signal. + for { + if num, ok := nextReceivedSignal(); ok { + if receivedSignals.Load() == 0 { + // That was the last pending signal, so signalWaitUntilIdle can + // return now. + signalIdleFutex.WakeAll() + } + return num + } + + // Clear the flag and then read receivedSignals again. The handler + // stores the signal before the flag, so no wakeup is lost. + signalRecvFutex.Store(0) + if receivedSignals.Load() != 0 { + continue + } + signalRecvFutex.Wait(0) + } +} + +//go:linkname signal_waitUntilIdle os/signal.signalWaitUntilIdle +func signal_waitUntilIdle() { + // Wait until signal_recv has processed all signals. Gosched is a no-op + // with threads, so this must block. + for receivedSignals.Load() != 0 { + signalIdleFutex.WaitUntil(0, signalIdlePoll) + } +} + +// Called from the signal handler to wake signal_recv. An atomic store and a +// futex wake syscall are both safe in a signal handler. +func signalRecvWake() { + if signalRecvFutex.Swap(1) == 0 { + // Changed from 0 to 1, so signal_recv may be waiting on it. + signalRecvFutex.WakeAll() + } +} + +// Reactivate the goroutine waiting for signals, if there are any. There is no +// such goroutine here, because the handler wakes the receiver directly. +func checkSignals() bool { + return false +} diff --git a/testdata/signal.go b/testdata/signal.go index a82991f086..164561fd7c 100644 --- a/testdata/signal.go +++ b/testdata/signal.go @@ -12,17 +12,24 @@ import ( ) func main() { + // A signal must reach the channel while the program does nothing else. The + // receive below is the only thing that runs, so no timer and no sleep can + // carry the delivery. c := make(chan os.Signal, 1) signal.Notify(c, syscall.SIGUSR1) + syscall.Kill(syscall.Getpid(), syscall.SIGUSR1) + report(<-c) + signal.Stop(c) + + // The same again, with a goroutine that reads the channel while the main + // goroutine sleeps. + c2 := make(chan os.Signal, 1) + signal.Notify(c2, syscall.SIGUSR1) // Wait for signals to arrive. go func() { - for sig := range c { - if sig == syscall.SIGUSR1 { - println("got expected signal") - } else { - println("got signal:", sig.String()) - } + for sig := range c2 { + report(sig) } }() @@ -36,7 +43,15 @@ func main() { // in a unit test). signal.Ignore(syscall.SIGUSR1) - signal.Stop(c) + signal.Stop(c2) println("exiting signal program") } + +func report(sig os.Signal) { + if sig == syscall.SIGUSR1 { + println("got expected signal") + } else { + println("got signal:", sig.String()) + } +} diff --git a/testdata/signal.txt b/testdata/signal.txt index c4726d7174..294eba06e3 100644 --- a/testdata/signal.txt +++ b/testdata/signal.txt @@ -1,2 +1,3 @@ got expected signal +got expected signal exiting signal program