Skip to content

Commit 545f911

Browse files
committed
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.
1 parent 61c315c commit 545f911

5 files changed

Lines changed: 169 additions & 56 deletions

File tree

src/runtime/runtime_unix.go

Lines changed: 17 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ package runtime
44

55
import (
66
"internal/futex"
7-
"internal/task"
87
"math/bits"
98
"sync/atomic"
109
"tinygo"
@@ -414,17 +413,6 @@ func signal_disable(s uint32) {
414413
tinygo_signal_disable(s)
415414
}
416415

417-
//go:linkname signal_waitUntilIdle os/signal.signalWaitUntilIdle
418-
func signal_waitUntilIdle() {
419-
// Wait until signal_recv has processed all signals.
420-
for receivedSignals.Load() != 0 {
421-
// TODO: this becomes a busy loop when using threads.
422-
// We might want to pause until signal_recv has no more incoming signals
423-
// to process.
424-
Gosched()
425-
}
426-
}
427-
428416
//export tinygo_signal_enable
429417
func tinygo_signal_enable(s uint32)
430418

@@ -448,48 +436,28 @@ func tinygo_signal_handler(s int32) {
448436
// goroutines.
449437
signalFutex.WakeAll()
450438
}
451-
}
452439

453-
// Task waiting for a signal to arrive, or nil if it is running or there are no
454-
// signals.
455-
var signalRecvWaiter atomic.Pointer[task.Task]
456-
457-
//go:linkname signal_recv os/signal.signal_recv
458-
func signal_recv() uint32 {
459-
// Function called from os/signal to get the next received signal.
460-
for {
461-
val := receivedSignals.Load()
462-
if val == 0 {
463-
// There are no signals to receive. Sleep until there are.
464-
if signalRecvWaiter.Swap(task.Current()) != nil {
465-
// We expect only a single goroutine to call signal_recv.
466-
runtimeFatal("signal_recv called concurrently")
467-
}
468-
task.Pause()
469-
continue
470-
}
440+
// Wake the goroutine inside os/signal that gives signals to the channels
441+
// of signal.Notify. This is a no-op with the cooperative scheduler, which
442+
// resumes that goroutine from checkSignals instead.
443+
signalRecvWake()
444+
}
471445

472-
// Extract the lowest numbered signal number from receivedSignals.
473-
num := uint32(bits.TrailingZeros32(val))
446+
// nextReceivedSignal takes the lowest numbered signal out of receivedSignals,
447+
// or returns 0, false when there are none pending.
448+
func nextReceivedSignal() (uint32, bool) {
449+
val := receivedSignals.Load()
450+
if val == 0 {
451+
return 0, false
452+
}
474453

475-
// Atomically clear the signal number from receivedSignals.
476-
receivedSignals.And(^(uint32(1) << num))
454+
// Extract the lowest numbered signal number from receivedSignals.
455+
num := uint32(bits.TrailingZeros32(val))
477456

478-
return num
479-
}
480-
}
457+
// Atomically clear the signal number from receivedSignals.
458+
receivedSignals.And(^(uint32(1) << num))
481459

482-
// Reactivate the goroutine waiting for signals, if there are any.
483-
// Return true if it was reactivated (and therefore the scheduler should run
484-
// again), and false otherwise.
485-
func checkSignals() bool {
486-
if receivedSignals.Load() != 0 {
487-
if waiter := signalRecvWaiter.Swap(nil); waiter != nil {
488-
scheduleTask(waiter)
489-
return true
490-
}
491-
}
492-
return false
460+
return num, true
493461
}
494462

