Explanation
What just happened
With GOMAXPROCS(1) there is one P, and only the thread holding it runs Go code. main called read() on a plain pipe, and its thread went into the kernel to wait.
Go didn’t let that stall everything. It handed the P to another thread, and the ticker and the writer ran there. When read() returned, main got a P back and carried on on its own thread.
Try the what-if switch
- network read:
mainreads from a TCP connection. It parks instead, the netpoller watches the socket, and the same thread runs the ticker. No second thread is needed.
Why it works this way
A thread in a system call can’t run Go code, but the P can. Handing it over keeps the other goroutines moving. Network I/O goes further: the netpoller watches the socket, so a waiting read holds no thread at all.
Watch out
Every goroutine blocked in a system call holds its own OS thread. Many of them at once means many threads.
Common questions
What happens when a goroutine makes a blocking system call?
Its OS thread waits in the kernel, and the goroutine dump shows [syscall]. Go hands the P to another thread, so other goroutines keep running: here the ticker ran on a second thread the whole time.
Does a network read block an OS thread in Go?
No. A conn.Read with no data parks the goroutine ([IO wait]) and its thread runs other goroutines. 20 goroutines waiting on network reads at once didn't need 20 threads.
Why does my Go program have so many threads?
Goroutines blocked in system calls each hold an OS thread. 20 goroutines blocked in syscall.Read needed 20 threads; 20 waiting on network reads didn't.
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