Skip to content
Software Atlas

System Calls

Your program can't touch the disk, the network or the clock's hardware: it has to ask the kernel. What one request really does, what it costs, how to watch them with strace, and how epoll, kqueue and io_uring let one thread juggle thousands of connections.

⏱ ~45 min read14 sections10 interactive widgetsGo · JavaScript · Linux
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

Your program has never read a file. Not once. It has only ever asked the operating system to read one for it, and waited for the answer.

In one sentenceA system call is your program asking the kernel to do something only the kernel is allowed to do: read a file, send bytes over the network, start a thread, sleep.

The kernel is the core of the operating system (Linux, macOS, Windows). It owns the hardware: the disk, the network card, memory, the CPU's time. Every other program, including yours, runs in user space, a restricted zone where touching hardware directly is simply not possible.

Here is the whole picture, top to bottom. Your code rarely writes the slip itself: the language runtime (Node's libuv, the C library Node uses for all its I/O; or the Go runtime) fills it in for you when you call fs.readFile or os.ReadFile.

Who does what when your program reads a file
Your codefs.readFile(…) · os.ReadFile(…)🧑 the customer
Language runtimeNode + libuv · the Go runtime📝 fills in the slip
system call: the teller window
Kernelfiles · network · memory · scheduling🏦 the teller + vault staff
Hardwaredisk · network card · RAM · CPU🔐 the vault
Everything above the dashed line runs in user mode and can only ask. Everything below it runs in kernel mode and does the actual work.

Before reading further, test your instinct. Which of these can your program do on its own?

Does this need the kernel?
For each action, guess: can your program do it alone, or must it ask the kernel (a system call)?
Add two numbers
Read a file
Sort an array of 1,000 numbers
console.log("hi") / fmt.Println("hi")
Send an HTTP request
Read the current time (Date.now / time.Now)
Start a goroutine
Sleep for 1 second
Answered 0 / 8
1

Every I/O is a syscall

Files, sockets, printing, sleeping, starting threads. Pure computation (math, sorting, building objects) usually isn't.
2

Crossing costs time

About 0.1 µs per call (a µs, microsecond, is a millionth of a second): roughly 100× an ordinary function call, up to ~1 µs on older or heavily patched machines. Fine once; noticeable a million times a second.
3

Waiting is the big deal

A call that has to wait (for data, for the disk) can put a whole thread to sleep. epoll, kqueue and io_uring exist so one thread can wait on thousands of things at once.

The rest of this topic follows that list: why the wall exists (§02), what one request does mechanically (§03), what it costs (§04), how to watch your programs make them (§05), and then how waiting is done well (§06 – §10), and how to run all this in production (§11).

Foundations§02

User mode vs kernel mode: why your code can't touch the disk

In one sentenceThe wall between your program and the hardware isn't a rule programmers agree to follow. It's built into the CPU: in user mode, the instructions that touch hardware simply don't work.

Why have a wall at all?

Imagine there were no wall. Any program could send commands straight to the disk controller. Then:

  • A bug in one program could wreck the whole machine. A buggy loop writing to the wrong spot on disk could overwrite another app's files, or the operating system itself.
  • Programs could spy on each other. Your browser could read your password manager's memory.
  • Nobody could share fairly. One program could keep the CPU forever and never let anything else run.

So the CPU has (at least) two privilege modes, a setting the CPU checks on every instruction. In kernel mode, every instruction is allowed. In user mode, the privileged instructions (talk to a device, change which memory a program can see, stop the CPU from being interrupted — see the table below) are refused: the CPU stops your program and hands control to the kernel, which usually kills it.

Rings

x86 CPUs (Intel and AMD) call these levels protection rings. The kernel runs in ring 0, your programs in ring 3:

Protection rings on an x86 CPU
Ring 0kernelring 1 (unused)ring 2 (unused)Ring 3 · appssyscall: the one door in
Ring 0 (kernel mode)
May run every instruction: talk to devices, change memory maps, switch threads.
Ring 3 (user mode)
Ordinary instructions only. A forbidden one makes the CPU stop and jump into the kernel, which normally kills the program.
The ways in
A syscall (your program asks), a hardware interrupt (a device needs attention), or a fault (your program did something illegal).
Rings 1 and 2 exist on paper but mainstream operating systems don't use them. ARM CPUs have the same idea under another name (EL0 for apps, EL1 for the kernel).

Notice the bottom box: there are only three ways for the CPU to go from your code into the kernel.

Way inWho starts itExample
System callYour program, on purpose“read 4 KB from fd 3” (an fd, file descriptor, is the small number the kernel gives you for an open file or connection)
Hardware interruptA devicethe network card: “a packet arrived”
Fault (exception)Your program, by accidenttouching memory that isn't yours → segfault

Two of these the program didn't ask for. A timer interrupt (a hardware interrupt from the CPU's built-in alarm clock) is how the scheduler, the part of the kernel that decides which thread runs next, takes the CPU back from a thread (Threads & Processes §04). The syscall is the only door your program opens deliberately, so it's the subject of this whole topic.

Same thread, different mode

One point trips almost everyone up. During a syscall, your thread keeps running, just in kernel mode. The kernel doesn't hand your request to some other kernel worker. Your thread walks through the window, does the vault work with the kernel's code and permissions, and walks back out.

That's why a syscall isn't a context switch (swapping one thread off the CPU for another; see Threads & Processes §05). It's a mode switch within the same thread, which is much cheaper. A context switch only happens if the kernel decides the thread must wait, say for data that hasn't arrived. Keep that difference in mind: §04 puts numbers on both (the context-switch figure comes from Threads & Processes).

Practitioner§03

One syscall, step by step

In one sentenceA syscall is a numbered request: put the service number and the arguments in CPU registers, execute one special instruction, and read the answer from a register when you're back.

On the bank slip, the first box says which service you want. In a syscall that's the syscall number. Linux on x86-64 has about 450 of them. A few you'll meet constantly:

NumberNameWhat it does
0readcopy bytes from an fd into your memory
1writecopy bytes from your memory to an fd
3closegive an fd back
39getpid“what's my process ID?”
232epoll_waitsleep until one of many fds is ready (§07)
257openatopen a file, get an fd

(Recall from §02: an fd, or file descriptor, is the small number the kernel gives you for an open file or connection: fd 1 is the terminal output. The File Descriptors topic goes deep on them.)

