Goroutines vs OS Threads
Open hundreds of TCP connections, each with a goroutine parked on a blocking Read, and watch the netpoller keep the OS-thread count tiny while goroutines pile up. On macOS, raise the descriptor limit first with ulimit -n 2048.
package main
import (
"fmt"
"io"
"net"
"runtime"
"runtime/pprof"
"time"
)
func main() {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
fmt.Println("listen:", err)
return
}
defer ln.Close()
const conns = 400 // each uses ~2 fds; raise ulimit -n to go higher
// Server: accept every connection and drain it (parks in the netpoller).
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go io.Copy(io.Discard, c)
}
}()
// Client: open many connections, each parked on a blocking Read.
held := make([]net.Conn, 0, conns)
for i := 0; i < conns; i++ {
c, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
fmt.Println("dial stopped at", i, ":", err)
break
}
held = append(held, c)
go func(c net.Conn) {
c.Read(make([]byte, 1)) // blocks forever: parked, not a busy thread
}(c)
}
time.Sleep(100 * time.Millisecond) // let everything settle
fmt.Println("connections held:", len(held))
fmt.Println("goroutines:", runtime.NumGoroutine()) // ~800+
fmt.Println("OS threads:", pprof.Lookup("threadcreate").Count()) // low double digits
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
for _, c := range held {
c.Close()
}
}Save the code as main.go and run it:
go run main.goExpected output:
connections held: 400
goroutines: 802
OS threads: 13
GOMAXPROCS: 14GOMAXPROCS equals your CPU count, and the OS-thread count stays low no matter how many connections you hold. That gap is the netpoller at work. On macOS, run ulimit -n 2048 first or the dials stop early at the descriptor limit.