Asynchronous Preemption in Go and Tight Loops That Hang
How Go 1.14 made tight loops preemptible with signals, an experiment where a call-free loop starves main, and what still blocks the scheduler.

Here is a program that should finish. It starts a goroutine that spins in a loop forever, and then main sleeps for 50 milliseconds and prints a line. On a machine with one processor available to Go, whether the line ever appears depended, for the first ten years of the language, on whether the loop body happened to contain a function call. The program is a good window into one of the quiet improvements in Go 1.14, and into what a "preemptive scheduler" means in a language that doesn't have threads in the usual sense.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article explains cooperative preemption and why it left a hole, shows the hole with a runnable experiment, and describes what the runtime does about it now. It belongs to the concurrency patterns cluster, extends how the scheduler works, and relates to goroutines and OS threads. Everything ran on Go 1.25.6.
Cooperative preemption and its hole
A scheduler that never interrupts a running task is cooperative. The task must voluntarily give up the processor, by blocking or by calling into the scheduler. Early Go worked mostly that way, with one addition. The compiler inserted a stack-size check at the start of almost every function, because Go stacks grow on demand and a function has to know whether it has room. The runtime reused that check as a preemption point: to stop a goroutine, it set a flag that made the next stack check fail, and the goroutine would fall into the scheduler at its next function call.
That works for nearly all code, because nearly all code calls functions often. A loop whose body calls nothing never reaches a check. The compiler is free to inline small functions, so a loop that looks like it calls things may not either. In those loops the goroutine keeps its processor until it finishes. Two bad things followed. Other goroutines on the same P starved, which on a one-processor program means the whole program stalls. And garbage collection, which has to stop all goroutines briefly, could wait indefinitely for the one in the loop to reach a safe point, so one tight loop in one goroutine could freeze a whole service's allocation.
The experiment
The program below reproduces the hole. A child process restricts Go to one processor, starts a goroutine that increments a counter forever (no calls, nothing for the old mechanism to latch onto), and has main sleep and print. A parent runs the child twice with a two-second timeout: once with defaults and once with GODEBUG=asyncpreemptoff=1, which switches the modern mechanism off and restores the pre-1.14 behavior:
package main
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"runtime"
"time"
)
// child: one P, one goroutine that never makes a function call, and a main that wants to wake up.
func child() {
runtime.GOMAXPROCS(1)
go func() {
n := 0
for {
n++ // a tight loop: no calls, so no cooperative preemption point
}
}()
time.Sleep(50 * time.Millisecond)
fmt.Println("main woke up and ran")
}
func main() {
if os.Getenv("EXPERIMENT_CHILD") == "1" {
child()
return
}
for _, setting := range []string{"default", "asyncpreemptoff=1"} {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
cmd := exec.CommandContext(ctx, os.Args[0])
cmd.Env = append(os.Environ(), "EXPERIMENT_CHILD=1")
if setting != "default" {
cmd.Env = append(cmd.Env, "GODEBUG="+setting)
}
out, err := cmd.CombinedOutput()
cancel()
switch {
case errors.Is(ctx.Err(), context.DeadlineExceeded):
fmt.Printf("%-18s hung: killed after 2s, output %q\n", setting, out)
case err != nil:
fmt.Printf("%-18s failed: %v\n", setting, err)
default:
fmt.Printf("%-18s %s", setting, out)
}
}
}default main woke up and ran
asyncpreemptoff=1 hung: killed after 2s, output ""With defaults, main wakes at 50 ms, prints, and exits. With asynchronous preemption off, the timer fires, but main needs the only processor to run, and the spinning goroutine never releases it. The child sits there until the parent kills it at the two-second mark, with no output. That is what every Go program on Go 1.13 or earlier could do, and the reason that code from that era sometimes contains an innocent-looking runtime.Gosched() inside a loop.
How asynchronous preemption works
Go 1.14 added a second mechanism that doesn't need the goroutine's cooperation. The monitor thread, sysmon, watches how long each goroutine has been running. When one has gone more than about 10 milliseconds without yielding, the runtime asks it to stop by sending a signal to the goroutine's thread, SIGURG on Unix systems. The signal handler runs on the interrupted thread, inspects where the goroutine's program counter is, and checks whether that spot is an asynchronous safe point: a place where the registers and stack have a layout the garbage collector can scan and the goroutine can be paused and resumed correctly. If so, it injects a call to the scheduler's preemption routine, and the goroutine is parked and queued like any other. If not (inside certain runtime sequences or between instructions that must stay atomic), it tries again shortly.
The release notes for Go 1.14 describe the change in a few lines, and the design is in the proposal document the Go team published beforehand. The practical result is that you no longer have to think about it. A CPU-bound goroutine cannot starve its neighbours for more than a scheduling quantum, and garbage collection can always stop the world promptly. That also made the old idiom of sprinkling runtime.Gosched() into loops unnecessary.
Side effects you may notice
The mechanism is a signal, and signals have consequences outside the runtime.
SIGURG shows up in traces. Running a Go program under strace or dtruss after 1.14 shows a stream of SIGURG deliveries, even on quiet programs, because the runtime also uses the signal for other wake-ups. It is not an error. The choice of SIGURG was deliberate, since it is a signal with no default effect that real programs almost never use.
System calls can be interrupted. A blocking system call interrupted by a signal returns EINTR, "interrupted system call". The Go standard library retries these for you. Code that calls syscall.Read and similar functions directly must handle it, and the Go 1.14 notes tell authors of such code to expect it. Programs that use cgo and block signals or install their own handlers for SIGURG can interfere with preemption; the documentation for os/signal mentions the signal as one the runtime uses.
Debugging gets a little odd. Debuggers that stop on every signal will stop on SIGURG, and the usual advice is to configure them to pass it through silently.
GODEBUG=asyncpreemptoff=1 exists as the switch to turn the mechanism off, mostly for debugging suspected runtime issues or in environments where signals are a problem. In normal operation, leave it alone.
What preemption does not fix
A goroutine inside a cgo call or a blocking system call is not running Go code, so the runtime cannot inject anything into it, and the signal can interrupt a syscall but not C code that doesn't return. That case is handled by creating other threads rather than stopping the goroutine (cgo calls and blocking syscalls). A few runtime sections are non-preemptible by design, such as parts of the memory allocator and garbage collector, and they are short.
Preemption also changes nothing about fairness within your logic. A CPU-bound goroutine will be paused after about 10 ms, which means a tight loop in one goroutine delays others on its P by up to that long, not forever. If you need latency better than that, avoid doing 10 ms of work in one go, or use more Ps. And preemption does not make unsynchronized code correct: a goroutine can now be stopped between any two instructions, so the race detector's warnings about unprotected shared data are more rather than less relevant (data races).
For a hands-on check on your own machine, rerun the experiment above with GOMAXPROCS(1) and tweak the loop. The runtime documentation for the GODEBUG settings lists asyncpreemptoff, and the Go 1.14 release notes remain the best short statement of what changed and why.