The arguments go in other registers (registers are the CPU's handful of tiny built-in storage slots). Why registers and not just “call a function”? Because the kernel's code isn't in your program. You can't jump to it. Registers are the one thing both sides can see at the moment of crossing.

Step through a write of three bytes to the terminal:

One write() syscall, step by step
Linux on x86-64. Step through with ← →.
Code
1msg := []byte("hi\n")
2n, err := os.Stdout.Write(msg)
3
4// Inside the Go runtime this becomes, roughly:
5// RAX ← 1 which service? (1 = write)
6// RDI ← 1 which fd? (1 = stdout)
7// RSI ← &msg[0] where are the bytes?
8// RDX ← 3 how many bytes?
9// SYSCALL the CPU instruction
10// n = RAX result: count, or -errno
11
12fmt.Println(n, err) // 3 <nil>
CPU mode
USER MODE ring 3 · your process
CPU registersthe request
not loaded yet
Kernelruns on your thread
not involved
Your code calls os.Stdout.Write(msg). So far this is an ordinary Go function call, running in user mode: the CPU will refuse any instruction that touches hardware directly.
1 / 8

Return values and errno

Every syscall returns one number in RAX. Zero or positive means success (often a count: bytes read, bytes written, the new fd). A small negative number means failure, and its value is the errno:

errnoNumberMeaningCommon cause
ENOENT2No such file or directorywrong path
EINTR4Interrupted system calla signal arrived while waiting (§11)
EBADF9Bad file descriptorusing an fd after close
EAGAIN11Resource temporarily unavailablenon-blocking read, no data yet (§06)
EACCES13Permission deniedfile mode, or a sandbox
EMFILE24Too many open filesan fd leak (File Descriptors)

Careful: you'll see the same failure written in different ways. The kernel returns -errno in RAX (-2 for ENOENT). Wrappers split it in two: C's libc and Go's raw syscall package return -1 (“failed”) and put the code in a separate errno / err. strace prints it the C way (= -1 ENOENT), and Node's libuv keeps the kernel's form (errno: -2). Click through one failure:

One failure, four spellings
Opening /nope.txt fails. Follow the same error up through each layer.
The kernel's raw answer is a single number in RAX: minus the errno. 2 is ENOENT, so failure looks like -2. Success would be ≥ 0 (here, the new fd).
1 / 5

Your language converts this for you. Same failure, both sides:

errno.go
_, err := os.Open("/nope.txt")
fmt.Println(err)                          // open /nope.txt: no such file or directory
fmt.Println(errors.Is(err, fs.ErrNotExist)) // true: portable check
fmt.Println(errors.Is(err, syscall.ENOENT)) // true: the raw errno is inside

Who actually executes the instruction?

You almost never write SYSCALL yourself. Something in between fills in the slip:

  • C programs and Node go through libc (the standard C library), whose small wrapper functions like read() load the registers and run the instruction. Node's libuv calls those wrappers.
  • Go on Linux skips libc: the Go runtime has its own wrappers written in assembly, which is part of why Go binaries are easy to copy between Linux machines. (On macOS, Go does call the system library, because Apple only supports syscalls through it.)
Practitioner§04

What a syscall costs, measured, and the vDSO shortcut

In one sentenceOn this machine a do-nothing syscall costs about 90 ns, a plain function call about 1 ns. Reading the clock costs about 50 ns and never enters the kernel at all, thanks to the vDSO.

Numbers beat adjectives, so let's measure. A microbenchmark runs one tiny operation millions of times and divides. We'll compare four things in Go:

bench_test.go
package bench

import (
	"os"
	"testing"
	"time"
)

//go:noinline
func add(a, b int) int { return a + b }

func BenchmarkFuncCall(b *testing.B) {
	x := 0
	for b.Loop() {
		x = add(x, 1) // a normal function call: no kernel
	}
	_ = x
}

func BenchmarkGetpid(b *testing.B) {
	for b.Loop() {
		os.Getpid() // ← a real getpid syscall, every time
	}
}

func BenchmarkTimeNow(b *testing.B) {
	for b.Loop() {
		time.Now() // ← reads the clock via the vDSO
	}
}

func BenchmarkWrite1(b *testing.B) {
	f, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
	if err != nil {
		b.Fatal(err) // stop this benchmark with a clear message
	}
	defer f.Close()
	one := []byte("x")
	for b.Loop() {
		if _, err := f.Write(one); err != nil { // ← a write syscall of 1 byte
			b.Fatal(err)
		}
	}
}

Real output, Go 1.24 on a 4-core 2.3 GHz Xeon virtual machine, Linux 6.18 (typical lines from three runs, which varied by about ±10%):

go test -bench . (on this machine)
BenchmarkFuncCall-4    1000000000      1.000 ns/op
BenchmarkGetpid-4        13093461     88.19 ns/op
BenchmarkTimeNow-4       23502009     52.17 ns/op
BenchmarkWrite1-4         6160263    192.3 ns/op
node bench.mjs (on this machine)
performance.now()        58 ns/op
fs.writeSync(1 byte)     352 ns/op

Put side by side (log scale, so each step right is 10× more):

What one call costs (on this machine)
Go 1.24 and Node 22 on a 4-core 2.3 GHz Xeon VM, Linux 6.18. Log-scale bars: each grid step is 10× slower. Your numbers will differ.
Operation≈ ns / call
Go: plain function call1
Go: time.Now()48
Node: performance.now()60
Go: os.Getpid()90
Go: write 1 byte to /dev/null190
Node: fs.writeSync 1 byte380
Thread context switch2,000
A real syscall is roughly 100× a function call. The vDSO rows are clock reads that never enter the kernel. The context switch is there for scale: it's what happens when a syscall has to wait.

Reading the numbers

  • getpid ≈ 90 ns is the price of the crossing itself: the kernel does almost no work for it. That's ~100 function calls' worth.
  • write ≈ 190 ns in Go, ≈ 350–400 ns from Node. The kernel does real work (check the fd, copy the byte), and Node adds its own layers: JS → C++ binding → libuv → libc.
  • None of these slept. No thread switch happened. If a syscall must wait, add a context switch (microseconds) plus however long the wait itself lasts.

Worked example. A Go service handling 50,000 requests a second makes ~20 small syscalls per request (read the request, a couple of writes, some epoll calls, logging). That's 1,000,000 syscalls/s × ~0.2 µs = 0.2 s of CPU per second: 20% of a core, just on crossings. Worth knowing, rarely worth panicking about. Cut the count where it's easy (§09).

Your machine will differ. Typical published ranges are ~50 ns to ~1 µs per simple syscall, depending on the CPU and especially on the kernel's security mitigations. After the 2018 Meltdown and Spectre disclosures, kernels added extra work to every crossing on affected CPUs. On some older Intel chips that roughly doubled the cost of a cheap syscall. Newer CPUs fix much of it in hardware. (This VM's CPU reports “Meltdown: not affected”.)

The vDSO: the kernel's answer booth on your side of the glass

Why is time.Now() cheaper than getpid(), when both “ask the OS” something? Because the kernel cheats, nicely. It maps a tiny read-only library, the vDSO, into every process. It holds code for a few questions whose answers the kernel keeps up to date in shared memory, mainly “what time is it?” (clock_gettime, gettimeofday). Calling it is a normal function call: no mode switch.

You can see the vDSO in any process's memory map, and prove the clock calls never reach the kernel:

Linux · real output
$ grep vdso /proc/self/maps
7f386e974000-7f386e976000 r-xp 00000000 00:00 0      [vdso]

$ strace -c -f ./vdso-demo      # calls time.Now() 1,000× and os.Getpid() 1,000×
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 18.25    0.000695           0      1000           getpid
   …                                                (no clock_gettime line at all)

