runtime: make Goexit exit host threads

The threads scheduler handled runtime.Goexit using the generic deadlock
path, which parked the current pthread forever. Add a scheduler-specific
goexit hook so it can remove the current task and exit the pthread while
ordinary blocking deadlocks still block.

Track main goroutine Goexit so the last remaining goroutine reports the
expected deadlock instead of letting the process exit successfully.
Expand the crash coverage with the Goexit cases covered by Big Go's
runtime tests.
This commit is contained in:
Jake Bailey
2026-07-03 16:48:11 -07:00
committed by Ron Evans
parent 16eefc59eb
commit 432ebe13ed
14 changed files with 225 additions and 36 deletions
+45 -7
View File
@@ -43,7 +43,9 @@ var mainTask Task
// Queue of tasks (see QueueNext) that currently exist in the program.
var activeTasks = &mainTask
var activeTaskCount uint32 = 1
var activeTaskLock PMutex
var mainExitedByGoexit bool
func OnSystemStack() bool {
runtimePanic("todo: task.OnSystemStack")
@@ -95,9 +97,6 @@ func (t *Task) Resume() {
t.state.pauseSem.Post()
}
// otherGoroutines is the total number of live goroutines minus one.
var otherGoroutines uint32
// Start a new OS thread.
func start(fn uintptr, args unsafe.Pointer, stackSize uintptr) {
t := &Task{}
@@ -117,7 +116,7 @@ func start(fn uintptr, args unsafe.Pointer, stackSize uintptr) {
}
t.state.QueueNext = activeTasks
activeTasks = t
otherGoroutines++
activeTaskCount++
activeTaskLock.Unlock()
}
@@ -127,6 +126,12 @@ func taskExited(t *Task) {
println("*** exit:", t.state.id)
}
if exit(t) {
runtimePanic("all goroutines are asleep - deadlock!")
}
}
func exit(t *Task) bool {
// Remove from the queue.
// TODO: this can be made more efficient by using a doubly linked list.
activeTaskLock.Lock()
@@ -135,16 +140,46 @@ func taskExited(t *Task) {
if *q == t {
*q = t.state.QueueNext
found = true
activeTaskCount--
break
}
}
otherGoroutines--
deadlocked := mainExitedByGoexit && activeTaskCount == 0
activeTaskLock.Unlock()
// Sanity check.
if !found {
runtimePanic("taskExited failed")
}
return deadlocked
}
func otherTasks(current *Task) uint32 {
if activeTaskCount == 0 {
return 0
}
return activeTaskCount - 1
}
// Exit exits the current task. If this is the main task and there are no other
// goroutines, it reports a deadlock.
func Exit() {
t := Current()
if t == &mainTask {
activeTaskLock.Lock()
noOtherTasks := otherTasks(t) == 0
if !noOtherTasks {
mainExitedByGoexit = true
}
activeTaskLock.Unlock()
if noOtherTasks {
runtimePanic("all goroutines are asleep - deadlock!")
}
}
if exit(t) {
runtimePanic("all goroutines are asleep - deadlock!")
}
tinygo_task_exit()
}
// scanWaitGroup is used to wait on until all threads have finished the current state transition.
@@ -206,7 +241,7 @@ func GCStopWorldAndScan() {
gcState.Store(gcStateStopped)
// Set the number of threads to wait for.
scanWaitGroup = initWaitGroup(otherGoroutines)
scanWaitGroup = initWaitGroup(otherTasks(current))
// Pause all other threads.
for t := activeTasks; t != nil; t = t.state.QueueNext {
@@ -242,7 +277,7 @@ func GCResumeWorld() {
}
// Set the wait group to track resume progress.
scanWaitGroup = initWaitGroup(otherGoroutines)
scanWaitGroup = initWaitGroup(otherTasks(Current()))
// Set the state to resumed.
gcState.Store(gcStateResumed)
@@ -304,6 +339,9 @@ func tinygo_task_init(t *Task, thread *threadID, numCPU *int32)
//go:linkname tinygo_task_start tinygo_task_start
func tinygo_task_start(fn uintptr, args unsafe.Pointer, t *Task, thread *threadID, stackTop *uintptr, stackSize uintptr) int32
//go:linkname tinygo_task_exit tinygo_task_exit
func tinygo_task_exit()
// Pause the thread by sending it a signal.
//
//export tinygo_task_send_gc_signal