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 withos/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 -nin the shell, you'll be wrong. Read/proc/<pid>/limitsfor the real value. - The
ReadDirof/proc/self/fdneeds 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 anEMFILEincident. Keep a little headroom. - Four descriptors belong to the runtime (epoll and its wakeup pipe/eventfd).
Exercises:
- Replace
os.Open("/dev/null")withnet.Dialto a local listener and count how many connections fit. Then setLimitNOFILE=in a systemd unit (Chapter 82) and check/proc/<pid>/limits. - Add a leak: open a file per HTTP request and "forget" to close it. Watch
ls /proc/<pid>/fd | wc -lclimb under load, then find the leak withlsof -p <pid>. - Use
ss -swhile running the TCP/IP guide'sclosewaitlab: each CLOSE_WAIT socket is a leaked descriptor.
Common mistakes
- Checking the shell's
ulimit -ninstead 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
- What's the difference between the soft and hard limits?
- What does Go do to
RLIMIT_NOFILEat startup, and for its children? - 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.