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.go

Expected output:

connections held: 400
goroutines: 802
OS threads: 13
GOMAXPROCS: 14

GOMAXPROCS 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.

💻 Run locally

Copy the code above and run it on your machine

© 2026 ByteLearn.dev. Free courses for developers. · Privacy