Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions _docs/03-observability-stack.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,46 @@ description: "Stand up the Grafana LGTM all-in-one container with podman compose
duration: "30 minutes"
---

## Learning objectives

By the end of this chapter you can:

- Stand up the Grafana `grafana/otel-lgtm` all-in-one container with `podman
compose`, pinned to a fixed image tag for reproducible output
- Enable the `podman.socket` systemd user unit so `podman compose` has a
Docker API endpoint to delegate to
- Explain OTLP's two transports (gRPC on `:4317`, HTTP on `:4318`) and why
this book standardizes on the curl-able HTTP path
- Post a minimal OTLP/JSON trace to `:4318/v1/traces` with `curl` and query
it back through Grafana's Tempo datasource proxy
- Declare an example's telemetry dependency with `requires: [lgtm]` and
explain why the harness marks a missing backend SKIP, never FAIL
- Reach the host's LGTM stack from inside a lab VM through the libvirt
gateway address and forward `OTEL_ENDPOINT` past `sudo`'s environment strip
- Cross-check `podman compose`'s claims independently with `podman ps` and
Grafana's own `/api/health` endpoint

## Why this matters

Every language runtime and every syscall wrapper in this book eventually
needs somewhere to send what it observes about itself: a span, a counter, a
log line. Without a backend that telemetry is just `printf` with extra
ceremony, read once and discarded. Standing up the pipeline here, before any
instrumented example exists, means every later chapter can treat
"telemetry works" as a given rather than a per-chapter chore, and it means
the choice of transport (OTLP over HTTP) and the shape of the check
(`requires: [lgtm]`, skip-not-fail) get decided once instead of twenty-six
times.

It also previews a discipline that recurs constantly in systems programming:
building the smallest thing that gives you the real interface. A production
observability stack is four independently scaled services; a workbench needs
the interface those services expose, not their scale. Knowing how to collapse
infrastructure to its essential shape, and verifying the collapse with the
same tools (`curl`, `podman ps`) you would use against the real thing, is a
skill this book leans on again when the lab VMs and later the page cache
force the same kind of judgment call.

Chapter 1 gave you three toolchains; Chapter 2 gave you two disposable VMs.
This chapter adds the last piece of the workbench: somewhere for telemetry to
*go*. Many chapters in this book emit metrics, traces, or logs (a syscall
Expand Down
41 changes: 41 additions & 0 deletions _docs/04-syscalls-and-the-abi.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,47 @@ description: "What a system call really is: the syscall instruction, the registe
duration: 40 minutes
---

## Learning objectives

By the end of this chapter you can:

- State the x86-64 syscall register ABI: the syscall number in `rax`,
arguments in `rdi rsi rdx r10 r8 r9`, and the result or `-errno` back in
`rax`, including why `r10` stands in for the C ABI's `rcx`
- Trace a syscall's round trip from the `syscall` trap instruction through
`entry_SYSCALL_64` and `sys_call_table` dispatch back to ring 3
- Identify vDSO-served calls (`clock_gettime`, `vgetrandom`) that never trap,
using `/proc/self/maps` and `strace -c` counts
- Compare how glibc wrappers, the Go runtime's own stubs, and rustix's
`linux_raw` backend each reach the kernel, using `objdump`, `ldd`, and
`strace`
- Build and run `sysprobe`, a four-step syscall demonstrator (open, write,
sleep, random) implemented identically in C++23, Go, and Rust
- Read `strace` output closely enough to spot wrapper-layer substitutions
(`nanosleep` traced as `clock_nanosleep`) and partial vDSO interception
- Attribute a binary's total syscall count, via `strace -c`, to runtime
startup overhead versus the four steps of actual application work

## Why this matters

Every abstraction in this book, RAII wrappers, error types, concurrency
primitives, sits on top of the same fact: a user-space program can only ask
the kernel for anything by trapping into it, and that trap is a register
convention, not a function call. Until that boundary is precise in your head,
every later claim about performance, safety, or portability is resting on a
guess. This chapter replaces the guess with instruments: a register-level
description of the trap, and three ways of watching it happen (`strace`,
`objdump`, `/proc/self/maps`) that stay in your toolkit for the rest of the
book.

It also sets up the book's central comparative method. C++, Go, and Rust are
not just three syntaxes for the same program; they are three different
relationships with the kernel ABI, from zero inlined `syscall` instructions
to 511 to 32. Seeing that difference now, with a trivial four-step program,
means later chapters on concurrency, I/O multiplexing, and signals can point
at a runtime behavior and explain it in terms you already have, instead of
treating each language's quirks as unrelated trivia.

Chapter 1 ended on a number it did not explain: the C++ hello made 64 syscalls,
the Go hello made 217, and we waved at "the runtime" and moved on. This chapter
is where that debt gets paid. With the lab and observability stack built, Part
Expand Down
41 changes: 41 additions & 0 deletions _docs/05-errors-three-ways.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,47 @@ description: "One error's journey from a failing write(2) into std::expected, a
duration: "45 minutes"
---

## Learning objectives

By the end of this chapter you can:

- State the kernel's error contract (a failing syscall returns `-errno` in
`rax`) and explain how glibc's thread-local `errno`, Go's `unix.Errno`
value, and rustix's `Errno` wrapper each deliver that same integer
- Implement `copyx`'s error taxonomy, distinct exit codes for source-side
versus destination-side failures, using `std::expected`, a wrapped Go
`error`, and a `thiserror` enum
- Apply the book's EINTR-retry and short-write-resume policies inside
`read`/`write` loops in all three languages
- Draw the values-versus-exceptions line per language: `std::expected`
versus exceptions at subsystem boundaries in C++, `error` versus `panic`
in Go, `Result` versus an abort-only `panic!` in Rust
- Reproduce `ENOSPC` deterministically with `/dev/full` and confirm
byte-identical error messages and exit codes across the three binaries
- Use `strace` to catch the exact failing `write(2)` syscall, before any
runtime has dressed the failure up as a language-level error