495463
func waitForEvents() {

src/runtime/signal_cooperative.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
//go:build (darwin || (linux && !baremetal && !wasip1 && !wasm_unknown && !wasip2 && !nintendoswitch)) && !scheduler.threads
2+
3+
package runtime
4+
5+
import (
6+
"internal/task"
7+
"sync/atomic"
8+
)
9+
10+
// The goroutine inside os/signal that reads signals is an ordinary task here.
11+
// It is parked with task.Pause and resumed from checkSignals below.
12+
13+
// Task waiting for a signal to arrive, or nil if it is running or there are no
14+
// signals.
15+
var signalRecvWaiter atomic.Pointer[task.Task]
16+
17+
//go:linkname signal_recv os/signal.signal_recv
18+
func signal_recv() uint32 {
19+
// Function called from os/signal to get the next received signal.
20+
for {
21+
if num, ok := nextReceivedSignal(); ok {
22+
return num
23+
}
24+
25+
// There are no signals to receive. Sleep until there are.
26+
if signalRecvWaiter.Swap(task.Current()) != nil {
27+
// We expect only a single goroutine to call signal_recv.
28+
runtimeFatal("signal_recv called concurrently")
29+
}
30+
task.Pause()
31+
}
32+
}
33+
34+
//go:linkname signal_waitUntilIdle os/signal.signalWaitUntilIdle
35+
func signal_waitUntilIdle() {
36+
// Wait until signal_recv has processed all signals. Yielding is enough:
37+
// the scheduler runs signal_recv, which is the only thing that empties
38+
// receivedSignals.
39+
for receivedSignals.Load() != 0 {
40+
Gosched()
41+
}
42+
}
43+
44+
// Called from the signal handler. The waiting task is resumed by checkSignals
45+
// instead, from the scheduler, so there is nothing to do here.
46+
func signalRecvWake() {
47+
}
48+
49+
// Reactivate the goroutine waiting for signals, if there are any.
50+
// Return true if it was reactivated (and therefore the scheduler should run
51+
// again), and false otherwise.
52+
func checkSignals() bool {
53+
if receivedSignals.Load() != 0 {
54+
if waiter := signalRecvWaiter.Swap(nil); waiter != nil {
55+
scheduleTask(waiter)
56+
return true
57+
}
58+
}
59+
return false
60+
}

src/runtime/signal_threads.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
//go:build (darwin || (linux && !baremetal && !wasip1 && !wasm_unknown && !wasip2 && !nintendoswitch)) && scheduler.threads
2+
3+
package runtime
4+
5+
import (
6+
"internal/futex"
7+
)
8+
9+
// Futex the receiver in os/signal waits on. Its value is 1 when the handler
10+
// has stored a signal that the receiver did not read yet.
11+
//
12+
// It cannot wait on signalFutex, because sleepTicks waits on that one too and
13+
// swaps it back to zero, which would lose the wakeup of the receiver.
14+
var signalRecvFutex futex.Futex
15+
16+
// Futex signalWaitUntilIdle waits on. Its value is always zero, so it is only
17+
// a wakeup address. The wait has a timeout because a wake that arrives before
18+
// the wait starts is not remembered.
19+
var signalIdleFutex futex.Futex
20+
21+
// How long signalWaitUntilIdle blocks before rechecking on its own.
22+
const signalIdlePoll = 1e6 // 1ms, in nanoseconds
23+
24+
//go:linkname signal_recv os/signal.signal_recv
25+
func signal_recv() uint32 {
26+
// Function called from os/signal to get the next received signal.
27+
for {
28+
if num, ok := nextReceivedSignal(); ok {
29+
if receivedSignals.Load() == 0 {
30+
// That was the last pending signal, so signalWaitUntilIdle can
31+
// return now.
32+
signalIdleFutex.WakeAll()
33+
}
34+
return num
35+
}
36+
37+
// Clear the flag and then read receivedSignals again. The handler
38+
// stores the signal before the flag, so no wakeup is lost.
39+
signalRecvFutex.Store(0)
40+
if receivedSignals.Load() != 0 {
41+
continue
42+
}
43+
signalRecvFutex.Wait(0)
44+
}
45+
}
46+
47+
//go:linkname signal_waitUntilIdle os/signal.signalWaitUntilIdle
48+
func signal_waitUntilIdle() {
49+
// Wait until signal_recv has processed all signals. Gosched is a no-op
50+
// with threads, so this must block.
51+
for receivedSignals.Load() != 0 {
52+
signalIdleFutex.WaitUntil(0, signalIdlePoll)
53+
}
54+
}
55+
56+
// Called from the signal handler to wake signal_recv. An atomic store and a
57+
// futex wake syscall are both safe in a signal handler.
58+
func signalRecvWake() {
59+
if signalRecvFutex.Swap(1) == 0 {
60+
// Changed from 0 to 1, so signal_recv may be waiting on it.
61+
signalRecvFutex.WakeAll()
62+
}
63+
}
64+
65+
// Reactivate the goroutine waiting for signals, if there are any. There is no
66+
// such goroutine here, because the handler wakes the receiver directly.
67+
func checkSignals() bool {
68+
return false
69+
}

testdata/signal.go

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,24 @@ import (
1212
)
1313

1414
func main() {
15+
// A signal must reach the channel while the program does nothing else. The
16+
// receive below is the only thing that runs, so no timer and no sleep can
17+
// carry the delivery.
1518
c := make(chan os.Signal, 1)
1619
signal.Notify(c, syscall.SIGUSR1)
20+
syscall.Kill(syscall.Getpid(), syscall.SIGUSR1)
21+
report(<-c)
22+
signal.Stop(c)
23+
24+
// The same again, with a goroutine that reads the channel while the main
25+
// goroutine sleeps.
26+
c2 := make(chan os.Signal, 1)
27+
signal.Notify(c2, syscall.SIGUSR1)
1728

1829
// Wait for signals to arrive.
1930
go func() {
20-
for sig := range c {
21-
if sig == syscall.SIGUSR1 {
22-
println("got expected signal")
23-
} else {
24-
println("got signal:", sig.String())
25-
}
31+
for sig := range c2 {
32+
report(sig)
2633
}
2734
}()
2835

@@ -36,7 +43,15 @@ func main() {
3643
// in a unit test).
3744
signal.Ignore(syscall.SIGUSR1)
3845

39-
signal.Stop(c)
46+
signal.Stop(c2)
4047

4148
println("exiting signal program")
4249
}
50+
51+
func report(sig os.Signal) {
52+
if sig == syscall.SIGUSR1 {
53+
println("got expected signal")
54+
} else {
55+
println("got signal:", sig.String())
56+
}
57+
}

testdata/signal.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
got expected signal
2+
got expected signal
23
exiting signal program

0 commit comments

Comments
 (0)