System Calls¶
What a system call is¶
A system call is how a user-space program asks the kernel to do something it is not allowed to do itself: read a file, send a packet, allocate address space, create a process or thread, wait on a lock. Every interaction a program has with the outside world goes through one. A storage engine's performance and correctness depend directly on which system calls it makes, how many, and how it handles their failures.
Why programs can't just do it themselves¶
The CPU runs in different privilege levels: user mode (x86 ring 3, ARM EL0) and kernel mode (ring 0, EL1). In user mode, instructions that touch hardware (I/O ports, page tables, interrupt control) fault, and memory pages marked supervisor-only are inaccessible. This is what stops a buggy or malicious process from reading another process's memory, overwriting the disk directly, or crashing the machine. The kernel is the only code with that power, and a system call is the one controlled door into it.
What happens during a system call¶
- The libc wrapper puts the system call number (e.g.
readis 0 on x86-64 Linux) and up to six arguments into registers. - The
syscallinstruction (svcon ARM) switches to kernel mode and jumps to the kernel's entry point. The kernel switches to the thread's kernel stack — user-space memory is never trusted for this. - The kernel looks up the handler in its system call table, validates
every argument (is
fdopen in this process? isbufa user address the process may write?), and does the work. - Data crosses the boundary by explicit copies (
copy_from_user,copy_to_user) that safely handle faults on bad pointers — this is why passing an invalid buffer returnsEFAULTinstead of crashing the kernel. sysretreturns to user mode. On failure, the raw kernel return is-errno; the libc wrapper converts it to-1and sets the thread-localerrno.
What it costs¶
Measured¶
From Go on an Apple Silicon Mac (Go 1.26.4), syscall.Getppid() — a
system call that does essentially nothing — costs about 145 ns per
call. That is the fixed price of the round trip: mode switch,
entry/exit code, register saving, security mitigations, plus the Go
runtime telling the scheduler the goroutine is in a syscall. For
comparison, a Go function call that is not inlined costs about 2 ns.
The fixed cost dominates small operations. Reading a 64 MiB file that
is already in the page cache through (*os.File).Read with different
buffer sizes (the 1- and 16-byte runs read only the first 8 MiB):
| Buffer size | Throughput | Calls | Time per call |
|---|---|---|---|
| 1 byte | 2.2 MB/s | 8,388,608 | 465 ns |
| 16 bytes | 34 MB/s | 524,288 | 467 ns |
| 256 bytes | 530 MB/s | 262,144 | 483 ns |
| 4 KB | 5,585 MB/s | 16,384 | 733 ns |
| 64 KB | 16,758 MB/s | 1,024 | 3,911 ns |
| 1 MB | 19,620 MB/s | 64 | 53,445 ns |
Up to a few hundred bytes, the time per call stays near ~470 ns regardless of size: almost all of it is overhead, so throughput is proportional to buffer size. From 4 KB upward, the copy itself starts to dominate, and beyond ~64 KB throughput approaches memory-copy speed. The gap between 1-byte and 1 MB reads is ~9,000x for the same data.
Worked example. Copying a 64 KB file one byte at a time means
65,536 read() calls; at ~470 ns each, ~30 ms spent crossing the
user/kernel boundary before counting any actual I/O. One 64 KB read()
pays the fixed cost once (~4 µs). This is the reason buffered I/O
exists (bufio.Reader and bufio.Writer in Go, stdio in C): fill a
user-space buffer with one large read and serve small reads from it.
The same effect on a socket — 5.8 µs per unbuffered 16-byte Write
against 8 ns with bufio — is measured in
Network Programming in Go.
Where the time goes¶
| Component | Notes |
|---|---|
| Mode switch and entry/exit | Saving/restoring registers, switching stacks |
| Speculative-execution mitigations | Spectre/Meltdown defenses (KPTI page-table switch, return-stack and branch-predictor flushing) added hundreds of nanoseconds on affected CPUs |
| Argument validation and copying | Proportional to the data moved |
| Cache and TLB pollution | Kernel code and data displace the application's in the CPU caches; the application runs slower for a while after returning |
| Blocking | If the call must wait (disk I/O, a lock, a network packet), the thread sleeps and the CPU switches to another thread: a context switch costs microseconds and dominates everything above |
The last row is the important one: a system call that blocks is expensive not because of the mode switch but because of the scheduler round trip.
System calls that aren't: the vDSO¶
Some frequently used calls only read kernel-maintained data. Linux
maps a small shared library, the vDSO, into every process, letting
clock_gettime, gettimeofday, and getcpu run entirely in user mode
by reading a kernel-updated memory page. That is why Go's time.Now()
costs tens of nanoseconds rather than a full system call (the Go
runtime calls the vDSO directly on Linux) — and why timestamping every operation in a database is
affordable.
Reducing the number of system calls¶
| Technique | Replaces | Used for |
|---|---|---|
| User-space buffering | Many small read/write |
Log writers, file readers (WritableFile buffers appends, one write per flush) |
readv / writev (vectored I/O) |
Several calls for several buffers | Writing a record header and payload in one call |
pread / pwrite |
lseek + read (two calls, and a race) |
Concurrent positional reads of SSTables |
sendfile, splice, copy_file_range |
read into user space + write out |
Serving files over sockets, copying files without user-space copies ("zero copy") |
epoll_wait returning many ready fds |
One call per socket | Event loops (I/O Models and Event Loop) |
io_uring |
One call per I/O | Many I/Os submitted and completed with one or zero calls (Async I/O and io_uring) |
futex fast path |
A system call per lock operation | Uncontended mutexes never enter the kernel (Concurrency Primitives) |
Real-world usage: Go's runtime batches I/O through its netpoller —
net.Conn.Read() does not block a thread in read(); goroutines waiting
on sockets are parked, and a single epoll_wait (or kqueue) call
reports which of thousands of connections are ready.
Handling errors correctly¶
At the C level, system call wrappers return -1 and set errno; in
Go, golang.org/x/sys/unix returns the same codes as syscall.Errno
error values. Either way, robust code must handle several outcomes that
are not failures of the operation:
errno / result |
Meaning | Correct handling |
|---|---|---|
EINTR |
A signal handler ran while the call was blocked; the call was interrupted before doing anything | Retry (many calls auto-restart with SA_RESTART, but not all — e.g. epoll_wait, nanosleep) |
Short count from read/write |
Fewer bytes than requested: end of file, a signal after partial progress, a socket buffer limit | Loop until all bytes are transferred or a real error occurs |
EAGAIN / EWOULDBLOCK |
Non-blocking fd has no data / no buffer space now | Wait for readiness (epoll), then retry |
ENOSPC, EDQUOT |
Disk or quota full | Real failure: return the error |
EIO |
Device error | Real failure; for fsync, do not retry (Durability and fsync) |
EMFILE / ENFILE |
Per-process / system-wide fd limit reached | Real failure; see File Descriptors |
At the raw system-call level (golang.org/x/sys/unix), the caller
handles both cases itself:
func writeFully(fd int, data []byte) error {
for len(data) > 0 {
n, err := unix.Write(fd, data)
if err == unix.EINTR {
continue // interrupted before writing anything: retry
}
if err != nil {
return fmt.Errorf("write: %w", err) // real error (ENOSPC, EIO, ...)
}
data = data[n:] // short write: continue with the rest
}
return nil
}
The os and net packages already do this: (*os.File).Write
retries on EINTR and loops over short writes, returning an error only
if it could not write everything, and io.ReadFull loops over short
reads. Code that calls unix.Read/unix.Write directly — or any
io.Reader contract (a single Read may return fewer bytes than
asked) — must handle short counts itself. Go returns the error value
from the call, so the C pitfall of reading a global errno after some
other call has overwritten it does not exist.
Observing system calls¶
| Tool | Shows |
|---|---|
strace -f -tt -T -e trace=openat,pread64,write,fsync ./prog (Linux) |
Each call with arguments, timestamp, and duration |
strace -c ./prog |
Summary: count, total time, errors per call type |
perf trace (Linux) |
Like strace with much lower overhead |
ltrace |
Library calls rather than system calls |
dtruss / fs_usage (macOS) |
System call tracing on macOS |
bpftrace (tracepoint:syscalls:*) |
Custom aggregation with negligible overhead, in production |
strace intercepts every call via ptrace and can slow a program by 10x
or more; use it to understand behavior, not to measure performance. The
summary mode is often the fastest way to discover that a "slow database"
is making a million 100-byte writes or an fsync per operation.
Restricting system calls¶
Because the system call interface is the kernel's entire attack surface from user space, sandboxes restrict it: seccomp filters on Linux allow only a whitelist of calls (Docker's default profile blocks dozens; Chrome's renderer allows very few). A storage engine running under such a profile must not depend on unusual calls — for example, io_uring is blocked by default in some container runtimes for exactly this reason.
Common pitfall¶
Assuming a function from the standard library is "just a function." A
log.Printf to an unbuffered os.Stderr on every request, os.Stat
in a loop to check whether a file exists, writing to an *os.File
directly in small pieces instead of through a bufio.Writer, opening
and closing the same file per request — each hides one or more system
calls per invocation, and at a million
operations per second the ~0.1–0.5 µs fixed cost of each becomes most of
the CPU budget. Use strace -c (or perf trace -s) on a running
benchmark: the count of calls per operation is usually more revealing
than any CPU profile.