## Why this matters

A syscall failure is not an exceptional event the kernel escalates to you;
it is an ordinary return value that your language's runtime chooses how to
represent, and that choice has consequences for every program you write
afterward. `copyx` turns that choice into something you can hold in your
hands: one file copier, one kernel fact (`ENOSPC` is 28, no more and no
less), three
type systems arguing about what to do with it, and one shared contract
(exit codes, message format, retry policy) that makes the disagreement
irrelevant from the outside.

That contract is not local to this chapter. Every program in the rest of the
book, the concurrent hasher in Chapter 6, the file watcher built across Part
2, the benchmark in Chapter 8, inherits the EINTR-retry and short-write-resume
rules established here, and every one of them reaches for the same
values-versus-exceptions line when a new failure mode shows up. Getting the
taxonomy right once, with a program small enough to read start to finish,
is what lets later chapters spend their words on new material instead of
re-litigating what a correct error policy looks like.

Chapter 4 walked a syscall down to the boundary and back, and ended on the
detail this chapter is built from: when a syscall fails, the kernel does not
throw, log, or explain: it puts a small negative integer in a register and
Expand Down
45 changes: 45 additions & 0 deletions _docs/06-concurrency-three-ways.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,51 @@ description: "The second thread that runs the whole book: how each language shar
duration: 50 minutes
---

## Learning objectives

By the end of this chapter you can:

- Compare C++23's `jthread`/`stop_token`/`condition_variable_any`, Go's
goroutines with channels and `context.Context`, and Rust's scoped threads
with `Send`/`Sync` as three vocabularies over the same `clone(2)`/`futex(2)`
kernel primitives
- Build `parhash`, a four-worker parallel file hasher that produces
byte-identical stdout across all three languages despite each one walking
the tree in a different order
- Implement safe cross-thread SIGINT cancellation per language: blocking the
signal and consuming it with `sigtimedwait` in C++, `signal.NotifyContext`
in Go, and a `signal_hook`-driven `AtomicBool` in Rust
- Explain why `mpsc::Receiver` is not `Sync`, why that forces
`Arc<Mutex<Receiver>>`, and how `thread::scope` lets workers borrow stack
data without reference counting
- Verify a cancellation-drain contract live, stop accepting new work, finish
what is in flight, report what completed, exit 130, by sending a real
SIGINT mid-run
- Inspect a running process's threads with `/proc/<pid>/task` and each
thread's `wchan` to confirm all three language models reduce to the same
kernel threads

## Why this matters

Every language in this book claims to make concurrency easier, and each one
is telling the truth about a different part of the problem. C++ gives you
the raw parts and trusts you to assemble a correct queue and signal
discipline; Go gives you a runtime that hides the threads entirely behind
channels and a scheduler; Rust gives you a compiler that refuses to let a
shared-mutation bug type-check. `parhash` makes that difference concrete
instead of rhetorical: the same four-worker pool, three times, with
`/proc/<pid>/task` proving that underneath the vocabulary it is always
`clone(2)` threads parked on `futex(2)`.

This chapter is also where the book's two permanent threads meet: Chapter 5's
error policy has to survive concurrent execution, and the cancellation drain
built here, stop accepting, finish what you hold, report what completed, is
the pattern every later concurrent example reuses, from the signal handling
in Part 7 to the event loops in Chapter 27. Understanding *why* each language
reaches for its own idiom here, rather than just copying the syntax, is what
makes those later chapters readable at a glance instead of a fresh puzzle
each time.