strace (§05) counted 1,000 getpid calls and zero clock calls. (Go doesn't remember your process ID between calls; each os.Getpid() really asks.)

Practitioner§05

Watching syscalls with strace

In one sentencestrace prints every syscall a program makes, with its arguments and result. It's the closest thing to watching your program talk to the kernel.

strace is a Linux tool (install it with your package manager, e.g. apt install strace). Point it at a command, and every syscall comes out on stderr in a C-like format:

anatomy of one strace line
read(3, "hello, kernel\n", 512) = 14
│    │  │                 │      └─ return value (bytes read; -1 plus an errno on failure)
│    │  │                 └─ argument: at most 512 bytes, please
│    │  └─ argument: where the data went (strace shows what landed there)
│    └─ argument: fd 3
└─ syscall name

The flags you'll use most:

CommandWhat it does
strace ./apptrace only the first thread: you'll miss most of what a Go or Node program does
strace -f ./appalso follow every thread and child process (always use this for Go and Node: both are multi-threaded)
strace -e trace=openat,read,write ./apponly show these syscalls
strace -e trace=%network ./apponly network-related ones (%file, %process also work)
strace -c -f ./appdon't print lines; print a summary table at the end
strace -T ./appshow the time spent inside each call
strace -p 4120 -fattach to an already-running process (PID 4120)

Two tiny programs, traced

Both programs read a 14-byte file, make one HTTP request to a local server, and print a line. Real output from Go 1.24 and Node 22 (libuv 1.51) on Linux 6.18, captured with strace -f -e trace=… and trimmed for reading. Click through it:

strace explorer
Real output, trimmed: thread ids replaced, long arguments shortened, some lines omitted. Click a syscall to see which line of code caused it.
main.go
1func main() {
2 data, err := os.ReadFile("hello.txt")
3 if err != nil {
4 panic(err)
5 }
6 resp, err := http.Get("http://127.0.0.1:18573/")
7 if err != nil {
8 panic(err)
9 }
10 body, _ := io.ReadAll(resp.Body)
11 resp.Body.Close()
12 fmt.Println(len(data), resp.Status, len(body))
13}
strace -f output (20 lines)
openat(AT_FDCWD, "hello.txt", O_RDONLY|O_CLOEXEC) = 3
Open the file. The kernel answers with file descriptor 3, the lowest free number (0, 1, 2 are stdin, stdout, stderr). O_CLOEXEC = don't leak this fd into child processes.

What jumps out:

  1. Your code caused syscalls it never mentioned. Go created an epoll instance; Node set up an io_uring. Runtimes do work on your behalf.
  2. Sockets are non-blocking (SOCK_NONBLOCK) in both, and both runtimes happily get EAGAIN and EINPROGRESS “errors”. Those aren't failures; they're how waiting is done without blocking a thread (§06).
  3. Files are handled differently from sockets. Go's epoll_ctl on the file failed with EPERM; Node never even tried, and sent the file work to helper threads. §08 explains why.

The summary view: strace -c

For a quick “what is this program spending its syscalls on”, -c beats reading lines. The same Go client, whole run (real output, trimmed to the interesting rows):

strace -c -f ./gocli (Go 1.24)
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
  0.00    0.000000           0       114           rt_sigaction
  0.00    0.000000           0        39           nanosleep
  0.00    0.000000           0        30           futex
  0.00    0.000000           0        23           mmap
  0.00    0.000000           0         7         2 read
  0.00    0.000000           0         3         1 epoll_ctl
  0.00    0.000000           0         3           epoll_pwait
  0.00    0.000000           0         1         1 connect
   …
------ ----------- ----------- --------- --------- ----------------
100.00    0.000000           0       292         4 total

292 syscalls for a program that “reads a file and makes one request”. Most are the runtime starting up: rt_sigaction is Go going through all ~64 signal numbers, reading each one's current handler and then installing its own for most of them, mmap asks for memory, futex and nanosleep are threads coordinating. The same Node program made 833, most of them while booting V8 and loading modules. The errors column counts the EAGAIN/EPERM/EINPROGRESS results, which were all expected.

Practitioner§06

Blocking vs non-blocking I/O

In one sentenceA blocking call makes your thread wait inside the kernel until the work can be done. A non-blocking call never waits: if it can't make progress right now, it returns at once with EAGAIN, meaning “try again later”.

Blocking: the default

By default, a read on a socket with no data yet is a blocking call. The kernel can't hand you bytes that haven't arrived, so it does the polite thing: it marks your thread as waiting, puts it on the socket's wait queue (a list of threads waiting on it), and runs some other thread on the CPU. When a packet arrives, the network card raises a hardware interrupt, the kernel files the data under the socket, sees your thread on the list and wakes it up. Your read returns as if no time had passed. Step through it:

A blocking read(): sleep and wake
One CPU core, two threads, one socket. Step through with ← →.
CPU corewho's running
Thread A user mode · running your code
Network card
nothing arriving
Socket 7 · wait queuesleeping threads
nobody waiting
Socket 7 · receive bufferkernel memory
empty
Thread A is running on a CPU core. It needs the next message from a socket (fd 7) and calls read(7, …).
1 / 8

For one connection, this is perfect: simple code, zero CPU used while waiting. The trouble starts with many connections. One thread can only sit in one chair, so the classic server gives every connection its own thread. At 10,000 connections that's 10,000 threads, each reserving stack memory and each wake-up costing a context switch. Async / Await §03 tells this story (the “C10K problem”) and Threads & Processes §04 shows what all those sleeping threads cost.

Non-blocking: “don't make me wait”

Set the O_NONBLOCK flag on an fd and the kernel changes its answer. No data? It returns immediately with EAGAIN. Here it is with a pipe (a kernel-managed byte channel; it behaves like a socket for this purpose):

nonblock.go
var p [2]int // p[0] = read end, p[1] = write end
if err := syscall.Pipe(p[:]); err != nil {
	panic(err)
}
syscall.SetNonblock(p[0], true) // ← O_NONBLOCK: never put me to sleep

buf := make([]byte, 64)
n, err := syscall.Read(p[0], buf) // nothing has been written yet
fmt.Println(n, err, errors.Is(err, syscall.EAGAIN))
// -1 resource temporarily unavailable true

syscall.Write(p[1], []byte("hi"))
n, err = syscall.Read(p[0], buf)
fmt.Println(n, err, string(buf[:n]))
// 2 <nil> hi

There's no JavaScript version because JavaScript never gets this low: Node's libuv makes every socket non-blocking for you, and handles the EAGAINs you saw in the strace in §05.

So we stop waiting… and spin

Non-blocking alone doesn't solve anything. If read says “not yet”, what does the thread do next? The naive answer is to ask again, in a loop: busy polling. It reacts instantly but burns a whole CPU core on “not yet, not yet, not yet”, and with 10,000 sockets you'd be making 10,000 useless syscalls per lap.

The three options side by side:

One thread waiting for data on a socket, three ways
1 · Blocking read(): simple, but the thread is stuck
thread
read
asleep in read() … can't do anything else
data!
2 · Non-blocking read() in a loop: never stuck, but burns a core
thread
no
no
no
no
no
no
no
no
no
no
no
no
no
data!
3 · Non-blocking + a readiness wait: asleep until something is ready
thread
wait
epoll_wait: watching 1,000 sockets
#12
#480
asleep
#7
Gray hatching = the thread is asleep inside the kernel (no CPU used). Colored = the thread is running. “no” = read() returned EAGAIN.

What we want is option 3: non-blocking sockets, plus one call that sleeps until any of them is ready. That's I/O multiplexing, the subject of the next section.

Practitioner§07

select, poll, epoll: one thread watching thousands

In one sentenceHand the kernel a list of fds once, then ask one question over and over: “which of these are ready?” The kernel sleeps your thread until the answer isn't empty, then returns only the ready ones. That's epoll.

Unix got this idea three times, each fixing the last one's scaling problem.

1983: select

select (C, for history)
fd_set readable;              // a bitmap: bit N = “watch fd N”
FD_ZERO(&readable);
FD_SET(sock1, &readable);     // you rebuild this before EVERY call
FD_SET(sock2, &readable);
select(maxfd + 1, &readable, NULL, NULL, NULL); // sleeps until ≥ 1 is ready
// on return, the bitmap was overwritten: test every bit to find the ready ones

Two problems. The fd_set bitmap has a fixed size, normally 1,024 bits, so a socket numbered 1024 or higher can't be watched at all (writing past the end is a classic memory-corruption bug). And every call does work proportional to all watched fds: you rebuild the bitmap, the kernel copies it in, checks each fd, copies it back, and you scan it.

1987: poll

poll replaced the bitmap with an array of {fd, events, revents} entries: no 1,024 limit. But it kept the second problem. Every call still sends the whole list in, and the kernel still checks every entry.

Worked example. A chat server holds 10,000 open connections; at any moment, about 10 have sent something. With poll, each wake-up copies 10,000 entries into the kernel, checks 10,000 sockets, copies 10,000 entries back, and your code scans 10,000 to find the 10. Do that 1,000 times a second and you're touching 10 million entries a second to do 10,000 units of real work.

2002: epoll

Linux's epoll (kernel 2.5.44) splits the job into three calls, so the list lives inside the kernel:

  1. epoll_create1() makes an epoll instance (itself an fd).
  2. epoll_ctl(ep, ADD, fd, events) adds an fd to its interest list. Once per fd, not once per wait.
  3. epoll_wait(ep, …) sleeps until the ready list is non-empty, then copies out only the ready entries.

The ready list is the trick. When a packet arrives on a watched socket, the kernel's own code (the part that handles the network card's interrupt) appends that socket to the ready list right then. So epoll_wait doesn't search anything: it hands over a list that's already built. Cost per call follows the number of ready fds, not watched ones.

