11. Linux systems programming essentials¶
Time: ~3 hours Prerequisites: 01, 02, 07; C at a professional level
1. In plain words¶
Your program cannot touch the disk, the network or another process directly. Only the kernel can.
When your code needs any of that, it asks the kernel through a system call (syscall): a
numbered request like "open this file", "read 4096 bytes from descriptor 5", "create a process".
On Windows you called CreateFileW, ReadFile, CreateProcessW, WaitForSingleObject; those
are Win32 API functions in kernel32.dll that eventually make Nt* system calls. On Linux the
C library (glibc) gives you thin wrappers, open, read, fork, waitpid, that are almost
one-to-one with the kernel's syscalls.
This module is the Linux half of what you already know from Win32: files and handles become files
and file descriptors, GetLastError() becomes errno, CreateProcess becomes fork + exec,
console control handlers become signals, services become systemd units, and the
"out of memory" you have seen as a failed malloc becomes the OOM killer. Knowing these lets
you read strace output, write a server that shuts down cleanly, and run it as a proper service.
Analogy: the kernel is a bank vault and syscalls are the teller window. You fill in a numbered
form (syscall number plus arguments), slide it through, and get back a result or an error code.
glibc is the friendly clerk who fills in the form for you; sometimes the clerk does extra work
first (buffering in printf, caching in malloc) before going to the window.
2. The real thing¶
2.1 Syscalls vs library calls¶
your code printf("x=%d\n", x) fopen/fread malloc(64)
| | |
glibc formats into a buffer; FILE* buffer; carves from an arena;
write() when full/newline read() in 4 KiB+ brk()/mmap() only when it grows
| | |
kernel write(1, buf, n) read(fd, buf, 4096) brk / mmap
- A syscall crosses into the kernel: the CPU switches privilege level (
syscallinstruction on x86-64), the kernel does the work, and returns. Cost: on the order of 100 ns for a trivial one on modern hardware (typical; more with mitigations for CPU vulnerabilities), plus whatever the work costs. - A library call may make zero, one or many syscalls.
printfusually makes none until its buffer flushes.mallocusually makes none. man 2 readdocuments the syscall wrapper;man 3 printfdocuments the library function. Section 2 vs section 3 is the distinction.straceshows syscalls only;ltraceshows library calls (module 07).
Every syscall reports failure the same way: the wrapper returns -1 (or MAP_FAILED, or NULL
for some library functions) and sets the thread-local variable errno to a positive code
(ENOENT, EINTR, EAGAIN, ...). Some newer interfaces (pthreads) return the error code directly
and do not touch errno.
2.2 errno discipline¶
int fd = open(path, O_RDONLY | O_CLOEXEC);
if (fd < 0) { // 1. check the RETURN VALUE, never errno alone
int e = errno; // 2. copy errno immediately: the next call may overwrite it
log_error("open %s: %s", path, strerror(e));
if (e == ENOENT) ... // 3. branch on specific codes you can handle
}
Rules:
errnois only meaningful right after a call that reported failure. Successful calls may leave garbage in it.- It is per-thread (glibc defines
errnoas(*__errno_location())), so threads do not clobber each other. - Any call in between, including
printfandfree, may change it. Save it first. - Signal handlers must save and restore it (section 2.6).
strerroris not thread-safe in old glibc;strerror_ror%minprintfare. In C++,std::system_error(errno, std::generic_category(), "open")carries it as an exception.- Windows mapping:
errnoisGetLastError(),strerrorisFormatMessage, and likeGetLastErrorit is valid only right after the failing call.
The codes you must recognise on sight:
| errno | Meaning | Typical response |
|---|---|---|
EINTR |
a signal interrupted a blocking call | retry the call |
EAGAIN / EWOULDBLOCK |
non-blocking fd has no data or no space | wait for readiness (epoll) |
ENOENT |
no such file or directory | configuration error |
EACCES / EPERM |
permission denied / operation not permitted | user, mode, capabilities, SELinux/AppArmor |
EMFILE |
this process hit its fd limit (ulimit -n) |
raise the limit; find the leak |
ENFILE |
the whole system hit its fd limit | fs.file-max |
ENOSPC |
disk full (or inodes exhausted) | df -h, df -i |
EPIPE |
wrote to a pipe/socket whose reader closed | close the connection (ignore SIGPIPE) |
ECONNRESET |
peer reset the TCP connection | close the connection |
EADDRINUSE |
port already bound | another instance; SO_REUSEADDR for TIME_WAIT |
ENOMEM |
kernel could not allocate | rare from malloc on Linux (overcommit); real for mmap with limits |
2.3 File I/O: open, read, write, fsync, close¶
int fd = open("data.log", O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0644);
ssize_t n = write(fd, buf, len); // may write fewer than len bytes: loop
fsync(fd); // wait until the device has it
close(fd); // can fail too: check it
- A file descriptor is a small integer indexing the process's table of open files. 0, 1, 2 are
stdin, stdout, stderr. Sockets, pipes, timers, epoll instances and devices are all fds: "everything
is a file" means one set of calls (
read,write,close,poll) works on all of them. openflags:O_RDONLY/O_WRONLY/O_RDWR,O_CREAT(with a mode),O_TRUNC,O_APPEND(every write goes to the end, atomically for eachwriteon local filesystems),O_EXCL(withO_CREAT: fail if it exists; the safe way to create lock files),O_CLOEXEC(close in the child afterexec; always set it),O_NONBLOCK,O_DIRECT(bypass the page cache; databases).- Short reads and writes are normal.
readreturns fewer bytes than asked at end of file, on pipes and sockets, and when a signal arrives.readreturning 0 means end of file (or the peer closed a socket). Every serious codebase has awrite_allloop; tcpkv's issend_allinsrc/main.cpp. writeis not durable. It copies into the kernel's page cache and returns. The kernel writes dirty pages back later (seconds, typically). If the machine loses power before that, the data is gone, even thoughwritesucceeded.fsync(fd)blocks until the data and metadata are on stable storage;fdatasyncskips non-essential metadata (timestamps). Creating a file also needs anfsyncof the directory to make the directory entry durable.- The atomic-replace pattern (configs, snapshots): write
file.tmp,fsync(tmp),rename("file.tmp", "file"),fsync(dirfd).renamewithin one filesystem is atomic: a reader sees the old file or the new one, never half of each. - Windows mapping:
CreateFileW=open,ReadFile/WriteFile=read/write,FlushFileBuffers=fsync,CloseHandle=close,MoveFileExW(MOVEFILE_REPLACE_EXISTING)=rename,FILE_FLAG_NO_BUFFERING=O_DIRECT.
2.4 mmap¶
mmap maps a file (or anonymous zeroed memory) into your address space. Reading the memory reads
the file; no read calls, no copy into a user buffer.
void *p = mmap(NULL, len, PROT_READ, MAP_PRIVATE, fd, 0); // file-backed, read-only
void *a = mmap(NULL, 1 << 20, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); // 1 MiB of zeroed memory
munmap(p, len);
MAP_SHARED: writes go to the file (and other processes mapping it see them);msyncflushes them likefsync.MAP_PRIVATE: copy-on-write; your writes are private.- Pages load lazily on first touch (a page fault).
madvise(MADV_SEQUENTIAL / MADV_WILLNEED)tunes read-ahead. - Uses: fast read-only access to large files (search indexes,
.soloading: the dynamic loader mmaps every shared library), shared memory between processes, and big allocations (glibcmallocusesmmapfor blocks over 128 KiB by default). - Pitfalls: an I/O error on a mapped file arrives as SIGBUS, not as an error code; a file that
shrinks under you gives SIGBUS on access past the end; you cannot
mmaplength 0. Databases that usemmapfor their main data (LMDB does; PostgreSQL does not) trade control over write ordering for simplicity. - Windows mapping:
CreateFileMapping+MapViewOfFile=mmap;VirtualAlloc= anonymousmmap.
2.5 Processes: fork, exec, wait, pipes¶
Linux splits "start a program" into two calls:
fork() -> duplicates the calling process. Returns 0 in the child, child's PID in the parent.
Memory is shared copy-on-write, so it is cheap. File descriptors are duplicated.
exec*() -> replaces the current program image with a new one. Same PID, same fds (except
O_CLOEXEC ones). Returns only on failure.
waitpid()-> parent collects the child's exit status. Until then the child is a zombie.
int pfd[2];
pipe2(pfd, O_CLOEXEC); // pfd[0] read end, pfd[1] write end
pid_t pid = fork();
if (pid == 0) { // child
dup2(pfd[1], STDOUT_FILENO); // child's stdout now goes into the pipe
execlp("uname", "uname", "-srm", (char *)NULL);
_exit(127); // exec failed; _exit, not exit (no atexit, no stdio flush)
}
close(pfd[1]); // parent: close the write end or read never sees EOF
/* read(pfd[0], ...) until 0 */
int status;
waitpid(pid, &status, 0);
if (WIFEXITED(status)) printf("exit %d\n", WEXITSTATUS(status));
if (WIFSIGNALED(status)) printf("signal %d\n", WTERMSIG(status));
Why two calls instead of one CreateProcess? Between fork and exec the child can rearrange
its own environment with ordinary calls: redirect fds with dup2, change directory, drop
privileges, set limits. That is how the shell implements cmd > file 2>&1 | other. The cost is
that fork in a multithreaded process is dangerous: only the calling thread exists in the
child, and any mutex another thread held (inside malloc, inside your logger) stays locked
forever. Between fork and exec in a threaded program, call only async-signal-safe functions.
posix_spawn (one call, like CreateProcess) avoids the problem and is faster for large
processes; glibc implements it with clone(CLONE_VM | CLONE_VFORK).
Pipes are one-way kernel buffers (64 KiB by default) between two fds. A reader blocks when
empty; a writer blocks when full; reading after all writers closed gives EOF (0); writing after
all readers closed gives SIGPIPE (and EPIPE if SIGPIPE is ignored).
Zombies and orphans. A child that exited but was not waitpided is a zombie (Z in ps): it
holds only a PID and its exit status. A parent that never reaps leaks PIDs. If the parent dies
first, the child is re-parented to PID 1 (or a subreaper), which reaps it. In a container, your
program may be PID 1, which is why the tcpkv image uses tini (module 08).
2.6 Signals and async-signal safety¶
A signal is an asynchronous notification delivered to a process: the kernel interrupts whatever a thread was doing and runs your handler on its stack.
| Signal | Number | Default | Typical source |
|---|---|---|---|
SIGINT |
2 | terminate | Ctrl-C |
SIGTERM |
15 | terminate | kill PID, systemctl stop, docker stop |
SIGKILL |
9 | terminate, cannot be caught | kill -9, OOM killer, stop timeout |
SIGSEGV |
11 | core dump | invalid memory access |
SIGABRT |
6 | core dump | abort(), failed assert, sanitizers |
SIGBUS |
7 | core dump | I/O error on mmap, misaligned access on some CPUs |
SIGPIPE |
13 | terminate | write to closed pipe/socket |
SIGCHLD |
17 | ignore | a child changed state |
SIGHUP |
1 | terminate | terminal closed; by convention "reload config" for daemons |
SIGUSR1/2 |
10/12 | terminate | application-defined (dump stats, rotate logs) |
SIGSTOP/SIGCONT |
19/18 | stop/continue, cannot be caught | kill -STOP, Ctrl-Z sends SIGTSTP |
Install handlers with sigaction, not signal (whose semantics vary):
struct sigaction sa = {0};
sa.sa_handler = on_signal;
sigemptyset(&sa.sa_mask); // signals blocked while the handler runs (plus this one)
sa.sa_flags = SA_RESTART; // auto-restart some interrupted syscalls; omit to get EINTR
sigaction(SIGTERM, &sa, NULL);
Async-signal safety. The handler can run between any two instructions of your program,
including in the middle of malloc while it holds the heap lock, or in the middle of printf
while it holds the FILE lock. If the handler calls malloc or printf, it may deadlock or
corrupt the heap. Only functions listed in man 7 signal-safety may be called: write, read,
_exit, kill, sigaction, signal-related functions, open, close and a few dozen more.
Not printf, malloc, free, syslog, pthread_mutex_lock, exit, or anything in C++ that
may allocate or lock (including std::cout and std::string construction).
Three safe patterns:
- Set a flag.
volatile sig_atomic_t g_stop = 1;in the handler; the main loop checks it. This is what tcpkv'smain.cppdoes. The main loop must wake up to see it: use a poll timeout or install the handler withoutSA_RESTARTso blocking calls returnEINTR. - Self-pipe. The handler
writes one byte to a non-blocking pipe; the event looppolls the read end along with everything else and handles the signal in normal context.m11_syscalls.c signalsdoes this. - signalfd (Linux only): block the signals with
sigprocmask(in every thread: do it before creating threads) and read them from an fd in the event loop. No handler at all.
In multithreaded programs, a process-directed signal is delivered to any thread that does not
block it. The robust design is: block signals in all threads, and have one thread receive them
via sigwait or signalfd.
EINTR: a blocking call (read, accept, poll, nanosleep) interrupted by a handled signal
fails with EINTR. Retry it in a loop, or treat it as "check your flags". SA_RESTART makes the
kernel restart many of them transparently, but never poll, epoll_wait, select or nanosleep.
Windows mapping: SetConsoleCtrlHandler handles Ctrl-C and close events on a new thread,
which is why Windows handlers can do more. Service stop requests arrive through the SCM handler.
Linux handlers interrupt an existing thread, hence the strict rules.
2.7 Timers¶
| Mechanism | Use |
|---|---|
clock_gettime(CLOCK_MONOTONIC) |
measuring durations; never jumps when NTP or an admin sets the clock |
clock_gettime(CLOCK_REALTIME) |
wall-clock timestamps for logs; can jump backwards |
nanosleep / std::this_thread::sleep_for |
simple delays |
timerfd_create |
a timer that is a file descriptor: put it in poll/epoll beside sockets |
poll/epoll_wait timeout argument |
the event loop's own "next deadline" |
alarm, setitimer, timer_create |
signal-based timers; avoid in new code (signal-safety rules again) |
Event-driven servers keep their timeouts (idle connections, request deadlines) in a min-heap or a
timer wheel and pass "time until the earliest deadline" to epoll_wait. timerfd is the simple
version. std::chrono::steady_clock is CLOCK_MONOTONIC in C++. Windows mapping:
QueryPerformanceCounter = CLOCK_MONOTONIC; waitable timers = timerfd.
2.8 epoll preview¶
tcpkv uses one thread per connection. That stops scaling at a few thousand connections (each thread has a stack, typically 8 MiB reserved, and context switches cost). The alternative is one thread watching many sockets and acting only on the ready ones:
int ep = epoll_create1(EPOLL_CLOEXEC);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = listen_fd };
epoll_ctl(ep, EPOLL_CTL_ADD, listen_fd, &ev); // register interest once
for (;;) {
int n = epoll_wait(ep, events, 64, timeout_ms); // sleep until something is ready
for (int i = 0; i < n; ++i) {
if (events[i].data.fd == listen_fd) accept_all_pending(); // accept4(... SOCK_NONBLOCK)
else handle_readable(events[i].data.fd);
}
}
select/pollpass the whole fd list on every call: O(n) per wait.epollkeeps the interest list in the kernel and returns only ready fds: cost proportional to activity.- Level-triggered (default): reported on every
epoll_waitwhile the condition holds. Easy. Edge-triggered (EPOLLET): reported once per change; you must read untilEAGAINor you will never hear about the leftover data. Faster, easier to get wrong. - All fds must be non-blocking: a blocking
readin an event loop stalls every client. - Writes: when
writereturnsEAGAINor a short count, keep the rest in a per-connection buffer, addEPOLLOUT, and continue when writable. - Windows mapping: I/O Completion Ports are completion-based (the kernel does the read and
tells you it finished); epoll is readiness-based (the kernel says you can read now without
blocking). Linux's completion-based interface is
io_uring.
code/m11_epoll_preview.c is a complete level-triggered echo server. The full event loop with
output buffers, timers and edge-triggered mode is
08_Networking_Distributed/06_nonblocking_io_epoll_event_loops.md.
2.9 Resource limits: ulimit¶
Every process has soft and hard limits (getrlimit/setrlimit), inherited by children.
ulimit -a # all soft limits of this shell
ulimit -n # open files: often 1024 soft (Ubuntu default)
ulimit -Hn # hard limit: the ceiling a non-root process may raise the soft limit to
ulimit -n 65536 # raise for this shell and its children
ulimit -c unlimited # allow core dumps (05_Memory_Debugging_RCA/06_core_dumps_on_linux.md)
cat /proc/$(pgrep -n tcpkv)/limits # the limits of a RUNNING process
prlimit --pid PID --nofile=65536:65536 # change them on a live process
The one that bites servers is RLIMIT_NOFILE. One TCP connection is one fd. With a soft limit of
1024, the 1021st client makes accept fail with EMFILE. Raise it where the process is started:
LimitNOFILE= in the systemd unit, --ulimit nofile= for Docker, /etc/security/limits.conf
for login sessions (it does not apply to systemd services). Other useful ones: RLIMIT_CORE
(core size), RLIMIT_AS (address space: makes malloc return NULL, good for testing out-of-memory
paths), RLIMIT_NPROC (processes/threads per user), RLIMIT_STACK (main thread stack, 8 MiB
default).
2.10 Memory overcommit and the OOM killer¶
Linux by default overcommits: malloc(1 GiB) succeeds even if there is not 1 GiB free, because
memory is only allocated when pages are first touched. If the system then runs out of RAM and swap
while processes touch their pages, the kernel cannot fail a malloc that already succeeded. It
picks a victim process and sends it SIGKILL. That is the OOM killer.
- Victim choice: the process with the highest
oom_score(/proc/PID/oom_score), roughly its share of memory, adjusted byoom_score_adj(-1000 = never kill, +1000 = kill first). systemd'sOOMScoreAdjust=sets it for a service. - Evidence: the process vanishes with exit status 137 (128 + 9);
dmesgorjournalctl -kshowsOut of memory: Killed process 1234 (tcpkv) total-vm:... anon-rss:.... There is no core dump and no log line from your program: SIGKILL cannot be caught. - cgroup limits (Docker
--memory, systemdMemoryMax=, Kubernetes limits) give each group its own OOM: the kernel kills inside the group that exceeded its limit, not a random process on the host.docker inspectthen showsOOMKilled: true(module 08). vm.overcommit_memory: 0 heuristic (default), 1 always, 2 strict accounting (thenmalloccan really return NULL). Databases sometimes recommend 2.- Windows mapping: Windows commits memory at allocation time against the commit limit (RAM +
page file), so
VirtualAlloc/mallocfails up front and there is no OOM killer. That is a real difference to mention in interviews.
2.11 systemd: running a server as a service¶
systemd is PID 1 on Ubuntu: it starts services at boot, supervises them, restarts them, collects
their output in the journal, and puts each one in a cgroup. A unit file describes one service.
The tcpkv unit, code/tcpkv/deploy/tcpkv.service (abridged):
[Unit]
Description=tcpkv TCP key-value server
Wants=network-online.target
After=network-online.target
StartLimitIntervalSec=60 # crash-loop guard: more than 5 starts in 60 s -> failed
StartLimitBurst=5
[Service]
Type=exec # foreground process; "started" once exec succeeded
ExecStart=/usr/local/bin/tcpkv 7000
User=tcpkv
Group=tcpkv
Restart=on-failure # restart on crash or non-zero exit, not on clean stop
RestartSec=2
KillSignal=SIGTERM
TimeoutStopSec=30 # SIGTERM, wait 30 s, then SIGKILL
StandardOutput=journal
StandardError=journal
LimitNOFILE=65536 # ulimit -n for the service
LimitCORE=infinity
MemoryMax=512M # cgroup memory cap: OOM inside this service only
NoNewPrivileges=yes
ProtectSystem=strict # whole filesystem read-only for this service
ProtectHome=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.target # 'enable' hooks it into normal boot
The commands:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin tcpkv
sudo cp deploy/tcpkv.service /etc/systemd/system/
sudo systemctl daemon-reload # after ANY unit file edit
sudo systemctl enable --now tcpkv # start now and at boot
systemctl status tcpkv # active?, PID, memory, CPU, last 10 log lines
journalctl -u tcpkv -f # follow logs
journalctl -u tcpkv -b -p warning # this boot, warnings and worse
journalctl -u tcpkv --since "2026-10-12 03:00" --until "03:15"
systemctl show tcpkv -p NRestarts,ExecMainStatus,Result
sudo systemctl edit tcpkv # drop-in override without editing the original
systemd-analyze security tcpkv # exposure score for the sandboxing options
Restart policies:
Restart= |
Restarts when |
|---|---|
no (default) |
never |
on-failure |
non-zero exit, killed by a signal (except clean stop signals), timeout, watchdog |
on-abnormal |
signal, timeout, watchdog; not on non-zero exit |
always |
any exit, even exit 0 (but not after systemctl stop) |
on-failure is right for a server whose clean exit means "someone asked me to stop". Docker's
restart: unless-stopped is the equivalent of Restart=always (module 08).
Logging. Under systemd, a service writes plain lines to stdout/stderr and journald stores
them with timestamp, unit, PID and priority. No log files, no rotation code, no syslog() needed.
Prefix a line with <3> to mark it as error priority (sd-daemon(3)), or use sd_journal_send
for structured fields. tcpkv's log_line writing to stderr is exactly right.
Type= matters: simple/exec for programs that stay in the foreground (all modern servers),
forking for old-style daemons that double-fork and write a PID file (do not write new ones),
notify for programs that call sd_notify("READY=1") when they are really ready to serve, which
lets dependent units wait for real readiness. Windows mapping: a systemd unit is a Windows service
registration (sc create), Restart= is the Recovery tab, journald is the Event Log,
User= is "Log on as".
WSL note: Ubuntu 24.04 on WSL runs systemd by default. If systemctl reports "System has not been
booted with systemd", put [boot] and systemd=true in /etc/wsl.conf and run wsl --shutdown.
2.12 strace as your syscall microscope¶
Module 07 introduced strace. Here it is the tool that makes this module concrete:
strace -f ./m11_syscalls spawn # clone, execve in the child, pipe2, dup2, wait4
strace -e trace=openat,read,write,fsync,close ./m11_syscalls fileio /tmp/a.txt
strace -e trace=%signal,poll,timerfd_settime ./m11_syscalls signals # then kill -TERM it
strace -c ./m11_syscalls mmap /etc/services # count: compare with reading the file in a loop
strace -f -e trace=epoll_wait,accept4,read,write ./epoll_echo 7100
strace -f -tt -T -p $(pgrep -n tcpkv) # what is the live server doing, with timings
Reading a line: openat(AT_FDCWD, "/tmp/a.txt", O_WRONLY|O_CREAT|O_TRUNC|O_CLOEXEC, 0644) = 3 is
the call, its decoded arguments and the result. A failure looks like = -1 ENOENT (No such file or
directory). <0.000021> with -T is the time spent inside the call. Note that glibc's open
is the openat syscall, and fork is clone; the wrapper names and the syscall names differ.
3. Code¶
3.1 code/m11_syscalls.c¶
Four demos in one C file, every return value checked:
gcc -std=c11 -Wall -Wextra -Werror -O2 -g m11_syscalls.c -o m11_syscalls
./m11_syscalls fileio /tmp/m11.txt # open/write/fsync/close, read back, ENOENT demo
./m11_syscalls mmap /etc/services # map a file and count lines without read()
./m11_syscalls spawn # pipe2 + fork + dup2 + execlp + waitpid
./m11_syscalls signals # self-pipe + timerfd + poll; Ctrl-C or kill -TERM PID
The heart of the signals demo:
static int g_sigpipe_w = -1; /* write end of the self-pipe */
/* write() is async-signal-safe; printf, malloc and mutexes are not.
* Save and restore errno because the interrupted code may be about to read it. */
static void on_signal(int signo) {
const int saved = errno;
const unsigned char b = (unsigned char)signo;
ssize_t ignored = write(g_sigpipe_w, &b, 1);
(void)ignored;
errno = saved;
}
/* main loop: one poll() over the self-pipe and a timerfd */
struct pollfd pfds[2] = {{.fd = sp[0], .events = POLLIN}, {.fd = tfd, .events = POLLIN}};
for (;;) {
const int pr = poll(pfds, 2, -1);
if (pr < 0) {
if (errno == EINTR) continue; /* the self-pipe will tell us which signal */
die("poll");
}
if (pfds[0].revents & POLLIN) { /* read the signal number; now printf and cleanup are fine */ }
if (pfds[1].revents & POLLIN) { /* read a uint64_t expiration count from the timerfd */ }
}
The file I/O demo shows a detail people get wrong: printf output is buffered in user space and
write(STDOUT_FILENO, ...) is not, so mixing them reorders output unless you fflush(stdout) first.
The same buffering is why a child created by fork without a flush prints the parent's pending
output twice, and why the child must leave with _exit, not exit.
3.2 code/m11_epoll_preview.c¶
gcc -std=c11 -Wall -Wextra -Werror -O2 -g m11_epoll_preview.c -o epoll_echo
./epoll_echo 7100 &
for i in 1 2 3; do (echo "hello $i"; sleep 1) | nc localhost 7100 & done; wait
kill -INT %1
The accept path drains every pending connection, because one readiness event can stand for many:
if (fd == lfd) {
for (;;) {
const int cfd = accept4(lfd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC);
if (cfd < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) break; /* none left */
if (errno == EINTR) continue;
perror("accept4"); /* EMFILE: log, keep serving */
break;
}
struct epoll_event cev = { .events = EPOLLIN | EPOLLRDHUP, .data.fd = cfd };
epoll_ctl(ep, EPOLL_CTL_ADD, cfd, &cev);
}
}
3.3 code/tcpkv/src/main.cpp revisited¶
Read tcpkv's main now with this module's vocabulary: signal(SIGPIPE, SIG_IGN) plus
MSG_NOSIGNAL (get EPIPE, do not die); sigaction without SA_RESTART for SIGTERM/SIGINT
and a volatile std::sig_atomic_t flag (pattern 1); poll with a 500 ms timeout so the flag is
noticed even if the signal went to another thread; SOCK_CLOEXEC on every socket; accept4
failures such as EMFILE logged without exiting; a bounded drain on shutdown so
TimeoutStopSec=30 and docker stop's 10 seconds are never reached.
3.4 code/tcpkv/deploy/tcpkv.service¶
The full unit with comments. CMake installs it (install(FILES deploy/tcpkv.service ...),
module 04). Try it on WSL:
cmake --preset release && cmake --build --preset release
sudo cmake --install build/release --prefix /usr/local --strip
sudo useradd --system --no-create-home --shell /usr/sbin/nologin tcpkv
sudo cp deploy/tcpkv.service /etc/systemd/system/ && sudo systemctl daemon-reload
sudo systemctl enable --now tcpkv
printf 'PING\n' | nc -N localhost 7000 # PONG
sudo kill -SEGV $(systemctl show -p MainPID --value tcpkv) # simulate a crash
sleep 3; systemctl status tcpkv # restarted; NRestarts=1
journalctl -u tcpkv -n 20
4. Under the hood (senior-level)¶
What a syscall instruction does. On x86-64, glibc's read wrapper puts the syscall number
(0 for read) in rax and arguments in rdi, rsi, rdx, r10, r8, r9, then executes syscall.
The CPU switches to ring 0 and jumps to the kernel's entry point, which saves registers, looks up
sys_call_table[rax], runs ksys_read, and returns with the result in rax. A negative value
between -4095 and -1 is an error; the wrapper negates it into errno and returns -1. Some calls
(clock_gettime, gettimeofday, getcpu) never enter the kernel: the vDSO is a small shared
library the kernel maps into every process that reads the time from a shared page. That is why
strace does not show clock_gettime and why it costs tens of nanoseconds (typical).
The fd table. Each process has a table of pointers to open file descriptions (struct file
in the kernel), which hold the file offset and status flags and point to the inode. dup2 and
fork copy pointers, so parent and child share the offset: if both write to an inherited fd
without O_APPEND, their writes interleave at one shared position. open twice creates two
descriptions with independent offsets. O_CLOEXEC is a flag on the fd-table slot, not the
description. Closing the last fd referring to a description releases it; for a socket, that
sends FIN.
Page cache and writeback. read and write go through the page cache: 4 KiB pages of file
content kept in otherwise free RAM. write marks pages dirty; the flusher threads write them
after dirty_expire_centisecs (30 s default) or when dirty memory exceeds dirty_background_ratio
(10 % default); a writer that produces data faster than the disk absorbs it is throttled in
write itself. fsync forces writeback of that file and issues a cache-flush command to the
device. fsyncgate (2018): PostgreSQL discovered that after a writeback error, Linux could mark
the pages clean and a retried fsync returned success while the data was lost. The fix in
PostgreSQL was to treat an fsync failure as fatal (PANIC and recover from the WAL). Lesson: an
fsync error is not retryable.
fork and copy-on-write. fork copies the page tables, not the pages, and marks every writable
page read-only in both processes. The first write by either side triggers a page fault; the
kernel copies that one page and makes it writable. A 10 GiB process can fork in milliseconds, but
copying its page tables is still proportional to its size, and a parent that keeps writing after
fork doubles its memory gradually. Redis's background save relies on exactly this behaviour.
Signal delivery. When a signal is sent, the kernel sets a bit in the target's pending set.
On the next return from kernel to user mode (after a syscall, an interrupt or a reschedule), it
checks for pending unblocked signals and, for a caught signal, builds a signal frame on the user
stack (saved registers, siginfo), points the instruction pointer at the handler and returns to
user mode. When the handler returns, it lands in a small trampoline that calls rt_sigreturn,
which restores the saved registers. If the thread was blocked in an interruptible syscall, the
syscall is aborted with EINTR or restarted per SA_RESTART. Standard signals do not queue: two
SIGCHLDs before you handle one become one, which is why a SIGCHLD handler must loop
waitpid(-1, ..., WNOHANG) until it returns 0.
epoll internals. epoll_ctl(ADD) inserts the fd into a red-black tree in the epoll instance
and registers a callback on the fd's wait queue. When the socket receives data, the network stack
wakes its wait queue; the callback appends the item to the epoll ready list and wakes threads
in epoll_wait. epoll_wait copies out ready items; for level-triggered items it puts them back
on the list if still ready. That is why epoll_wait is O(ready) and not O(registered). The
interest is registered on the open file description, not the fd number: closing an fd whose
description is still referenced elsewhere (a dup or a forked child) leaves the registration
alive and produces events for an fd you no longer have, a classic bug.
systemd and cgroups. Every unit gets its own cgroup under /sys/fs/cgroup/system.slice/
tcpkv.service/. MemoryMax= writes memory.max, TasksMax= writes pids.max, CPU options
write cpu.max. Because systemd tracks the service by cgroup and not by PID, it finds and kills
every process the service forked on stop, which PID files never could. KillMode=mixed sends
SIGTERM to the main process and SIGKILL to the rest after the timeout. The journal reads the
service's stdout/stderr from a socket systemd connected to fds 1 and 2 before exec, so it knows
which unit each line came from. systemd-cgls shows the tree; systemd-cgtop shows usage.
5. In production¶
- The fd leak. The server works for days, then every new connection fails and logs show
accept: Too many open files.ls /proc/PID/fd | wc -lsits at exactly theLimitNOFILEvalue;ls -l /proc/PID/fdshows thousands of sockets in CLOSE_WAIT (ss -tanp): the code path for one error forgot toclose. Raising the limit buys time; RAII (a C++unique_fdclass, orstd::unique_ptrwith a custom deleter) fixes it. Your C server'sgoto cleanupdiscipline is the C version of the same thing. - Missing fsync. A service acknowledged writes after
write()only. A host reboot lost the last 30 seconds of "committed" data. In the banking context this is the difference between the WAL being durable and not; it is why PostgreSQL'ssynchronous_commitandfsyncsettings exist. Ask "where is the fsync?" in every design review of something that persists data. - Logging from a signal handler. A crash handler that calls
printfor the logging library to print a stack trace works in testing and deadlocks in production when the crash happens insidemalloc. Write a minimal message withwrite(2, ...), then let the core dump carry the details (05_Memory_Debugging_RCA/06_core_dumps_on_linux.md). - Fork in a threaded server. Calling
system()orpopen()from a multithreaded server to run a helper script occasionally hangs the child forever (a lock held by another thread at fork time), and doubles RSS momentarily for large processes. Useposix_spawn, or a small helper process started early, before any threads exist. - Silent OOM kills. The service restarts every few hours with no error in its logs.
systemctl statusshowsstatus=9/KILL,journalctl -k | grep -i oomshows the kill, andsystemctl show -p MemoryMaxshows the cap. Find the growth with heap profiling (05_Memory_Debugging_RCA/09_leaks_and_memory_growth.md), not by raising the limit. - SIGPIPE killed the server. A client disconnects mid-response, the next
sendraises SIGPIPE, the process dies with status 141 and no log line. Ignore SIGPIPE at startup and useMSG_NOSIGNAL; every network server on Linux does this. On Windows the equivalent failure just returnsWSAECONNRESET, so this one surprises Windows developers. - Restart loops.
Restart=alwayswithRestartSec=100msand a configuration error produces thousands of restarts per minute, floods the journal, and hides the first error. Useon-failure, aRestartSecof seconds, andStartLimitBurstso the unit ends upfailedand alerts fire. - 3 AM: after a deploy,
systemctl restart tcpkvtakes exactly 30 seconds and then the service starts. The new code blocks SIGTERM by accident (a library calledsigprocmaskin its init) so the stop always runs intoTimeoutStopSecand SIGKILL.grep Sig /proc/PID/statusshowsSigBlkwith bit 15 set (decode the hex mask). Every restart was dropping in-flight requests.
6. Try it¶
- (easy) Run each
m11_syscallsdemo understrace -f. Done when you can name the syscall behind the glibc wrappersopen(openat),fork(clone) andwaitpid(wait4), and explain whyclock_gettimedoes not appear in any trace. - (easy) Run
ulimit -n 64in a shell, start./build/debug/tcpkv 7000from it, and open 100 connections with a small Python loop. Done when the server log showsaccept: Too many open files, the server keeps serving existing clients, andcat /proc/PID/limitsconfirms the limit. - (medium) Remove the
fsyncfrom the fileio demo and measure both versions writing 10,000 small records withhyperfine. Then write the atomic-replace version (tmp,fsync,rename,fsyncof the directory). Done when you can state the cost offsyncon your disk and explain why the directory needs its ownfsync. - (medium) Write
mini_shell.cthat reads a command line, splits on spaces, supports one|and one> file, and runs it withfork,pipe,dup2,execvpandwaitpid, printing the exit status or signal. Done whenls -l | wc -landecho hi > /tmp/xwork, no zombies remain (ps -o stat), andstrace -fshows the expecteddup2calls. - (medium) Install tcpkv as a systemd service on WSL with the unit from
deploy/. Kill it with SIGSEGV three times, then six times within a minute. Done when you can showNRestartsfromsystemctl show, the unit infailedstate after the burst limit, and the relevant journal lines, and you can explainStartLimitBurst. - (hard) Turn
m11_epoll_preview.cinto a tcpkv server: non-blocking sockets, a per-connection input buffer split into lines, a per-connection output buffer flushed onEPOLLOUT, the existing C++parse_commandandStore, and asignalfdfor SIGTERM. Done whenscripts/tcpkv_client.py --selftestandscripts/load_test.sh 7000 64 5000pass against it, andstrace -cshows one thread doing all the work.
7. Interview questions¶
Q: What is the difference between a system call and a library function?
A system call transfers control to the kernel through a CPU instruction to do something only the
kernel can do (I/O, process creation, memory mapping). A library function runs in user space and
may make zero or more syscalls: printf buffers and calls write occasionally, malloc calls
brk or mmap only when its arenas grow. strace shows the former, ltrace the latter.
Q: Does write() returning success mean the data is on disk?
No. It means the kernel copied it into the page cache. It reaches the device later through
writeback. For durability call fsync (or fdatasync) on the file, and fsync the directory
after creating or renaming files. Treat an fsync error as fatal for that data, because the
kernel may have dropped the dirty pages.
Q: What can you safely do in a signal handler?
Only async-signal-safe operations: set a volatile sig_atomic_t flag, write to a pipe or
eventfd, _exit. No malloc, printf, locks or C++ that might allocate, because the handler may
have interrupted the same code holding the same lock. Save and restore errno. Do the real work
in normal context via a flag, the self-pipe trick, or signalfd.
Q: Explain fork and exec. Why are they separate?
fork duplicates the process with copy-on-write memory and duplicated fds; exec replaces the
program image in the current process. Separating them lets the child set up its environment
(redirect fds, change directory, drop privileges) with ordinary calls before running the new
program, which is how shells implement redirection and pipes. In multithreaded programs only
async-signal-safe calls are allowed between them; posix_spawn is the safer single call.
Q: What is EINTR and how do you handle it?
A blocking syscall interrupted by a caught signal returns -1 with errno == EINTR. Retry the
call in a loop, or use the interruption to check a shutdown flag. SA_RESTART restarts many
calls automatically but never poll, epoll_wait or select.
Q: Why is epoll more scalable than select or poll?
select and poll pass the full fd set on every call and the kernel scans it, so each wait is
O(n) in watched fds. epoll registers interest once in a kernel object and maintains a ready list
filled by callbacks, so epoll_wait costs O(ready). Level-triggered reports while data remains;
edge-triggered reports changes only and requires draining to EAGAIN.
Q: A process disappeared with exit code 137 and no logs. What happened?
It received SIGKILL (128 + 9), most often from the OOM killer (system or cgroup limit) or a stop
timeout escalating from SIGTERM. Check journalctl -k or dmesg for "Out of memory: Killed
process", docker inspect for OOMKilled, or systemctl status for the stop timeout. SIGKILL
cannot be caught, so the program never logs it.
Q: How do you run a C++ server as a service on Linux?
A systemd unit: ExecStart in the foreground, a dedicated User=, Restart=on-failure with a
RestartSec and start-limit burst, TimeoutStopSec matching the graceful-drain time,
LimitNOFILE for connections, MemoryMax for a cgroup cap, sandboxing options, logs to
stdout/stderr collected by journald and read with journalctl -u. The server handles SIGTERM by
draining and exiting 0.
8. Recap¶
- Syscalls enter the kernel (
man 2); library calls may or may not make syscalls (man 3).stracesees syscalls. - Check the return value, then copy
errnoimmediately; know EINTR, EAGAIN, EMFILE, EPIPE, ENOSPC on sight. read/writecan be short: loop.writeis not durable:fsyncthe file and the directory;renameis atomic.mmapmaps files or anonymous memory; lazy page faults; I/O errors arrive as SIGBUS.fork(copy-on-write) +exec+waitpid; close unused pipe ends;_exitin the child;posix_spawnin threaded programs.- Signal handlers: only async-signal-safe calls; flag, self-pipe or
signalfd; saveerrno; ignore SIGPIPE. - Timers:
CLOCK_MONOTONICfor durations,timerfdor the epoll timeout for event loops. - epoll: register once, wait returns ready fds; non-blocking fds; level vs edge triggered.
ulimit -ncaps connections; setLimitNOFILEin the unit;/proc/PID/limitsshows the live values.- Overcommit plus OOM killer: SIGKILL, exit 137, evidence in
journalctl -k; cgroup limits contain it. - systemd unit:
Type=exec,User=,Restart=on-failure,TimeoutStopSec,LimitNOFILE,MemoryMax, journald logs.