Explanation
What just happened
With GOMAXPROCS(1) there is one P, so only one goroutine runs Go code at a time. main took it for a loop that never calls a function, so the loop has no point where it stops on its own.
The runtime interrupted it anyway: async preemption. Each time, the P got to the scheduler, the ticker’s timer fired, and the ticker took a turn. Then main carried on where it left off.
Try the what-if switch
- asyncpreemptoff=1: the runtime may not interrupt
main. The loop held the P to the end, and the ticker never ran during it.
Why it works this way
Without preemption, a goroutine stops only where it checks: a function call with a stack check, or blocking. A tight loop has neither. Async preemption lets the runtime stop it anywhere, so one goroutine can’t freeze the rest.
Watch out
Turning preemption off brings the old risk back, and a call in the loop may not save you: the compiler leaves the check out of tiny leaf functions.
Common questions
Can an infinite loop in one goroutine block the others?
Not on its own. The runtime interrupts a running goroutine mid-loop (async preemption), even when the loop makes no function calls. With GOMAXPROCS(1), the ticker here still got its turns while main spun.
What does GODEBUG=asyncpreemptoff=1 do?
It stops the runtime from interrupting a running goroutine. A loop with no function calls then holds its P until it ends: here the ticker got no turn during the loop.
Why did my time.Sleep(time.Millisecond) take longer than 1 ms?
A sleeping goroutine's timer fires only when its P gets to the scheduler. While main held the only P, the ticker woke only at main's preemptions, not after 1 ms.
Verified: this timeline matches a real program, checked in CI on Go 1.26 and 1.27. How we verify. Unclear or wrong? Tell us