Chapter 5 pinned down the first of this book's two permanent threads: how each
language turns an `errno` into something a caller can act on. This chapter
pins down the second, how each language *shares work*, and it does it the
Expand Down
39 changes: 39 additions & 0 deletions _docs/07-fds-raii-and-the-vfs.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,45 @@ description: "The three-layer model behind every file descriptor (fd table, open
duration: 45 minutes
---

## Learning objectives

By the end of this chapter you can:

- Explain the three-layer fd model: the per-process fd table (home of the
close-on-exec bit), the system-wide open file description (flags and
offset), and the VFS inode (type, size, mtime)
- Justify `O_CLOEXEC` at open time as the only race-free way to stop an fd
leaking into a `fork`/`exec`'d child, versus a later `fcntl` call
- Walk a directory tree safely with `openat`/`fstatat` anchored on a
directory fd, avoiding the re-resolution races a path-based walk invites
- Build and compare owning fd wrappers across languages: a move-only C++
`Fd` class with an explicit `release()`, Go's `os.File` with a deferred
`Close`, and Rust's `OwnedFd`/`BorrowedFd`
- Implement `fwatch` v0, a deterministic snapshot/diff tool, identically in
C++23, Go, and Rust, including its `+`/`-`/`~` diff format
- Verify fd-table, open-file-description, and inode claims independently
using `/proc/<pid>/fd`, `/proc/<pid>/fdinfo`, `strace`, `ls -li`, and `lsof`

## Why this matters

Most bugs involving file descriptors come from treating "the fd" as a single
thing when it is really three: a slot in your process, a shared kernel
object, and the file itself. Conflating them is how a `dup`'d descriptor
surprises you by sharing an offset, how a privileged fd leaks across `exec`,
or how a deleted file keeps its storage alive because something still holds
it open. This chapter gives you the vocabulary to say precisely which layer
a bug lives on, and the tools (`/proc/<pid>/fd`, `fdinfo`, `strace`) to prove
it rather than guess.

That precision also opens the book's second long-running artifact. `fwatch`
starts here as a plain snapshot/diff tool with no events at all, by design:
before it can react to filesystem changes in later chapters (inotify, epoll,
io_uring, and eventually a Landlock sandbox), it needs an owning fd
abstraction and a race-free directory walk that will not need to be rebuilt
once real concurrency and real kernel notifications enter the picture. The
RAII-style wrapper and the anchored `openat`/`fstatat` walk built here are
required by every version of `fwatch` that follows.

This chapter starts the second of the book's long-running artifacts: `fwatch`,
the file watcher that will grow inotify, epoll, io_uring, and finally a
Landlock sandbox over the next several chapters. Version 0 has no events at
Expand Down
42 changes: 42 additions & 0 deletions _docs/08-page-cache-and-durability.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,48 @@ description: "What write(2) actually promises, and what durability costs: dirty
duration: 45 minutes
---

## Learning objectives

By the end of this chapter you can:

- Explain what a successful buffered `write(2)` actually guarantees: a
dirty page in the page cache, visible to readers, nothing about the device
- Distinguish `fsync`, `fdatasync`, and `sync_file_range` by exactly what
each flushes, and explain why `sync_file_range` is not a durability
primitive despite touching the device
- Use `O_DIRECT` correctly: respect its block-alignment requirements and
treat an `EINVAL` at `open(2)` as a documented, filesystem-dependent
outcome rather than a generic error
- Implement the tmpfile → `fsync` → `rename` → `fsync(dir)` pattern for
crash-safe file replacement
- Build and run `iobench`, a tri-language write benchmark with buffered,
fsync-every, and direct modes, and read its `MiB/s` and `fsync_ms` output
as two different claims
- Measure writeback and durability cost independently of the benchmark's own
numbers, using `/proc/meminfo`'s `Dirty:` counter, `/proc/<pid>/io`, and
`strace -c`

## Why this matters

"The write succeeded" is one of the most quietly overloaded sentences in
systems programming. A successful `write(2)` return code says only that your
bytes reached kernel memory; whether they survive a power loss a second later
depends on a sync call you have to ask for explicitly, and getting that
choice wrong is how real databases lose real data after passing every test
that never pulled the power cord. This chapter turns that gap from folklore
into a measured quantity: 21 ms to "write" 64 MiB, 138 ms to write it
durably, watched directly in `/proc/meminfo` and `/proc/<pid>/io` rather than
taken on faith.

