10 - Introspecting the System
📋 Jump to Takeaways🎁 How many file descriptors is your process holding right now? How much memory does the OS think it's using, and how close are you to a limit that will start failing your opens? The kernel keeps the answers in a set of virtual files, waiting for you to read them.
A good systems program can look at itself and the machine it runs on. This lesson covers the two windows into that state: the /proc filesystem and the resource-limit syscalls, plus how the Go runtime's own view differs from the OS view.
The /proc Filesystem
Linux exposes kernel and process state as a virtual filesystem mounted at /proc. The "files" there aren't on any disk; reading one asks the kernel for live data. Because they're ordinary files, you read them with the same os.ReadFile from File I/O Deep:
load, err := os.ReadFile("/proc/loadavg")
if err != nil {
log.Fatal(err)
}
fmt.Print(string(load)) // 0.42 0.31 0.28 1/523 40021Each process has a directory /proc/<pid>, and the special symlink /proc/self always points at the current process. Reading /proc/self/status gives a human-readable dump of your own state:
status, _ := os.ReadFile("/proc/self/status")
// contains lines like:
// VmRSS: 10240 kB resident memory
// FDSize: 64 file descriptor slots
// Threads: 7You parse the lines you care about. There's a file for almost everything: /proc/meminfo for system memory, /proc/<pid>/fd/ for open descriptors, /proc/cpuinfo for processors.
Knowing Your Own Process
Before reading /proc/<pid>, you need the pid. Go gives you the basics directly, and /proc/self saves you from even needing them most of the time:
fmt.Println(os.Getpid()) // 40021 this process
fmt.Println(os.Getppid()) // 1 the parent (often the init system)A parent pid of 1 usually means your process was started by the init system (systemd), which is exactly what you'd see for a service, a hint we'll build on in Writing a Daemon.
Resource Limits
Every process runs under limits: the maximum open files, memory, number of threads, and more. These are the rlimits, each with a soft limit (the current ceiling) and a hard limit (the max the soft limit may be raised to). Read one with syscall.Getrlimit:
var lim syscall.Rlimit
syscall.Getrlimit(syscall.RLIMIT_NOFILE, &lim)
fmt.Printf("open files: soft=%d hard=%d\n", lim.Cur, lim.Max)
// open files: soft=1024 hard=1048576RLIMIT_NOFILE is the one that bites network servers: it caps how many descriptors you can hold, which ties straight back to "descriptors are limited" from The OS Interface. A server expecting many connections raises the soft limit toward the hard limit at startup:
lim.Cur = lim.Max // raise soft limit up to the hard ceiling
if err := syscall.Setrlimit(syscall.RLIMIT_NOFILE, &lim); err != nil {
log.Fatal(err)
}
fmt.Println("raised open-file limit to", lim.Cur)An unprivileged process can raise its soft limit up to the hard limit, but only a privileged one can raise the hard limit itself.
Measuring Resource Usage
To ask "how much CPU and memory have I actually used," call syscall.Getrusage. It fills a struct with accumulated statistics for the process:
var ru syscall.Rusage
syscall.Getrusage(syscall.RUSAGE_SELF, &ru)
fmt.Println("user CPU (s):", ru.Utime.Sec) // time on the CPU in user mode
fmt.Println("sys CPU (s):", ru.Stime.Sec) // time in kernel (syscalls)
fmt.Println("peak RSS:", ru.Maxrss) // high-water memoryUtime and Stime split your CPU time into user-mode work versus time spent inside syscalls. Maxrss is peak RSS (resident set size), the physical RAM the process is actually holding, as opposed to memory it reserved but hasn't touched. Watch the units, though: it's kilobytes on Linux and bytes on macOS, a classic cross-platform gotcha.
Runtime View Versus OS View
Go's runtime package reports the same-looking numbers, but from a different vantage point. runtime.ReadMemStats describes the Go heap the garbage collector manages, not the whole process image the OS sees:
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Println("Go heap in use:", m.HeapInuse) // Go-managed heap only
fmt.Println("goroutines:", runtime.NumGoroutine())The distinction matters. Maxrss from getrusage includes the runtime, stacks, and mapped files; HeapInuse is only the Go heap. When you're debugging a leak, ask which one is growing: a rising Go heap points at your allocations, while a rising RSS with a flat heap points at something outside the GC's reach (cgo, mmap, thread stacks). mmap maps a file or a block of memory straight into your address space, so it counts against the process but never passes through the Go heap. For deep allocation and CPU analysis, reach for the profiler in Profiling & Benchmarks.
Counting CPUs
Two runtime functions report parallelism, and they can differ. runtime.NumCPU is how many cores the OS offers; runtime.GOMAXPROCS(0) is how many the scheduler will actually use in parallel:
fmt.Println(runtime.NumCPU()) // 8 cores the OS reports
fmt.Println(runtime.GOMAXPROCS(0)) // 8 cores Go will schedule ontoThey usually match, but in a container with a CPU quota you may want to set GOMAXPROCS lower than NumCPU so the scheduler matches the CPU you're actually allotted, which /proc and cgroup files can tell you. cgroups are the Linux feature that caps how much CPU and memory a container gets, and the kernel exposes those caps as files you can read.
Key Takeaways
/procis a virtual filesystem of live kernel/process state read with ordinaryos.ReadFile. Use/proc/selffor the current process;/proc/loadavg,/proc/meminfo,/proc/<pid>/status,/proc/<pid>/fd/are common targets.os.Getpid()/os.Getppid()give your pid and parent pid (a ppid of 1 means the init system started you).syscall.Getrlimit/Setrlimitread and change resource limits; each has a soft (Cur) and hard (Max) value.RLIMIT_NOFILEcaps open descriptors; raiseCurtowardMaxfor busy servers.syscall.Getrusage(RUSAGE_SELF, &ru)reports CPU time (Utime/Stime) and peak memory (Maxrss, in KB on Linux, bytes on macOS).runtime.ReadMemStatsshows the Go heap only;getrusage'sMaxrssshows the whole process. A rising heap points at Go allocations; rising RSS with a flat heap points outside the GC.runtime.NumCPU()(cores available) can differ fromruntime.GOMAXPROCS(0)(cores Go uses), which matters in CPU-limited containers.
🎁 You've been reading data into a buffer and writing it back out, and every one of those trips copies bytes across the kernel boundary. What if you could send a file straight to a socket without the data ever entering your program at all?