Frontend Labs
⋯

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, and docker 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's os.File puts pipes in the netpoller too. We used syscall.Read to bypass it.)
  • Regular files can't use epoll on Linux, so os.ReadFile on 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:

  1. Run any lab under strace -f -c -o trace.txt and read the summary. Which system call is called most?
  2. Run GODEBUG=schedtrace=1000 ./threads and read the scheduler's once-a-second report: gomaxprocs, threads, idleprocs, runqueue.
  3. 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 top as a leak without checking what the threads are blocked in (cat /proc/<pid>/task/*/stack as root, or a goroutine profile).

Check yourself

  1. What are G, M, and P?
  2. Why don't 10,000 goroutines waiting on sockets need 10,000 threads?
  3. Why can a slow disk create thousands of threads in a Go program?

Further reading

Generated from real-life-os-guide.md · 8 Oct 2026 16:36