2 · The Linux platform Draft

System calls

6 min read

On this page

2.3 System calls: the price of asking

A process uses the operating system by asking, and the ask has to raise the core’s privilege level. This section is what that costs: the trip itself, the work on the far side of it, what the kernel leaves behind in your caches on the way back, and the asks that hide inside ordinary Rust.

The mechanism

Your code puts the call’s number and its arguments in registers and executes syscall. The core raises its privilege level, saves your instruction pointer and flags, and jumps to the kernel’s entry point; the entry code switches to a kernel stack and saves the rest of your registers, looks the number up in a table and runs the handler; sysret reverses the trip. On a kernel with no speculation mitigations the round trip, with a handler that does nothing, is about 100 ns. Since 2018 most kernels pay more: the Meltdown fix, kernel page-table isolation, switches page tables on every entry and exit so that user code cannot read kernel memory through speculation, and the Spectre fixes flush or fence the branch predictor; on affected Intel parts a trivial call such as getpid costs 200–300 ns, and on the newest parts, which fix it in hardware, it is back near 100. cat /sys/devices/system/cpu/vulnerabilities/* says which mitigations your box runs.

That is the floor. A call that does something costs what it does, on top. read on a socket that has data waiting finds the socket, takes its lock, copies the bytes out of the kernel’s buffer into yours and updates the receive window: 1–2 µs. write to a file copies into the page cache and marks it dirty: 1–3 µs, more when it has to allocate a page. Waking a sleeping thread finds it on its wait queue, marks it runnable and sends an interrupt to its core: about 1 µs here, plus the whole wake-up on the other side. mmap and munmap change page tables and cost microseconds; the unmap also has to tell every other core running this process to forget what it knew about those pages.

Then the indirect cost. The kernel’s handler and the data it touches go through your L1 and L2 and your TLB, and the branch predictor learns its branches instead of yours. When your loop resumes, the next few hundred cycles run slower than they did. That cost does not appear in any system-call benchmark, because the benchmark has no working set to lose.

The calls that are not calls

Some things are asked for so often that the kernel provides user-space implementations in the vDSO. Common x86-64 examples include the following functions (the exact exports depend on the architecture and kernel): clock_gettime, gettimeofday, time and getcpu. Instant::now() is clock_gettime(CLOCK_MONOTONIC) through the vDSO; it reads the TSC and scales it, 20–30 ns, with no change of privilege level at all. strace will not show it, because it never happens. The exception is a clock source or clock ID the vDSO cannot serve: a supported paravirtual clock can still be read in user space, while an unsupported source falls back to a real syscall and Instant::now() costs 300 ns to a microsecond. That is why the previous section checked current_clocksource.

The calls you did not know you made

Ordinary Rust asks the kernel for things through code that does not look like it does. These can reach a syscall; buffering, allocator state, contention and the library implementation decide when. Trace the actual program rather than inferring one call from each source expression.

  • println!, eprintln!, dbg!: a write to file descriptor 1 or 2 for every line, under a lock on stdout. Rust’s stdout is line-buffered whatever it is connected to, so every newline is a system call.
  • log::info! and tracing subscribers writing to a file or stdout: the same write, after formatting.
  • Collection growth and format!: allocation or reallocation, usually served in user space, sometimes reaching brk, mmap or mremap and copying existing data.
  • Large heap allocations and frees: possible mmap and munmap calls. The decision depends on allocator state and tuning, not a fixed Rust size boundary.
  • A contended Mutex::lock, Condvar::wait, RwLock: a futex. Uncontended, none.
  • std::sync::mpsc send when the receiver is parked, thread::park, thread::unpark: futex.
  • thread::sleep, thread::yield_now: nanosleep, sched_yield.
  • Socket I/O: a receive or send syscall on the ordinary kernel path. An empty non-blocking receive can be much cheaper than a data-bearing call, often hundreds of nanoseconds.
  • File::read, File::write, std::fs::metadata: read, write, statx.

Counting them

strace -c -f ./binary prints a table of every system call the process made, by name and count, when it exits. It works through ptrace, the interface debuggers use, which stops the process at the entry and the exit of every call; that makes each call 10–50× slower, so it is a tool for counting, never for timing. A loop that makes no calls runs at full speed under it, which is itself a test. perf trace -s ./binary gives the same table with far less overhead, because it reads the kernel’s own tracepoints instead of stopping the process. For a running thread, perf stat -e raw_syscalls:sys_enter -t <tid> -- sleep 10 counts its calls over ten seconds (as root, or with kernel.perf_event_paranoid at -1), and strace -e trace=mmap,munmap,brk,mremap shows only the memory calls. These are the cheapest measurements in the book, and the first thing to run when a histogram’s tail grows.

Worked example: one log line per message

A feed handler decodes a message, updates the book, and writes one line about it: println!("{seq} {px} {qty}"). Formatting runs each field’s Display implementation straight into stdout’s line buffer: no allocation, a few hundred nanoseconds. The stdout lock is 20 ns uncontended. The write is 1–2 µs to a pipe and 5–10 µs to a terminal. Then the indirect cost: the kernel’s write path has just walked through L1, and the next message’s book update misses on lines that were resident a microsecond ago.

Against a 1 µs budget the write alone is two to ten budgets, before the handler has done any work. At 50,000 messages per second the handler spends 10% of its time inside write; at 500,000 it cannot keep up, and the queue behind it grows until the market goes quiet. A faster logger does not change that arithmetic, because the cost is the ask, not the formatting. What changes it is not asking on the hot path: the record goes into a buffer in the thread’s own memory, a store and nothing else, and a thread with time to spare does the asking later.

The receive is the one ask a message handler cannot avoid on a plain kernel stack, since the bytes have to come from somewhere, and Chapter 4 is about getting them without it.