Here's that loop in real Go, with pipes standing in for network connections:

epoll_demo.go (Linux only)
ep, err := syscall.EpollCreate1(0) // ① a new epoll instance (it's an fd too)
if err != nil {
	panic(err)
}

var pipes [3][2]int // [i][0] = read end, [i][1] = write end
for i := range pipes {
	syscall.Pipe(pipes[i][:]) // (errors ignored here and below for brevity)
	ev := syscall.EpollEvent{Events: syscall.EPOLLIN, Fd: int32(pipes[i][0])}
	syscall.EpollCtl(ep, syscall.EPOLL_CTL_ADD, pipes[i][0], &ev) // ② watch it
}

go func() { // somewhere else, data shows up on pipes 2 and 0
	time.Sleep(100 * time.Millisecond)
	syscall.Write(pipes[2][1], []byte("hello"))
	syscall.Write(pipes[0][1], []byte("hey"))
}()

events := make([]syscall.EpollEvent, 8)
for handled := 0; handled < 2; {
	n, err := syscall.EpollWait(ep, events, -1) // ③ sleep until something is ready
	if err == syscall.EINTR {
		continue // a signal interrupted the wait: just wait again (§11)
	}
	if err != nil {
		panic(err)
	}
	for _, e := range events[:n] { // ④ only the READY fds come back
		buf := make([]byte, 16)
		k, _ := syscall.Read(int(e.Fd), buf)
		fmt.Printf("fd %d ready: %q\n", e.Fd, buf[:k])
		handled++
	}
}
// fd 8 ready: "hello"
// fd 4 ready: "hey"

You'd never write this in a real Go program: the runtime's netpoller runs exactly this loop for you (§08). In Node, libuv runs it at the heart of the event loop.

Try it: interest list, ready list, and two triggering modes

Inside epoll: interest list and ready list
Click a socket to register or unregister it. Make packets arrive, then call epoll_wait().
Sockets (click = epoll_ctl ADD / DEL)
Interest list (set by epoll_ctl)
5 · 6 · 7 · 8
Ready list (kept by the kernel)
—
epoll_wait calls: 0 · fds handled: 0
poll() would check all 4 watched fds on every call; epoll just hands over the ready list.
epoll_create1() = 4 · epoll_ctl(ADD) for fd 5, 6, 7, 8

