Frontend Labs
⋯

Chapter 78 -- File descriptors and the netpoller: holding thousands of connections

In one sentence

Every socket, file, and pipe is a file descriptor, the per-process limit (RLIMIT_NOFILE) caps how many a server can hold, and Go's netpoller lets one OS thread wait on all of them with epoll.

Why this matters

"Too many open files" (EMFILE) is the classic failure of a busy server or a leaky one: every new connection fails while the existing ones keep working. Chapter 43 introduced ulimit -n. Go adds a twist that confuses people comparing a Go service with the shell that started it.

How it actually works

  • Two limits: the soft limit is what's enforced, and the hard limit is the ceiling an unprivileged process can raise its soft limit to.
  • Go raises its own soft limit at startup to the hard limit minus one (since Go 1.19; the Go source explains that the −1 lets Go notice if another process later changes its limit with prlimit). Go then restores the original soft limit for any program it starts with os/exec, so old software that breaks above 1,024 descriptors isn't affected.
  • epoll: the runtime registers every network descriptor with one epoll instance. A dedicated wait (epoll_pwait) wakes exactly the goroutines whose sockets are ready. That's why Chapter 73 measured 10,000 socket-blocked goroutines on 16 threads.

Build it in Go (15 min) -- hit EMFILE on purpose

//go:build linux

// fdlimit: every socket and file is a file descriptor, and the per-process
// limit (RLIMIT_NOFILE, `ulimit -n`) caps how many connections a server can
// hold. Go quietly raises the soft limit to the hard limit at startup.
//
//	docker run --rm --ulimit nofile=256:2048 -v "$PWD/bin":/b debian:bookworm /b/fdlimit
package main

import (
	"errors"
	"fmt"
	"os"
	"syscall"
)

func main() {
	var rl syscall.Rlimit
	syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rl)
	fmt.Printf("RLIMIT_NOFILE as Go sees it: soft=%d hard=%d\n", rl.Cur, rl.Max)
	fmt.Println("(Go raised the soft limit to hard-1 at startup; programs it execs get the original back)")

	var files []*os.File
	for {
		f, err := os.Open("/dev/null")
		if err != nil {
			fmt.Printf("open #%d failed: %v\n", len(files)+1, err)
			fmt.Println("EMFILE?", errors.Is(err, syscall.EMFILE), "-- 'too many open files'")
			break
		}
		files = append(files, f)
	}
	files[0].Close() // reading /proc/self/fd needs one free descriptor itself
	fds, _ := os.ReadDir("/proc/self/fd")
	fmt.Printf("descriptors open: %d (%d files + stdin/out/err + the runtime's epoll and wakeup fds)\n", len(fds), len(files)-1)
}
$ docker run --rm --ulimit nofile=256:2048 -v "$PWD/bin":/b debian:bookworm sh -c 'ulimit -Sn; /b/fdlimit'
256                                                         <- the shell's soft limit
RLIMIT_NOFILE as Go sees it: soft=2047 hard=2048            <- Go raised it: hard - 1
(Go raised the soft limit to hard-1 at startup; programs it execs get the original back)
open #2042 failed: open /dev/null: too many open files
EMFILE? true -- 'too many open files'
descriptors open: 2047 (2040 files + stdin/out/err + the runtime's epoll and wakeup fds)

What to notice:

  • The shell said 256, and Go got 2,047. If you size a Go service by ulimit -n in the shell, you'll be wrong. Read /proc/<pid>/limits for the real value.
  • The ReadDir of /proc/self/fd needs a descriptor too. The first draft of this lab printed "0 open" because every descriptor was in use, so even the diagnostic failed. In production, logging libraries that open files hit the same wall during an EMFILE incident. Keep a little headroom.
  • Four descriptors belong to the runtime (epoll and its wakeup pipe/eventfd).

Exercises:

  1. Replace os.Open("/dev/null") with net.Dial to a local listener and count how many connections fit. Then set LimitNOFILE= in a systemd unit (Chapter 82) and check /proc/<pid>/limits.
  2. Add a leak: open a file per HTTP request and "forget" to close it. Watch ls /proc/<pid>/fd | wc -l climb under load, then find the leak with lsof -p <pid>.
  3. Use ss -s while running the TCP/IP guide's closewait lab: each CLOSE_WAIT socket is a leaked descriptor.

Common mistakes

  • Checking the shell's ulimit -n instead of the running process's /proc/<pid>/limits.
  • Raising the limit to hide a descriptor leak.
  • Forgetting that every client connection and every upstream connection a proxy holds counts.

Check yourself

  1. What's the difference between the soft and hard limits?
  2. What does Go do to RLIMIT_NOFILE at startup, and for its children?
  3. How does the netpoller avoid one thread per connection?

Further reading

  • man 2 getrlimit, man 7 epoll.
  • The comment at the top of Go's src/syscall/rlimit.go (go.dev/issue/46279) explains the startup adjustment.

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