Part 20 -- Systems programming in Go: the OS from inside a program
Every chapter so far looked at the kernel from the outside, through
ps,strace,free, anddocker stats. This part looks from the inside. You write small Go programs that make the kernel's behaviour visible and measurable, and you see where a modern language runtime and the operating system meet: threads, signals, cgroup limits, durability, file descriptors,/proc, namespaces, and profiling. Basic Go is enough. The Go guide covers the language. Every program was run on Linux while writing this guide, and the outputs shown are real.
Chapter 73 -- Go meets the kernel: system calls, threads, and the runtime
In one sentence
A Go program is a normal Linux process whose runtime multiplexes many goroutines onto a few OS threads, and how a goroutine waits decides whether it costs an OS thread.
Why this matters
"Goroutines are cheap" is true, but not unconditionally. A goroutine that
sleeps or waits on a socket costs a few kilobytes. A goroutine stuck in a
blocking system call (a slow disk read, a cgo call, an NFS stall) pins a whole
OS thread, and the runtime creates more threads to keep running other
goroutines. Services have hit Go's 10,000-thread limit and crashed this way.
Knowing which waits are cheap explains thread counts in top, memory
overhead, and some very strange incidents.
How it actually works
The scheduler has three kinds of object (G, M, P, covered in the Go guide's scheduler chapter):
G goroutine your code; thousands to millions
M machine an OS thread (what `ps -L` and /proc/<pid>/status "Threads:" count)
P processor a slot allowed to run Go code; there are GOMAXPROCS of them
How a goroutine waits determines what happens to its thread:
| The goroutine is... | What the runtime does | Thread cost |
|---|---|---|
| sleeping, waiting on a channel or mutex | parks the G; the M runs other Gs | none |
| reading/writing a socket or pipe | parks the G and registers the fd with epoll (the netpoller); one thread waits for all of them | none |
in a blocking system call (file I/O, syscall.Read) |
the M blocks inside the kernel; the P is handed to another M, which may be new | one thread per blocked call |
| in cgo | same as a blocking system call | one thread per call |
strace -f -c ./yourprogram on any Go binary shows the runtime's own system
calls: futex (threads sleeping and waking), epoll_pwait (the netpoller),
clone (new threads), mmap (heap growth), rt_sigprocmask (signal handling).
Build it in Go (20 min) -- count the threads
//go:build linux
// threads: how many OS threads does a Go program really use? It depends on
// HOW goroutines wait. Run it and compare the three cases.
//
// go run ./threads
package main
import (
"fmt"
"net"
"os"
"runtime"
"strings"
"syscall"
"time"
)
func threads() string {
b, _ := os.ReadFile("/proc/self/status")
for _, l := range strings.Split(string(b), "\n") {
if strings.HasPrefix(l, "Threads:") {
return strings.TrimSpace(strings.TrimPrefix(l, "Threads:"))
}
}
return "?"
}
func report(label string) {
time.Sleep(500 * time.Millisecond) // let the runtime settle
fmt.Printf("%-52s goroutines=%-6d OS threads=%s\n", label, runtime.NumGoroutine(), threads())
}
func main() {
report("start")
// 1. 10,000 goroutines in time.Sleep: parked by the Go scheduler, no thread each.
for i := 0; i < 10000; i++ {
go time.Sleep(time.Hour)
}
report("+10,000 goroutines sleeping")
// 2. 10,000 goroutines blocked reading network sockets: parked in the
// netpoller (one epoll instance), still no thread each.
ln, _ := net.Listen("tcp", "127.0.0.1:0")
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go func() { buf := make([]byte, 1); c.Read(buf) }() // waits forever
}
}()
var conns []net.Conn
for i := 0; i < 5000; i++ { // 5,000 client + 5,000 server goroutines
c, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
fmt.Println("dial:", err)
break
}
conns = append(conns, c)
go func() { buf := make([]byte, 1); c.Read(buf) }()
}
report("+10,000 goroutines blocked on socket reads")
// 3. 100 goroutines blocked in a RAW blocking system call: each one pins
// an OS thread, and the runtime starts new threads to keep running Go code.
for i := 0; i < 100; i++ {
var p [2]int
syscall.Pipe(p[:])
go func() { buf := make([]byte, 1); syscall.Read(p[0], buf) }() // blocks the thread itself
}
report("+100 goroutines blocked in raw read(2) syscalls")
runtime.KeepAlive(conns)
}
Real output (Linux, 14 CPUs):
start goroutines=1 OS threads=5
+10,000 goroutines sleeping goroutines=10001 OS threads=16
+10,000 goroutines blocked on socket reads goroutines=20002 OS threads=16
+100 goroutines blocked in raw read(2) syscalls goroutines=20102 OS threads=108
What to notice:
- 20,000 goroutines, 16 threads. Sleeping and network-blocked goroutines cost no threads. Ten thousand idle TCP connections are cheap in Go, which is why it's popular for proxies and gateways.
- 100 blocking system calls, +92 threads. Each raw
read(2)on a pipe that has no data pins its thread inside the kernel. (Go'sos.Fileputs pipes in the netpoller too. We usedsyscall.Readto bypass it.) - Regular files can't use epoll on Linux, so
os.ReadFileon a slow disk or network filesystem blocks a thread for real. A thousand goroutines reading from a stalled NFS mount means about a thousand threads. The runtime crashes the program at 10,000 (runtime/debug.SetMaxThreads).
Exercises:
- Run any lab under
strace -f -c -o trace.txtand read the summary. Which system call is called most? - Run
GODEBUG=schedtrace=1000 ./threadsand read the scheduler's once-a-second report:gomaxprocs,threads,idleprocs,runqueue. - Lower the limit with
debug.SetMaxThreads(50)and rerun. What happens at the syscall step?
Real-world example
A log-shipping service written in Go read thousands of files from an NFS
share, one goroutine per file. When the NFS server stalled, every read blocked
its thread, the thread count climbed past 10,000, and the process died with
runtime: program exceeds 10000-thread limit. The fix was a semaphore (a
buffered channel of capacity 64) around file reads: bounded concurrency for
blocking work.
Common mistakes
- Assuming every goroutine is cheap, including those blocked in file I/O or cgo.
- Unbounded fan-out over blocking operations. Use a worker pool or semaphore.
- Reading a high thread count in
topas a leak without checking what the threads are blocked in (cat /proc/<pid>/task/*/stackas root, or a goroutine profile).
Check yourself
- What are G, M, and P?
- Why don't 10,000 goroutines waiting on sockets need 10,000 threads?
- Why can a slow disk create thousands of threads in a Go program?
Further reading
- Go guide, Chapter 28: the scheduler deep dive and Chapter 53: goroutine stacks, revisited.
- "Scheduling In Go" (Ardan Labs, three-part series): the GMP model with diagrams.