Things to try: unregister a socket and make packets arrive on it (nobody's told). Then switch to edge-triggered, tick “reads only 1 KB”, let 2 packets arrive on one socket and call epoll_wait twice.

Level-triggered vs edge-triggered

  • Level-triggered (the default): an fd is reported as long as it's ready. Read half the data, and the next epoll_wait reports it again. Forgiving and simple.
  • Edge-triggered (EPOLLET): an fd is reported when it becomes ready, i.e. when new data arrives. Read half the data and you will not hear about the rest until more arrives. In exchange, there are fewer repeat notifications, so the kernel and your loop do less work per wake-up.

The names come from electronics: a level is “the signal is high”, an edge is “the signal just went from low to high”. The rule for edge-triggered is non-negotiable: after each wake-up, read (or write) until you get EAGAIN. That's exactly why the Go strace in §05 ended with one more read returning EAGAIN: Go registers sockets with EPOLLET.

Other operating systems

LinuxmacOS / FreeBSDWindows
Watch many fdsepollkqueueselect / WSAPoll (rarely used)
Modelreadinessreadinesscompletion (IOCP)
One call can…waitregister changes and waitwait for finished operations

kqueue (FreeBSD 2000, macOS) is epoll's cousin: you pass a list of changes and collect events in the same kevent call, which saves syscalls, and it can also watch files changing, processes exiting, signals and timers. IOCP on Windows works differently: it tells you when an operation has finished, not when one could start. That difference turns out to matter a lot, and §10 picks it up with io_uring.

Practitioner§08

How Go and Node use all this

In one sentenceBoth runtimes do the same trick for the network: non-blocking sockets + one epoll loop. And both give up on it for files, because epoll can't help with files. Go spends a thread; Node sends the work to a thread pool.
Same request, different paths: where each runtime sends I/O on Linux
🐹 Go runtime
socket / pipe
netpoller (epoll)
goroutine parks; the thread runs other goroutines
regular file
blocking read() on the current thread
the runtime gives this thread's scheduling slot (a P) to another thread
time.Now()
vDSO
no syscall at all
🟩 Node (libuv)
socket / pipe
event loop (epoll)
main thread sleeps in epoll_pwait, runs callbacks
regular file
libuv thread pool
4 threads by default; done → eventfd wakes the loop
DNS lookup
libuv thread pool
getaddrinfo blocks, so it's a pool job too
Sockets and pipes can say “ready / not ready”, so both runtimes use epoll for them. Regular files are always “ready” to epoll, so files take a thread.

Go: the netpoller

You never see EAGAIN in Go code, yet the strace in §05 was full of them. Here's what happens when a goroutine calls conn.Read(buf) and no data has arrived:

  1. The runtime calls read on the non-blocking socket. Result: EAGAIN.
  2. Instead of returning that error to you, it parks the goroutine: sets it aside, remembering “wake me when fd 7 is readable”. The OS thread goes off to run other goroutines.
  3. The netpoller is an epoll instance with every socket registered once (edge-triggered). Whenever the scheduler has a moment, or a thread has nothing to do, it calls epoll_wait and marks the goroutines behind the ready fds as runnable.
  4. Your goroutine resumes, retries read, gets the data. To your code, conn.Read just took a while.

Async / Await §09 animates this with the scheduler's desks and workers. The point for this topic: one goroutine waiting costs a few KB of memory, not a thread. Here's the proof, 100 goroutines all waiting on pipes that never get data:

park_vs_block.go (complete program)
package main

import (
	"fmt"
	"os"
	"strings"
	"syscall"
	"time"
)

// threads returns this process's OS thread count, as the kernel reports it.
func threads() string {
	status, _ := os.ReadFile("/proc/self/status")
	for _, line := range strings.Split(string(status), "\n") {
		if strings.HasPrefix(line, "Threads:") {
			return strings.TrimSpace(strings.TrimPrefix(line, "Threads:"))
		}
	}
	return "?"
}

func main() {
	raw := len(os.Args) > 1 && os.Args[1] == "raw" // `go run . raw` picks the blocking version

	var writers []*os.File // keep the write ends alive, so the pipes stay open
	for i := 0; i < 100; i++ {
		r, w, err := os.Pipe() // nobody ever writes: every read waits forever
		if err != nil {
			panic(err)
		}
		writers = append(writers, w)
		go func() {
			buf := make([]byte, 1)
			if raw {
				syscall.Read(int(r.Fd()), buf) // ← blocking syscall: pins an OS thread
			} else {
				r.Read(buf) // ← netpoller: the goroutine parks, the thread moves on
			}
		}()
	}
	time.Sleep(500 * time.Millisecond)
	fmt.Println("100 goroutines waiting; OS threads:", threads())
	_ = writers // still in use here, so the GC can't close the write ends early
}
real output (Go 1.24, 4 cores)
$ go run .        # r.Read: through the netpoller
100 goroutines waiting; OS threads: 5
$ go run . raw    # raw blocking syscall
100 goroutines waiting; OS threads: 103

When a goroutine makes a syscall that really blocks (the second run), the runtime can't park it: the thread itself is stuck inside the kernel. So before every syscall that might block, the runtime notes “this thread is in a syscall”. If it stays there for more than a short while (sysmon checks every 20 µs when busy, up to every 10 ms when idle), a background monitor thread (sysmon) hands that thread's scheduling slot (the runtime calls it a P) to another thread so other goroutines keep running. Correct, but each stuck call costs a whole OS thread. A thousand slow blocking calls means a thousand threads, and Go stops the program once it reaches 10,000 threads (debug.SetMaxThreads).

Node: libuv's event loop and thread pool

Node's main thread runs the event loop (Async / Await §07). Under the hood, each lap includes one epoll_pwait call (the loop's poll phase), with a timeout until the next timer is due. The strace in §05 showed both halves:

  • Sockets are non-blocking and registered with epoll. Reads, writes, connect, accept all happen on the main thread, only when epoll says they won't block.
  • Files (and DNS lookups, some crypto, zlib) go to the libuv thread pool: 4 threads by default, set with the UV_THREADPOOL_SIZE environment variable (max 1,024). A pool thread makes the blocking call, then writes to an eventfd that epoll is watching, to wake the main thread with the result.

That pool is small and shared, and it's easy to fill. Real run: four slow password hashes occupy all four threads, and a 14-byte file read waits behind them:

Four hashes, one tiny file read, a 4-thread pool
Real timings from pool.mjs below (Node 22, 4 cores). Green = the file read's open/stat/read/close jobs.
pool 1
pbkdf2 #0
pool 2
pbkdf2 #1
pool 3
pbkdf2 #2
pool 4
pbkdf2 #3
readFile
queued: every pool thread is busy
0 ms50 ms100 ms150 ms
The read needs a pool thread, but all four are busy hashing. It waits in libuv's queue until the first hash finishes, then completes at 131 ms for 14 bytes.

The program behind it:

pool.mjs
import fs from "node:fs/promises";
import crypto from "node:crypto";

const t0 = performance.now();
const ms = () => Math.round(performance.now() - t0);

// pbkdf2 = deliberately slow password hashing (1,000,000 rounds); it runs on the pool
for (let i = 0; i < 4; i++) {          // fill all 4 pool threads
  crypto.pbkdf2("pw", "salt", 1_000_000, 32, "sha256", () => console.log(`hash ${i} done at ${ms()} ms`));
}
await fs.readFile("hello.txt");         // needs a pool thread too
console.log(`readFile done at ${ms()} ms`);
real output (Node 22, 4 cores)
$ node pool.mjs
hash 1 done at 129 ms
readFile done at 131 ms            ← a 14-byte read took 131 ms: it was queued
…
$ UV_THREADPOOL_SIZE=8 node pool.mjs
readFile done at 1 ms              ← a free thread was available
hash 1 done at 121 ms
…

DNS §07 shows the same trap with dns.lookup, which also uses this pool.

Why epoll can't do files

It's tempting to think “just make the file non-blocking and add it to epoll”. The kernel says no, and it's right to:

  • Readiness means “won't wait for someone else.” A socket isn't ready until the other machine sends something. A file on disk has no “someone else”: its data is always there, so the kernel considers regular files always ready. epoll_ctl refuses them outright with EPERM, which is exactly the failed line in the Go strace (§05). (O_NONBLOCK is silently ignored on regular files.)
  • But reading can still block. If the data isn't in the page cache (the kernel's memory copy of recently used file data), the read sleeps until the disk delivers, maybe 0.1 ms on an SSD, 10 ms on a spinning disk, much more on a network filesystem. Readiness can't predict that.

So on Linux, a readiness API can't make file I/O asynchronous. You need either a thread that's allowed to block (what Go and libuv do) or an API that runs the whole operation for you in the background. That second option is io_uring (§10).

Practitioner§09

Making fewer syscalls: buffering, batching, zero-copy

In one sentenceThe crossing has a fixed price, so carry more per trip. Collect small writes in your own memory and send them in one syscall. It's the single cheapest performance win in I/O code.

Buffering: the measurement

A program writes 100,000 short lines (“line 0”, “line 1”, …, about 1 MB in total) to a file, once directly and once through a buffer:

buf.go
package main

import (
	"bufio"
	"fmt"
	"os"
	"time"
)

func main() {
	buffered := len(os.Args) > 1 && os.Args[1] == "buffered"
	f, err := os.Create("out.txt")
	if err != nil {
		panic(err)
	}
	defer f.Close()

	start := time.Now()
	if buffered {
		w := bufio.NewWriter(f) // ← a 4,096-byte buffer in your memory
		for i := 0; i < 100_000; i++ {
			fmt.Fprintf(w, "line %d\n", i) // copies into the buffer: no syscall
		}
		if err := w.Flush(); err != nil { // ← sends what's left. Forget this and the tail is lost!
			panic(err)
		}
	} else {
		for i := 0; i < 100_000; i++ {
			fmt.Fprintf(f, "line %d\n", i) // ← one write() syscall per line
		}
	}
	fmt.Fprintln(os.Stderr, "took", time.Since(start).Round(time.Millisecond)) // ← 1 more write
}

