05 — Linux Syscalls for C++ Engineers¶
Every C++ abstraction eventually bottoms out at a syscall. std::mutex becomes futex on Linux. std::filesystem::copy becomes read/write (or copy_file_range if you’re lucky). std::thread becomes clone. When you profile a hot path and the flame graph is 40% kernel time, you cannot debug it without knowing which syscalls your library is calling and why.
You are on macOS Apple Silicon. That means two things: (1) you will use kqueue locally where Linux docs say epoll, and (2) most Zoho servers and every FAANG study whiteboard assume Linux. Learn both surfaces; keep the mapping in your head.
1. The mental model: file descriptors are the universal handle¶
On Unix, almost everything is a file descriptor: files, sockets, pipes, timers (timerfd), signals (signalfd), event objects (eventfd), even other epoll instances. That means one API — read/write/close plus a readiness notifier — handles all I/O. Master that and you understand 80% of a Linux server.
macOS keeps the same abstraction (fds are POSIX) but replaces the Linux-specific readiness syscalls with BSD equivalents.
2. mmap — the fastest way to touch a file¶
mmap maps a file (or anonymous memory) into your process’s address space. Reads and writes become memory accesses, dispatched by the page fault handler.
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int fd = ::open("data.bin", O_RDONLY);
struct stat st{}; ::fstat(fd, &st);
void* p = ::mmap(nullptr, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
if (p == MAP_FAILED) { /* handle */ }
// Sequential scan hint (Linux only; macOS ignores or partial support)
::madvise(p, st.st_size, MADV_SEQUENTIAL);
// Use it like a byte array:
const char* bytes = static_cast<const char*>(p);
// … parse …
::munmap(p, st.st_size);
::close(fd);
When to use mmap:
Random access into a large file (parsing indexes, memory-mapped databases).
Zero-copy sharing between processes (
MAP_SHARED+ anonymous file orshm_open).Startup speed:
mmapan entire binary blob and let the page cache handle it.
When NOT to use it:
Streaming a huge file top-to-bottom.
read()with a small buffer is often faster because the kernel prefetches predictably; page faults onmmapare per-4KB and can thrash.Networked filesystems (NFS, SMB). Behavior is undefined-ish and hangs are common.
Writing where you need
fsyncguarantees.msyncsemantics are subtle; prefer explicitwrite+fsync.
macOS note: mmap works identically. madvise flags differ; MADV_SEQUENTIAL and MADV_RANDOM exist, MADV_DONTNEED behaves differently (it actually frees pages on Linux; on macOS it’s advisory).
3. epoll (Linux) and kqueue (macOS/BSD) — the same idea, two APIs¶
You have N sockets. You want to know which ones are ready to read without spawning N threads or spinning on select. Both APIs solve this by registering interest once and receiving batched readiness events.
Linux epoll skeleton¶
#include <sys/epoll.h>
#include <unistd.h>
int ep = ::epoll_create1(EPOLL_CLOEXEC);
epoll_event ev{};
ev.events = EPOLLIN | EPOLLET; // edge-triggered
ev.data.fd = listen_fd;
::epoll_ctl(ep, EPOLL_CTL_ADD, listen_fd, &ev);
epoll_event events[64];
for (;;) {
int n = ::epoll_wait(ep, events, 64, /*timeout_ms*/ -1);
for (int i = 0; i < n; ++i) {
int fd = events[i].data.fd;
// read until EAGAIN because we're edge-triggered
for (;;) {
char buf[4096];
ssize_t r = ::read(fd, buf, sizeof(buf));
if (r > 0) { /* consume */ continue; }
if (r == 0) { ::close(fd); break; } // peer closed
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
if (errno == EINTR) continue;
/* real error */ ::close(fd); break;
}
}
}
Level-triggered vs edge-triggered (EPOLLET):
Level:
epoll_waitkeeps returning the fd as long as it has data. Simpler; wastes wakeups on partial reads.Edge: notification fires once per state transition. You must drain to
EAGAINon every event or you will lose data. Fewer syscalls; harder to get right.
macOS kqueue skeleton (what you’ll actually run for P3.3)¶
#include <sys/event.h>
#include <sys/time.h>
#include <unistd.h>
int kq = ::kqueue();
struct kevent change{};
EV_SET(&change, listen_fd, EVFILT_READ, EV_ADD | EV_CLEAR, 0, 0, nullptr);
::kevent(kq, &change, 1, nullptr, 0, nullptr);
struct kevent events[64];
for (;;) {
int n = ::kevent(kq, nullptr, 0, events, 64, /*timeout*/ nullptr);
for (int i = 0; i < n; ++i) {
int fd = (int)events[i].ident;
if (events[i].filter == EVFILT_READ) {
for (;;) {
char buf[4096];
ssize_t r = ::read(fd, buf, sizeof(buf));
if (r > 0) continue;
if (r == 0) { ::close(fd); break; }
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
if (errno == EINTR) continue;
::close(fd); break;
}
}
}
}
EV_CLEAR on kqueue is roughly EPOLLET on epoll — edge-triggered semantics.
Mapping table for your notes:
Concept |
Linux |
macOS |
|---|---|---|
Readiness multiplexer |
|
|
Add/remove interest |
|
|
Wait for events |
|
|
Edge trigger flag |
|
|
Cross-fd timer |
|
|
Cross-fd signal |
|
|
User wakeup fd |
|
|
Portability layer: if you want one API, use libraries — libuv, libevent, asio, mio. For P3.3 write kqueue directly and add three sentences of Linux-parity notes in your README.
What most people get wrong: they choose edge-triggered because a blog told them it was faster, then forget to loop until
EAGAIN. Under load, half their bytes vanish and they can’t reproduce it in a debugger. Start with level-triggered. Move to edge-triggered only after a benchmark says it matters, and only after you’ve written thewhile(read == EAGAIN)loop.
4. futex — what std::mutex is on Linux¶
Linux std::mutex is a thin wrapper over futex(FUTEX_WAIT / FUTEX_WAKE). A futex is “an atomic integer in userspace with a syscall to sleep when it has a specific value.” Uncontended locks are userspace atomic ops; contention drops into the kernel.
You will not call futex() directly in production C++ (that’s what std::mutex is for), but reading the manpage (man 2 futex) is a one-hour investment that clarifies the mental model. On macOS the equivalent primitive is os_unfair_lock and (privately) __ulock_wait. std::mutex on modern macOS uses os_unfair_lock for uncontended fast paths as of libc++ recent versions; older versions used pthread over Mach ports and were noticeably slower.
5. clock_gettime — the only clock you should use¶
std::chrono::steady_clock on Linux and macOS both wrap clock_gettime. Pick the right clock ID for your purpose:
Clock ID |
Guarantees |
Use for |
|---|---|---|
|
Never goes backward; not affected by NTP jumps |
Timeouts, benchmarks, deadlines |
|
Not adjusted by NTP frequency slew either |
Micro-benchmarks under time discipline |
|
Wall clock; can jump |
Timestamps for logs / disk |
|
CPU time for this process |
CPU cost isolation |
|
CPU time for this thread |
Same, per-thread |
On Apple Silicon mach_absolute_time is what backs the standard chrono clocks. It runs off the Apple architected 24 MHz timer — resolution is ~41 ns. Do not chase nanoseconds finer than that; you’re measuring rounding, not signal.
6. perf_event_open — for when the profiler isn’t answering¶
Linux perf_event_open gives you access to CPU performance counters (cycles, instructions, cache misses, branch mispredicts) from userspace. This is what perf stat uses under the hood. It is not something you call directly often — you use perf on the command line or google-benchmark’s --benchmark_perf_counters. Know that it exists so you can read the flame graphs.
macOS equivalent: Instruments.app and xctrace record --template 'CPU Counters'. The M-series exposes counters via the kperf framework; Apple doesn’t publish a stable userspace API, but Instruments works well enough for most performance work. For CPU-bound C++ hot-path analysis, use Instruments’ “CPU Profiler” and “System Trace” templates. For serious cycle counting, boot a Linux VM (or a qemu-system-aarch64 on M-series) and use perf.
7. Signals — one paragraph so you don’t get burned¶
Signals in a multithreaded C++ program are a minefield. The rules you need:
Only async-signal-safe functions in a signal handler. That excludes
printf,malloc,std::mutex, essentially everything you’d want.Block signals in worker threads and dedicate one thread to
sigwaitorsignalfd. This is the “signal handling thread” pattern; it turns signals into normal fd events.Never install a
SIGSEGVhandler for control flow. Use it only to dump state and re-raise.
Portable rule: convert signals to fd events (signalfd on Linux, EVFILT_SIGNAL on macOS) and handle them in your event loop. No handlers.
8. strace (Linux) / dtruss (macOS) — the syscall X-ray¶
When behavior is inexplicable:
# Linux
strace -f -e trace=network,ipc -o trace.log ./your_program
# macOS (requires SIP disable for third-party binaries; your own works)
sudo dtruss -f ./your_program 2>&1 | head -200
Add these to your muscle memory. Any time a program “hangs,” “is slow,” or “doesn’t do what I said,” a syscall trace collapses the mystery in seconds.
9. Common bugs¶
EINTRunhandled. Every blocking syscall can return-1witherrno == EINTRif a signal fired. Loop.Reading less than requested from a socket and assuming that’s an error.
readon a socket can return short. Loop until you have what you need or hit EOF.Closing an fd from thread A while thread B is
poll/epoll_waiton it. Undefined. UseEVFILT_USER/eventfdto wake the loop first.mmap-ing a file and then truncating it. Accessing the vanished pages is aSIGBUS, notEFAULT. Guard withsigactionor don’t do it.Blocking
accepton a listening socket after the process has forked — the parent and child both wake up on the same fd (thundering herd). UseSO_REUSEPORTand one accept per thread, or a single accept-thread.Assuming Linux behavior on macOS.
MSG_NOSIGNALdoesn’t exist (useSO_NOSIGPIPEon the socket orMSG_NOSIGNALconditionally).epoll_*functions are absent. Read the mapping table.
Required reading¶
man 2 mmap,man 2 epoll,man 2 kqueue,man 2 futex,man 7 signal-safety. The manpages are the source of truth. Read them once; skim them again when they bite you.Michael Kerrisk — The Linux Programming Interface. The reference book. Chapters 44 (pipes), 45 (SysV IPC), 63 (multiplexing), 44 (mmap). Skim table of contents, deep-read what you use.
Julia Evans — Bite Size Linux / zines. Warm intro that stays honest about complexity.
wizardzines.com.libuv design docs (
docs.libuv.org/en/v1.x/design.html). Shows what a serious cross-platform event loop looks like inside.Beej’s Guide to Network Programming — free, still the friendliest intro to sockets.
Exercises¶
Write a
catclone in ~50 lines thatopen→mmap→write(STDOUT, …)→munmap. Time it vs/bin/caton a 1 GB file. Explain the result to yourself.Write a program that catches
SIGINTviasignalfd(orEVFILT_SIGNAL) and prints “goodbye” in the main loop, not in a handler. Nosignal()calls.Run
strace -c ls -l /usr/bin > /dev/null(orsudo dtruss -c) and identify the top-3 syscalls. Guess why before checking. Now do it on your own project’s binary.
Nav: ← 04 Lock-Free Basics · Phase 3 README · → 06 Undefined Behavior