It also closes the loop Chapter 7 opened. That chapter gave you a precise
model of what a file descriptor is; this one gives you a precise model of
what happens to the bytes you push through it, which is the other half of
"I wrote a file" that most introductions skip. The durability tools
introduced here, `fsync`/`fdatasync` semantics, the atomic-rename pattern,
`O_DIRECT`'s tradeoffs, are exactly the primitives Part 2's remaining
chapters (event loops, and eventually `fwatch`'s own durability story) build
on next.

Chapter 7 ended with a file descriptor safely wrapped in RAII and a byte
successfully written through the VFS. This chapter is about the uncomfortable
question that follows: written *where*? A successful `write(2)` puts your
Expand Down
17 changes: 17 additions & 0 deletions _docs/09-event-loops-epoll-inotify.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,23 @@ description: "The readiness model: nonblocking fds multiplexed by one epoll loop
duration: 50 minutes
---

## Learning objectives

By the end of this chapter you can:

- Register nonblocking file descriptors with `epoll_create1`/`epoll_ctl` and explain why a kernel-side interest list lets `epoll_wait` scale where `poll`/`select` cannot.
- Distinguish level-triggered from edge-triggered (`EPOLLET`) readiness, state the drain-to-`EAGAIN` discipline each requires, and identify when `EPOLLONESHOT` applies.
- Build an inotify watch with `inotify_init1`/`inotify_add_watch`, parse packed `inotify_event` records, and recognize `IN_Q_OVERFLOW` as the signal to fall back to a snapshot/diff rescan.
- Fold `timerfd` and `signalfd` into one epoll loop as ordinary readable fds, including the one-shot re-arm pattern and the block-then-read signalfd prerequisite.
- Compare the explicit C++/Rust single-threaded epoll loop against Go's goroutine-plus-channel `select` design built on the runtime's netpoller.
- Inspect a running watcher's interest list and fd table through `/proc/<pid>/fdinfo` and read `strace` output for `epoll_wait`, inotify, timerfd, and signalfd calls.

## Why this matters

Every scalable network service, file watcher, and terminal multiplexer rests on the same decision: decouple waiting from the work the wait is for. The readiness model is how a single thread tracks thousands of file descriptors without a thread per source, and it is the direct ancestor of the event loops behind editors, sync clients, and the servers Part 6 builds.

This chapter also establishes the comparison that drives the rest of this part: an explicit, visible-in-source epoll loop versus a runtime that hides the same machinery behind channels and goroutines. Chapter 10 builds on this foundation by introducing the completion model, io_uring, as the second half of the readiness-versus-completion pair.

Chapter 8 closed on a question durability cannot answer: "which of ten
thousand fds is ready?" This chapter answers it with the **readiness model**:
the kernel will tell you *when an fd has something for you*, and a program
Expand Down
16 changes: 16 additions & 0 deletions _docs/10-io-uring.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ description: "fwatch grows a batched sync engine and meets the second of the two
duration: 60 minutes
---

## Learning objectives

By the end of this chapter you can:

- Explain the difference between the readiness model (epoll asks "may I?") and the completion model (io_uring reports "it is done").
- Construct an io_uring instance with `io_uring_setup`, map the SQ ring, CQ ring, and SQE array via the canonical `mmap`s, and submit batched operations with `io_uring_enter`.
- Write and link submission queue entries (`IORING_OP_READ`/`IORING_OP_WRITE`) with `IOSQE_IO_LINK` to express intra-pair ordering without a userspace round trip.
- Measure when completion-based I/O helps and when it costs more than a synchronous read/write loop, using `strace -c` syscall counts and wall-clock timings.
- Account for io_uring's security exposure, including why container seccomp profiles deny it by default, and explain Go's deliberate refusal to expose a uring engine.

## Why this matters

Readiness and completion are the two mental models underlying every I/O-heavy system on Linux, and knowing which one a workload needs is a design decision with measurable consequences: this chapter's own numbers show a completion-based engine running roughly ten times slower than a synchronous loop on warm, small files, while the same engine would win decisively against deep queues on real storage or busy sockets. Fewer syscalls is not automatically faster, and this chapter proves it rather than asserting it.

The chapter also surfaces a security tradeoff worth carrying forward: io_uring's opcode-dispatched interface can bypass syscall filters written against `read`/`write`/`connect`, which is why container runtimes deny it by default. That tension between a powerful kernel interface and a shrinking attack surface returns when Part 7 moves these programs into containers.

Chapter 9 ended with `fwatch` blocked in an event loop, and with a promise:
epoll would not be the last word on Linux I/O. Epoll answers one question:
*which of these fds may I touch without blocking?* Then it leaves every
Expand Down
Loading
Loading