Real results on this machine, with strace -c -e trace=write counting the calls:

Versionwrite syscallsTime (3 runs)
Go, unbuffered100,00141–65 ms
Go, bufio (4 KB)2676–7 ms
Node, writeSync per line100,00460–71 ms
Node, 64 KB chunks2112–15 ms

(1,088,890 bytes ÷ 4,096 ≈ 266 buffers, plus one write for the “took 7ms” line the program prints to stderr = 267. The Node counts include a few writes of runtime housekeeping.)

~7× faster in Go, ~5× in Node, from changing three lines. The kernel work of copying 1 MB didn't change; what disappeared was 100,000 crossings and 100,000 trips through each runtime's I/O layers. See how the count falls as the buffer grows:

Writing 100,000 short lines to a file
Pick a buffer size. Each colored group below is one write() syscall carrying those lines.
The first 60 lines → syscalls
Every line is its own write(): 60 lines, 60 trips to the kernel.
write() calls
100,000
estimated time
≈ 51 ms
Measured on this machine (Go): no buffer: 100,001 writes, 41–65 ms; bufio 4 KB: 267 writes, 6–7 ms. The estimate above uses those two runs; real time also depends on the disk and the page cache.

Node streams batch for you: writev

Node's writable streams (fs.createWriteStream, sockets, HTTP responses) already queue small writes while one is in flight, then send the queue with a single writev syscall. Real count for the same 100,000 out.write(…) calls on a file stream: 98 writev + 11 write, not 100,000. You get batching without writing any buffer code, as long as you write through a stream rather than calling fs.writeSync in a loop.

On a network socket you can ask for this explicitly: socket.cork() holds writes, socket.uncork() sends them together. Go's equivalent is a bufio.Writer around the connection, or net.Buffers (which uses writev too).

Batching other calls

The same idea shows up everywhere once you look for it:

  • Read in bigger chunks. bufio.NewReader / bufio.Scanner in Go read 4 KB+ at a time even if you consume a line at a time. Node streams read 64 KB chunks for files by default.
  • One epoll_wait, many events. Go asks for up to 128 ready fds per call, libuv for up to 1,024 (you saw both numbers in §05).
  • Database and Redis pipelining batch many commands into one network write: the same trade at a higher layer.

Zero-copy: let the kernel move it

Serving a file over HTTP the naive way: read 64 KB from the file into your memory, write those 64 KB to the socket, repeat. The bytes cross the boundary twice and you pay two syscalls per chunk. sendfile tells the kernel “copy from this file fd to that socket fd” and the data never enters your process: zero-copy.

Go does this automatically. io.Copy(conn, file) notices it has an *os.File and a TCP connection and uses sendfile. Real strace of copying a 1 MB file to a socket:

strace -f -e trace=sendfile,read,write ./serve
sendfile(4, 7, NULL, 2147483647) = 1000000     ← the whole megabyte in one call
sendfile(4, 7, NULL, 2147483647) = 0           ← 0 = end of file, done

Two syscalls for a megabyte, and no copies through Go's memory. http.ServeFile and http.FileServer get this for free over plain HTTP. (With HTTPS, the bytes must pass through your process to be encrypted, so sendfile can't be used.) Node's core doesn't expose sendfile: fs.createReadStream(path).pipe(res) does read/write in 64 KB chunks, which is fine for most apps; for heavy static files, put nginx (which uses sendfile) or a CDN in front.

Staff-level§10

io_uring: shared rings, fewer syscalls

In one sentenceepoll says “you can read now” and you make the call. io_uring lets you say “read this for me” and tells you when it's done. Requests and results travel through two rings of shared memory, so many operations cost one syscall, or none.

Two models: readiness and completion

Everything up to now used the readiness model: the kernel tells you an fd is ready, then you do the read or write yourself. Two weaknesses fell out of it:

  1. Every operation is still its own syscall. epoll saves the waiting, not the doing. 10,000 ready sockets still means 10,000 read calls.
  2. Files don't fit (§08). They're always “ready” yet can still block on the disk, so runtimes burn threads on them.

The completion model fixes both: you describe the whole operation (“read 4 KB from fd 7 into this buffer”) and the kernel tells you when it has finished, with the data already in place. Windows has worked this way for decades with IOCP. Linux got it in 2019 with io_uring (kernel 5.1).

The two rings

At setup, your program and the kernel share two ring buffers:

  • The submission queue (SQ): you write request entries (SQEs) at its tail: an opcode saying which operation (read, write, accept, send, open, fsync…), fd, buffer address, length, and a tag of your choice.
  • The completion queue (CQ): the kernel writes result entries (CQEs): your tag plus the result, which is the same number the classic syscall would have returned (bytes, or a negative errno).

Writing into the SQ and reading from the CQ are plain memory operations: no syscall. One io_uring_enter call says “I've added N entries” (and can also wait for completions). Try it:

io_uring: two rings in shared memory
Your program writes requests into one ring and reads results from the other. The kernel reads and writes the same memory.
Submission queue (SQ) you write · kernel reads
·
·
·
·
·
·
·
·
empty: queue something below
⚙ kernel working on:nothing
Completion queue (CQ) kernel writes · you read
·
·
·
·
·
·
·
·
empty: results appear here
Queue a few operations. Notice that queuing costs no syscall: it's just writing into memory the kernel can also see.
operations completed: 0 · syscalls used: 0 · the classic way (a readiness wait or thread-pool hand-off, plus the read/write itself): ≈ 0

The flow, in C with liburing (the small helper library most io_uring code uses), for illustration:

io_uring with liburing (C, illustrative)
struct io_uring ring;
io_uring_queue_init(256, &ring, 0);                 // io_uring_setup: create both rings

for (int i = 0; i < 3; i++) {                        // queue 3 reads: NO syscalls yet
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_read(sqe, fds[i], bufs[i], 4096, 0);
    io_uring_sqe_set_data(sqe, (void *)(long)i);     // our tag: which request is this?
}
io_uring_submit(&ring);                              // ONE io_uring_enter for all three

for (int done = 0; done < 3; done++) {
    struct io_uring_cqe *cqe;
    io_uring_wait_cqe(&ring, &cqe);                  // sleeps only if the CQ is empty
    printf("req %ld → %d\n", (long)io_uring_cqe_get_data(cqe), cqe->res);
    io_uring_cqe_seen(&ring, cqe);                   // mark the slot free: plain memory write
}

Why it's fast:

  • Batching: 100 operations, one io_uring_enter. Under load, completions keep arriving in the CQ while you work, and you read them without asking.
  • Files are truly async: the kernel does the read in the background (with its own worker threads if it has to wait on the disk), so you don't need a thread pool.
  • Optional zero-syscall mode: with IORING_SETUP_SQPOLL, a kernel thread watches your SQ and picks up new entries itself. You pay for that with a CPU core that keeps spinning while there's work.
  • More than read/write: accept, connect, send, open, close, stat, fsync, timeouts, even chains of linked operations (“open, then read, then close”).

Where Go and Node stand (Go 1.24, Node 22)

When is it worth it? When syscall overhead or thread-pool hand-offs dominate: databases and storage engines doing many small random reads, proxies at very high packet rates. For a typical web service whose time goes to waiting on databases and APIs, epoll + a good runtime is already enough, and that's what Go and Node give you today.

Staff-level§11

In production: profiles, tracing, sandboxes and EINTR

In one sentenceIn production, syscalls show up in three ways: as CPU time you can profile, as behavior you need to trace without slowing things down, and as permission errors when a sandbox says no.

1 · Spotting a syscall-heavy program

Start with top. Its CPU line splits time into us (user: your code) and sy (system: kernel code running on your behalf, mostly inside syscalls). A service at 40% sy is spending a lot of its CPU crossing into the kernel. Per process, pidstat -u 1 shows %usr and %system.

Then profile. Go's profiler pprof samples the stack many times a second; syscall-heavy code shows up as time in the syscall wrapper. Here is the 1-byte-write benchmark from §04, profiled (real output, trimmed). flat is time spent in that function itself; cum adds the functions it called:

go test -bench Write1 -cpuprofile cpu.out && go tool pprof -top bench.test cpu.out
      flat  flat%   sum%        cum   cum%
     430ms 60.56% 60.56%      430ms 60.56%  internal/runtime/syscall.Syscall6
      70ms  9.86% 70.42%       70ms  9.86%  runtime.exitsyscallfast
      50ms  7.04% 77.46%       50ms  7.04%  runtime.casgstatus
      40ms  5.63% 83.10%      710ms   100%  os.(*File).Write
      10ms  1.41% 95.77%       50ms  7.04%  runtime.entersyscall

Syscall6 is the Go runtime's raw syscall function: 60% of the time is the crossing itself. entersyscall/exitsyscall are the runtime's bookkeeping around every syscall that might block (§08). When you see these near the top of a real service's profile, look at cum (time including callees) to find which of your code paths makes the calls, then batch them (§09). In Node, node --cpu-prof writes a profile you open in Chrome DevTools; time spent in native writeSync/readSync frames is the equivalent signal. On Linux, perf top or perf record -g (if installed and permitted; it wasn't on this machine) show kernel functions too, so you can see where inside the kernel the time goes.

2 · Tracing without wrecking performance

strace is built on ptrace: the kernel stops the traced thread at every syscall entry and exit and switches to strace so it can read the registers. That's at least two extra context switches per syscall. Real numbers from this machine for the 100,000-write program of §09:

NormalUnder strace -cSlowdown
Go, unbuffered (100,001 writes)~50 ms1,624 ms~30×
Go, bufio (267 writes)~7 ms12 ms~2×

The slowdown scales with the syscall count, so it hits exactly the programs you'd want to investigate. Don't attach strace to a busy production process for more than a few seconds.

eBPF is the production-safe alternative: small programs run inside the kernel at tracepoints (named hook points built into the kernel), aggregate counts and histograms there, and only send the summary out. No stopping the process. bpftrace gives you one-liners. These need root, a reasonably recent kernel and often extra container privileges; they weren't available on this machine, so the lines below are illustrative:

bpftrace one-liners (illustrative; need root)
# Which syscalls does PID 4120 make, and how often? (Ctrl-C prints the counts)
bpftrace -e 'tracepoint:syscalls:sys_enter_* /pid == 4120/ { @[probe] = count(); }'

# How long do read() calls take? A latency histogram in microseconds
bpftrace -e 'tracepoint:syscalls:sys_enter_read /pid == 4120/ { @s[tid] = nsecs; }
             tracepoint:syscalls:sys_exit_read  /@s[tid]/ { @us = hist((nsecs - @s[tid]) / 1000); delete(@s[tid]); }'
lighter-weight alternatives
perf trace -s -p 4120        # strace-like summary, much lower overhead (perf tools)
syscount -p 4120             # from the bcc tools package: syscall counts per name

3 · Sandboxes: seccomp and containers

seccomp lets a process install a filter that every later syscall is checked against; the filter is inherited by children and can't be removed. Container runtimes use it: Docker's default profile blocks a few dozen dangerous or rarely needed syscalls (loading kernel modules, rebooting, mount…), and Kubernetes applies the runtime's default when a pod sets securityContext.seccompProfile.type: RuntimeDefault. You can see whether a process is filtered:

real output (this machine: no filter)
$ grep Seccomp /proc/self/status
Seccomp:          0          ← 0 = off, 2 = filtered
Seccomp_filters:  0

What it looks like when it bites: a call that works on your laptop fails in the container with EPERM (“operation not permitted”), or the process is killed outright. A real example from 2021: newer C libraries started using a new syscall, clone3, to create threads. Older Docker seccomp profiles answered EPERM instead of ENOSYS (“no such syscall”, which would have triggered a fallback to the old clone), so programs in containers failed to start threads until the runtimes were updated. Lessons: run your integration tests under the same seccomp profile as production, and when you see an inexplicable EPERM, strace it in the container to find the call.

4 · EINTR: when a signal interrupts a wait

A signal (SIGTERM, SIGCHLD, …) can arrive while a thread is sleeping inside a slow syscall like epoll_wait, a blocking read on a pipe, or nanosleep. The kernel runs the signal handler, then makes the syscall return early with EINTR: “interrupted, nothing went wrong, please call again”. Some calls, like a blocking read, are restarted automatically if the handler was installed with SA_RESTART (Go installs its handlers that way), but pure waits like epoll_wait and nanosleep never are. Code that calls them directly and doesn't retry treats a harmless signal as a failure.

  • Go's standard library retries EINTR for you in os, net and friends. But if you call syscall or golang.org/x/sys/unix directly, you must handle it yourself, as in the epoll loop in §07. This got more common in Go 1.14, which started using a signal (SIGURG) to preempt long-running goroutines (interrupt them so others get a turn); the Go 1.14 release notes warn that raw syscalls will see more EINTR errors.
  • Node: libuv retries internally; JavaScript code never sees EINTR.
  • Child processes and pipes are where it still shows up in practice: a tool written in another language, a C extension, or a raw syscall in a Go program that also runs exec.Command (SIGCHLD arrives whenever a child exits).
retrying a raw syscall
for {
	n, err = unix.Read(fd, buf)
	if err != unix.EINTR {
		break // success, or a real error
	}
	// interrupted by a signal: just try again
}
Practitioner§12

Pitfalls gallery

Each of these has caused real outages or real slowdowns. Most are invisible in code review and obvious under strace -c.

#1 · Both

Printing inside a hot loop

Every console.log / fmt.Println is at least one write syscall (§01). In Node it's worse than it looks: when stdout is a file or a pipe (as in most containers, where a log collector reads your output), console.log is synchronous on Linux, so the event loop stops for each one. 100,000 debug lines cost 100,000 syscalls.

✕ Don't
for (const row of rows) {
  console.log("processing", row.id); // a write() per row, blocking the loop
  handle(row);
}
✓ Do
let handled = 0;
for (const row of rows) {
  handle(row);
  handled++;
}
console.log("processed", handled, "rows"); // one line, one write
// for real logs: a logger configured to buffer its output
#2 · Go

A bufio.Writer that's never flushed

bufio.Writer holds up to 4 KB in your memory. If the function returns without Flush(), that tail never reaches the kernel, with no error and no crash. The file is just shorter than it should be. Small outputs (under 4 KB) vanish completely.

✕ Don't
w := bufio.NewWriter(f)
for _, r := range rows {
	fmt.Fprintln(w, r)
}
return f.Close() // ← the buffer is still in memory: lost
✓ Do
w := bufio.NewWriter(f)
for _, r := range rows {
	fmt.Fprintln(w, r)
}
if err := w.Flush(); err != nil { // ← send the tail, and check it worked
	f.Close()
	return err
}
return f.Close()
#3 · JS

Sync file calls in a request handler

fs.readFileSync, fs.writeSync and friends make the blocking syscall on the main thread. While the disk works, no other request, timer or callback runs. On a laptop SSD with a warm page cache it's microseconds and nobody notices; on a busy network disk it's tens of milliseconds for every user at once.

✕ Don't
// app = an Express server (a popular Node web framework)
app.get("/config", (req, res) => {
  const cfg = fs.readFileSync("config.json"); // blocks every request
  res.type("json").send(cfg);
});
✓ Do
const cfg = fs.readFileSync("config.json"); // once, at startup: fine

app.get("/config", (req, res) => {
  res.type("json").send(cfg);               // or: await fs.promises.readFile(…)
});
#4 · JS

Assuming the thread pool is infinite

fs.promises.*, dns.lookup, crypto.pbkdf2/scrypt and zlib all share the same 4-thread libuv pool. Four slow jobs make every file read wait, as the real run in §08 showed (a 14-byte read took 131 ms). Raise UV_THREADPOOL_SIZE when the workload needs it, and keep CPU-heavy work in worker threads instead.

#5 · Go

Calling (*os.File).Fd()

f.Fd() returns the raw descriptor, and as a side effect switches the file back to blocking mode, so it leaves the netpoller. On a pipe or socket, reads now pin an OS thread (100 goroutines → 103 threads in §08), and SetReadDeadline stops working. If you only need the number for a syscall, use f.SyscallConn() and its Control method instead, which keeps the file non-blocking.

#6 · Raw syscalls

Treating EAGAIN and EINTR as failures

When you go below the standard library (Go's syscall/x/sys/unix, or a C addon), two errnos aren't errors at all. EAGAIN on a non-blocking fd means “nothing yet: wait for readiness”. EINTR means “a signal interrupted the wait: call again” (§11). Code that logs and gives up on them fails randomly under load, or whenever a signal arrives.

#7 · Raw epoll

Edge-triggered without draining

With EPOLLET, reading 4 KB of an 8 KB message and going back to epoll_wait leaves 4 KB sitting in the socket with no future notification (§07). The connection looks hung; nothing logs an error. With edge-triggered, always loop read until EAGAIN.

#8 · Ops

Leaving strace attached to a live service

strace stops the process twice per syscall: the program from §11 ran ~30× slower under it. Attached to a busy production process, it can trip timeouts and health checks. Trace a test instance, trace for seconds not minutes, filter with -e, or use eBPF tools.

#9 · Both

Counting on O_NONBLOCK for files

Setting O_NONBLOCK on a regular file does nothing: a read that misses the page cache still sleeps until the disk answers, and epoll refuses the fd (EPERM). Async file I/O on Linux means a thread pool (Node, and Go's thread hand-off) or io_uring (§10).

Practitioner§13

Check yourself: predict the outcome

Commit to an answer before you click. Each explanation points back to the section that covers it.

QUESTION 1 / 6
How many syscalls?

This Go loop runs on Linux. Roughly how many write syscalls does it make?

w := bufio.NewWriter(os.Stdout) // 4096-byte buffer
for i := 0; i < 1000; i++ {
    fmt.Fprintln(w, "ok")        // 3 bytes each
}
w.Flush()
QUESTION 2 / 6
What does this print?

A non-blocking pipe with nothing written to it yet:

var p [2]int
syscall.Pipe(p[:])
syscall.SetNonblock(p[0], true)
n, err := syscall.Read(p[0], make([]byte, 8))
fmt.Println(n, err)
QUESTION 3 / 6
Why doesn't Node use epoll for files?
QUESTION 4 / 6
Edge-triggered surprise

A socket is registered with EPOLLET. 8 KB arrive. Your handler reads 4 KB and returns to epoll_wait. No more data is ever sent. What happens to the other 4 KB?

QUESTION 5 / 6
Why is it slow under strace?

A program writes 100,000 lines unbuffered: about 50 ms normally, 1.6 s under strace -c. Why?

QUESTION 6 / 6
Which one never enters the kernel on Linux?
Answered 0 / 6
Foundations§14

Cheat sheet

🏦
User code asks, the kernel doesFiles, sockets, printing, sleeping, threads: all syscalls. The CPU itself enforces the wall (user mode vs kernel mode).
⏱️
~0.1 µs per crossingAbout 100× a function call (90 ns for getpid here); up to ~1 µs on older or heavily patched machines. No context switch unless the call has to wait.
🕰️
The vDSO skips the kerneltime.Now / Date.now / performance.now read the clock in user space: ~50 ns, zero syscalls.
🔎
strace -f shows the truth-e trace=… to filter, -c for a summary. It slows the program a lot: test instances only.
🚪
Non-blocking + epollO_NONBLOCK returns EAGAIN instead of sleeping; epoll_wait sleeps until any watched fd is ready and returns only those.
📈
Edge-triggered: drain until EAGAINLevel-triggered repeats while data remains; edge-triggered only fires on new data.
📁
Files don't fit epollAlways “ready”, yet reads can block on disk. Go spends a thread; Node uses its 4-thread pool; io_uring does it truly async.
📦
Fewer, bigger syscallsbufio / streams / writev / sendfile. 100,001 writes → 267 made it ~7× faster.
If you want to…Linux / CGoNode
See every syscallstrace -f ./appsamesame
Count syscallsstrace -c -f ./appsamesame
Buffer small writesfwrite (stdio)bufio.NewWriter + Flush()write through a stream (auto writev)
Wait on many socketsepoll_wait / keventjust use goroutines (netpoller)just use callbacks / await (event loop)
Copy file → socketsendfileio.Copy(conn, file)fs.createReadStream(p).pipe(res) (read/write; no sendfile)
Async file I/Oio_uring or threadsgoroutines (thread hand-off)fs.promises (thread pool)
More file/DNS concurrency—bound it with a worker poolUV_THREADPOOL_SIZE=16
Read the clock cheaplyclock_gettime (vDSO)time.Now()performance.now()
errnoMeansUsually
EAGAINwould blocknon-blocking fd, wait for readiness
EINPROGRESSstarted, not donenon-blocking connect
EINTRinterrupted by a signalretry the call
EPERMnot permittedseccomp sandbox, or epoll on a regular file
ENOENTno such filewrong path (strace shows which)
EMFILEtoo many open fdsan fd leak or a low ulimit -n
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in OS & RuntimeFile Descriptors & Sockets
Read →