Handbooks  /  Linux Internals
Handbook~16 min readSystemsworked math + runnable code
The Linux Internals Handbook

Everything is
a file.

Linux runs the internet, your phone, and most of the cloud on a single, almost suspiciously simple idea: everything is a file. A document, a keyboard, a network socket, another program's output — all of them are just a stream of bytes you reach through a small integer called a file descriptor, using the same four calls: open, read, write, close. That uniformity is why tiny tools compose into anything, why a | b works, and why the whole system feels like one thing. This handbook is those small mechanisms — descriptors and pipes — worked out and made runnable.

01

One interface

Linux's defining design choice is "everything is a file." Nearly every resource — a regular file, a directory, a hardware device, a network socket, a pipe between programs, even live kernel state under /proc — is exposed through the same interface: the file. You touch all of them with the same small set of system calls: open, read, write, close. A program reading input doesn't know or care whether the bytes come from a disk file, the keyboard, a socket, or another program — they all arrive as a stream behind a descriptor.

That single abstraction is Linux's great multiplier. Learn one interface and it works everywhere; write one tool and it composes with every other, because they all speak "bytes through a descriptor." It's why the command line is so powerful and why decades-old Unix tools still slot into modern pipelines. Everything below — descriptors, pipes, redirection, fork/exec — is just the machinery that makes this uniform "file" idea real and cheap. Understand the abstraction and the internals stop being a pile of trivia and become variations on one theme.

The one-sentence version

Linux models every resource as a file reached through a small-integer descriptor, so open/read/write/close work on everything — and pipes wire one process's output descriptor to another's input, which is the whole of a | b.

02

The descriptor table

A file descriptor is a small non-negative integer that names an open resource within a process. Each process owns a file-descriptor table — effectively an array — where the kernel tracks everything it has open. Three descriptors are set up before your code even runs: 0 is standard input, 1 is standard output, 2 is standard error. That's why printf writes to descriptor 1 and errors go to 2.

The rule for new descriptors is beautifully simple and worth remembering: open returns the lowest-numbered free descriptor. Open a file with 0,1,2 taken and you get 3; open another and you get 4; close(3) and the next open reuses 3. This lowest-free rule is exactly what makes redirection work: to send a program's output to a file, the shell closes descriptor 1, then opens the file — which, being the lowest free number, becomes descriptor 1 — so everything the program writes to "stdout" now lands in the file, without the program knowing. A tiny, deterministic allocation rule turns into one of the shell's most-used features.

03

Pipes compose processes

A pipe is an in-kernel FIFO buffer with two ends: write bytes into one end, read them out the other, in order. It's the mechanism that lets one process's output become another's input — the beating heart of the Unix philosophy of small tools joined together.

How the shell wires a | b
1 · pipe()Kernel makes a FIFO buffer with a read end and a write end.
2 · a's stdoutProcess a's descriptor 1 is set to the pipe's write end.
3 · b's stdinProcess b's descriptor 0 is set to the pipe's read end.
ResultWhatever a prints, b reads — neither knows the other exists.

The elegance is that a and b are ordinary programs that just read stdin and write stdout — they have no idea they've been chained. The shell did all the wiring with descriptors before either started. That's why you can compose cat log | grep error | wc -l out of three tools that were never designed to know about each other: each speaks only "bytes in, bytes out," and pipes carry the bytes between them. The same primitive underlies networking too — a socket is just another two-ended byte stream behind a descriptor.

04

The fd & pipe rules

Descriptor allocation is deterministic — always the lowest free integer — and closing frees a number for reuse, which is the whole trick behind redirection:

open()  =  min{ n ≥ 0 : n ∉ table }     0,1,2 = stdin, stdout, stderr     close(fd) → fd reusable

With 0,1,2 taken, open→3, open→4; close(3) then open→3 again. Redirection = close(1) then open(file) so the file lands on descriptor 1.

A pipe is a FIFO: bytes come out in the order they went in, and reading an empty pipe returns nothing (or blocks, in the kernel):

write(pipe, [b1…bk])  then  read(pipe, n)  =  first n bytes, in order     read(empty) = [ ]

Write [1,2,3,4], read 2 → [1,2], read 2 → [3,4]: first-in-first-out. The runnable version below implements the fd table and a pipe and exercises both rules.

RUN IT YOURSELF

A descriptor table and a pipe

Two small Linux mechanisms that compose into everything. The file-descriptor table maps small integers to open resources; 0/1/2 are the standard streams, open() returns the lowest free descriptor, and closing one frees that number for reuse — which is exactly how the shell redirects output. A pipe is a kernel FIFO buffer: write bytes in one end, read them out the other in order, and reading an empty pipe yields nothing. Together they're how a | b works. Change the opens/closes or the pipe reads and watch the descriptors and the byte order.

CPython · WebAssembly
05

Fork & exec

How does a new program start? Linux splits it into two deliberately separate calls, and the split is where the descriptor magic happens. fork() clones the current process — after it, there are two nearly identical processes (parent and child) continuing from the same line, differing only in fork's return value so each knows which it is. exec() then replaces a process's program with a new one: same process identity (same PID, same open file descriptors), entirely new code.

The shell runs a command by combining them: it forks a child, and in the child — before exec — it sets up file descriptors (this is where redirection and pipe wiring happen, since the child still shares the layout), then execs the target program, which inherits those descriptors. That window between fork and exec is precisely why the design is split: it's the moment to arrange "stdout goes to this pipe" before the new program starts and takes over. It's a gorgeous consequence of "everything is a file" — because descriptors survive exec, you can wire a program's inputs and outputs from the outside, and the program just finds its streams already connected. Processes, redirection, and pipelines all fall out of this one two-step dance.

06

When the table gets huge: epoll

Everything so far assumed a small descriptor table. A shell has maybe five entries in it; a three-stage pipeline has a dozen spread across three processes. Now change one number. A network server does exactly the same thing every program does — accept() hands back a descriptor, you read and write it like any other file — except it does it ten thousand times, and then a million times. The abstraction does not break: a socket is still a stream of bytes behind a small integer, and every rule from the last three sections still holds. What breaks is a question that was free at five descriptors and becomes the entire workload at ten thousand: which of these have something to say right now?

That question has a name-brand failure attached to it. Around ten thousand concurrent connections, servers of the late 1990s stopped scaling — not crashing, just melting: CPU climbing, latency spiking, throughput falling, while nearly every one of those ten thousand clients sat idle waiting for the next chat message. Dan Kegel wrote it up in 1999 as the C10K problem, and the detail that matters is that the machines were not out of network, disk, or memory bandwidth. They were spending their cycles on bookkeeping.

The first instinct — one thread per connection — is the most expensive form of that bookkeeping. Every thread gets its own stack (glibc reserves 8 MB of address space per thread by default, of which a few dozen KB are ever touched), so ten thousand threads reserve on the order of 80 GB of address space and add ten thousand entries to the scheduler's world. Each context switch costs roughly one to a few microseconds of direct work, plus the invisible tax of a cold L1 and a flushed TLB on the other side. You end up paying the CPU to shuffle threads that have nothing to do. One body per door does not scale; you want one watcher who can see every door — which means pushing the watching down into the kernel.

The classic way to do that is select(): hand the kernel your whole set of descriptors and ask which are ready. It works, and it has a hard wall built into its own type. An fd_set is a fixed bitmap of FD_SETSIZE bits — 1024 on Linux — so putting descriptor 5000 into one is not an error you can catch, it is memory corruption. poll() removed the ceiling by taking an array of 8-byte struct pollfd entries instead of a bitmap, but it kept the shape of the idea intact: you re-submit the entire list on every call, and the kernel walks the entire list every time. Cost scales with how many connections you are watching, not how many are doing anything.

poll(N)  =  copy 8N bytes in → scan N descriptors → copy 8N bytes out     cost ∝ N total
epoll_wait(K)  =  copy 12K bytes out → no scan     cost ∝ K ready

Take N = 10,000 watched and K = 100 ready. poll copies 80 KB into the kernel and 80 KB back out and inspects 10,000 descriptors — at a thousand loop iterations a second that is 160 MB/s of pure argument-shuffling — to tell you about 100 sockets. Ninety-nine percent of the work is asking connections that had nothing to say. epoll returns the same answer in a 1.2 KB array.

epoll restructures the question instead of optimising the scan. You register each descriptor with the kernel once, with epoll_ctl(EPOLL_CTL_ADD), and the kernel files it in a red-black tree keyed by descriptor number. From then on the kernel does not need you to ask: the same wake-up path that would have woken a thread blocked in read() instead calls back into epoll and appends that descriptor to a ready list. epoll_wait pops that list and hands it over. Idle sockets are never examined, because the kernel already knew their state the moment it changed.

How epoll flips the cost model
1 · epoll_create1The kernel makes an epoll instance — which is itself a file descriptor in your table. The thing that watches files is a file.
2 · epoll_ctl(ADD)Each connection is registered once, not re-submitted per call. The kernel stores it in a red-black tree.
3 · kernel callbackWhen a socket becomes readable, its wake-up path appends it to the ready list. No scan, no polling.
4 · epoll_waitYou receive the ready list. Idle sockets cost memory, not CPU.

That is the whole shift: cost moves from proportional to your total connection count to proportional to your active connection count. A box holding a million sockets where four hundred are exchanging bytes at any instant pays for four hundred. It is worth being precise about what actually bounds that million, though, because it is no longer the event loop: it is per-socket kernel memory (a TCP socket with its send and receive buffers is several KB, so a million of them is gigabytes before your application allocates anything), the descriptor limits themselves (ulimit -n and fs.file-max), and, on the client side, the 65,535 ephemeral ports per source address. epoll removes the CPU wall and leaves the memory wall standing.

Three failure modes account for most of the epoll bugs engineers actually hit. The first is edge-triggered mode: with EPOLLET the kernel notifies you only on a transition, so if you read 4 KB out of a socket that had 4.2 KB waiting and go back to epoll_wait, you will never be told about the remaining 200 bytes — no new packet, no new edge. The connection simply goes silent forever, usually in production, usually under load. The rule is absolute: edge-triggered descriptors must be O_NONBLOCK and must be drained in a loop until read returns EAGAIN. Level-triggered (the default, and the semantics select/poll always had) is the forgiving choice.

The second is the thundering herd: run sixteen workers all blocked in epoll_wait on the same listening socket, and one incoming connection wakes all sixteen; fifteen lose the race and go back to sleep having burned a context switch each. The fixes are EPOLLEXCLUSIVE (Linux 4.5) or SO_REUSEPORT, which gives each worker its own listening socket and lets the kernel hash connections across them — the same load-spreading intuition you can play with in the load balancer lab.

The third is structural, and it is the price of the design: one blocking call poisons the whole loop. A single thread serving 100,000 connections has zero tolerance for a slow operation. A synchronous DNS lookup, a page fault on a cold file, a Redis KEYS command that walks the keyspace — one millisecond of blocking at 100,000 connections is a hundred connection-seconds of stall that shows up as a latency spike nobody can attribute. This is why event-loop systems are so insistent about never doing blocking work on the loop thread, and why the interesting engineering moves to what you hand off and how.

This one mechanism is the reason a single thread can be the event loop under software you use daily: nginx multiplexes tens of thousands of connections per worker through epoll, Redis runs command processing on one thread and leans on epoll to know which client sockets need attention, and Node's loop reaches epoll through libuv. And note how neatly it lands back on the descriptor-leak pitfall: none of this matters if you are still on the default ulimit -n of 1024, because you cannot hold a million connections you are not allowed to open. See epoll(7) for the full interface, and the networking handbook for what is happening on the wire underneath.

The escalation, step one

epoll does not change what a descriptor is — it changes what it costs to ask about all of them at once. Register interest once and let the kernel tell you; never re-ask about everything on every pass.

▶  Watch it explained

epoll: how one server holds a million connections

07

When the syscalls are the cost: io_uring

epoll fixed the question. Look closely at what it did not fix. epoll_wait returns and says "descriptor 8,231 is readable." To actually get the bytes you call read(8231, ...) — a system call. To send the answer you call write(...) — another system call. epoll deleted the wasted scan and left the per-operation crossing exactly where it was. Push the connection count high enough and that crossing, not the network and not the disk, becomes the thing you are paying for.

A system call is not an ordinary function call. Your program lives in user space and is not permitted to touch a disk or a network card; only the kernel is. So read and write are privilege transitions: the CPU switches rings, swaps to a kernel stack, and later switches back. Historically a null system call cost well under a hundred nanoseconds. After the 2018 Meltdown and Spectre mitigations — page-table isolation swapping the page tables on every kernel entry and exit, plus branch-prediction barriers — measured null-syscall costs on many machines landed in the couple-hundred-nanosecond range or worse. And there is a smaller cost hiding inside every one of those calls that this handbook has already introduced: passing a descriptor means the kernel indexes your process's descriptor table, takes a reference on the open file, does the work, and drops the reference. The small-integer lookup from section 02 is cheap, but you pay it on every single call.

100,000 connections × 2 events/s  =  200,000 events/s
per event: 1 read + 1 write  =  400,000 syscalls/s
400,000 × 500 ns  =  0.20 s of CPU per second  =  20% of a core, doing zero I/O

A fifth of a core burned on mode transitions before a single byte is moved, and it scales linearly with event rate. Double the traffic and you buy the crossings twice.

io_uring, added by Jens Axboe in Linux 5.1 (2019), attacks that directly: stop crossing the wall per operation. Setup returns — inevitably — a file descriptor, and you mmap three regions out of it: a submission queue (SQ), a completion queue (CQ), and the array of submission entries themselves. Both the application and the kernel read and write that same memory, coordinating through head and tail indices and memory barriers. If that sounds familiar it should — it is the producer/consumer discipline of the pipe from section 03, with two differences: the two ends are your process and the kernel, and there is no copy between them, because there is only one buffer.

The entries are deliberately small and fixed: a submission entry (io_uring_sqe) is 64 bytes, a completion entry (io_uring_cqe) is 16. You fill a hundred and twenty-eight SQEs — read this socket, write that one, accept a connection, open a file — then make one io_uring_enter call saying "pick up 128." The kernel performs them asynchronously and posts each result as a CQE. You collect results by reading shared memory: no system call at all to reap completions. Batching removes crossings on the way in; shared memory removes them on the way out.

Two further modes push the count toward zero. With IORING_SETUP_SQPOLL the kernel runs a thread that watches the submission ring's tail on its own, so in steady state you submit work by writing memory and never call into the kernel at all (the thread parks after an idle timeout, and you are expected to check the IORING_SQ_NEED_WAKEUP flag and issue one enter to wake it). And io_uring_register lets you pre-register your descriptors and buffers, which resolves that per-call descriptor-table lookup once instead of on every operation. Multishot operations go further still: one accept or recv submission can produce an unbounded stream of completions, so a single 64-byte entry serves thousands of connections.

ApproachSyscalls per second at 200,000 events/sWhat dominates
Thread per connection~400,000, plus ~200,000 context switchesScheduling and stacks
poll() loop~400,000 + a full 10,000-entry scan per passRe-scanning idle connections
epoll + read/write~400,000The crossings themselves
io_uring, batch of 128~3,100 io_uring_enter callsActual I/O
io_uring + SQPOLL~0 (a kernel thread polls the ring)Actual I/O

Now the failure modes, because io_uring's are unusually sharp. The first one most teams meet is not performance at all — it is that io_uring is often switched off. Its asynchronous, deeply privileged surface has produced a steady stream of kernel vulnerabilities; Google reported in 2023 that io_uring accounted for a large majority of the successful Linux kernel exploits submitted to its bug bounty, and that it had disabled io_uring across ChromeOS, Android, and its production servers. Container runtimes followed: Docker's default seccomp profile blocks the io_uring system calls. Before you design around it, find out whether your runtime will even let you call it.

The second is buffer lifetime, and it is a genuinely new hazard. read() returns when your buffer is full; an SQE returns when the kernel has accepted the request, and the kernel writes into your buffer some time later. Free that buffer, reuse it for another connection, or let the iovec or sockaddr you pointed at go out of scope before the matching CQE arrives, and you have a use-after-free that no compiler will warn you about and that only reproduces under load. Every submitted structure must outlive its completion.

The third is completion-queue overflow. The CQ defaults to twice the SQ's size, which sounds generous until a burst of multishot receives fills it while your loop is busy; older kernels dropped the overflowing completions outright, newer ones spill them to an internal list and raise IORING_SQ_CQ_OVERFLOW, but either way you have lost the property you were relying on. Reap every iteration, and size the rings for your burst rather than your average — the same backpressure reasoning that governs any bounded queue, including the finite pipe buffer in the pitfalls below. Two smaller ones round it out: SQPOLL dedicates an entire core to spinning, which on a four-core box is a quarter of the machine given away; and the feature set is heavily kernel-version-gated, so code that works on 6.x may not compile-time or run-time exist on 5.4 — which is why almost nobody drives the rings by hand and everybody uses liburing, which probes for capabilities.

Notice what has and has not changed. The wall between user space and the kernel has not moved, and the kernel still does the same underlying work — io_uring does not make one disk read or one packet send any faster. What it changes is how often you have to knock. For a server juggling a hundred thousand connections, the difference between a syscall per operation and a syscall per batch is the difference between boundary crossings dominating your CPU profile and barely appearing in it. Axboe's design paper, Efficient IO with io_uring, and io_uring(7) are the primary sources. Which leaves one obvious question: if the crossings are nearly free now, what is left to pay for? The bytes.

The escalation, step two

epoll made asking cheap; io_uring makes the asking itself disappear. Shared ring buffers plus batching plus asynchronous execution turn hundreds of operations into a handful of wall-crossings — and, in polled mode, into none.

▶  Watch it explained

io_uring: how one system call does the work of thousands

08

When the copies are the cost: zero-copy

The escalation has one step left. epoll made it cheap to ask about a huge descriptor table; io_uring made the asking almost free. Neither touched what happens to the bytes once you are across the wall. For a whole category of server — static file hosts, video segment origins, message brokers, proxies — the application never looks at the payload. It is a pipe with a hostname. And for those, the copies are the entire remaining bill.

Trace the obvious implementation, written in exactly the vocabulary of this handbook: read(file_fd, buf, 65536) then write(sock_fd, buf, n). Four copies happen, and four transitions between user and kernel mode.

The naive path: four copies, four crossings
1 · DMADisk → page cache. Hardware moves it; the CPU is not involved.
2 · CPU copyPage cache → your buffer. User space cannot read kernel memory, so read() copies.
3 · CPU copyYour buffer → socket buffer. write() copies the identical bytes straight back into the kernel.
4 · DMASocket buffer → NIC. Hardware again.

Copies two and three are pure ceremony. The application does not inspect, transform, or even decode those bytes; it hauls them out of the kernel and immediately hands them back. The CPU is carrying furniture through a room nobody is using. Put numbers on it: serving a 1 GB file through a 64 KB buffer means 16,384 read calls and 16,384 write calls — 32,768 system calls, which is the io_uring problem all over again — and 2 GB of CPU-copied traffic (1 GB in, 1 GB back out). At a realistic ~10 GB/s of achievable memory-copy bandwidth that is roughly 0.2 seconds of CPU per gigabyte served, and the whole time your 64 KB buffer is evicting other people's data from L2 and L3. Saturate a 10 Gbps link and you are asking the CPU to shuttle about 2.5 GB/s of bytes it never reads.

read() + write()  =  4 copies (2 DMA + 2 CPU), 2 syscalls, 4 mode transitions
sendfile()  =  3 copies (2 DMA + 1 CPU), 1 syscall, 2 mode transitions
sendfile() + scatter-gather DMA  =  2 copies (2 DMA + 0 CPU), 1 syscall, 2 mode transitions

"Zero-copy" counts CPU copies. The DMA transfers are real and unavoidable — the bytes physically have to reach the card. What disappears is the processor's involvement in moving them.

sendfile(out_fd, in_fd, offset, count) is the purest expression of "everything is a file" in the whole system call table: both ends are just descriptors, so the kernel is free to shortcut the entire path between them. The data still DMAs from disk into the page cache and the CPU still copies it once into the socket buffer, but the trip through your address space is gone. Three copies instead of four, one call instead of two, and two mode transitions instead of four. It dates back to Linux 2.2, and it has edges worth knowing: in_fd must be something mmap-able — a regular file, not a socket or a pipe — which is why socket-to-socket sendfile returns EINVAL, and why proxies reach for splice(2) (Linux 2.6.17) instead, which moves page references through a pipe rather than bytes. Before Linux 2.6.33, out_fd had to be a socket; since then it can be any file.

The last CPU copy goes away in hardware. A NIC that advertises scatter-gather DMA (plus checksum offload) does not need a contiguous, pre-assembled buffer handed to it — it can be given a list of page-and-offset descriptors pointing straight at where the data already sits in the page cache, and pull from there. Now there are two copies left, both DMA, both hardware: disk to page cache, page cache to card. The CPU sets the transfer up and never touches a payload byte. That is what the term means, and it is worth saying the precise version out loud, because "zero-copy" misleads people constantly: zero CPU copies, not zero copies.

This is not exotic; it is what the throughput numbers you have read are made of. nginx has sendfile on; for static assets. Kafka's broker moves log segments from disk to consumer sockets with FileChannel.transferTo, which is sendfile underneath — a large share of its famous throughput comes from the fact that the broker never parses the messages it forwards. Netty exposes it as DefaultFileRegion. The common thread is a program whose job is transport, not computation.

And now the failure modes, which are where most of the real engineering lives. The biggest by far: TLS destroys zero-copy. The moment the bytes must be encrypted, somebody has to read them, and if that somebody is your userspace TLS library, all the copies come straight back — teams routinely turn on HTTPS and watch static-file throughput per core fall off a cliff, then go looking for a bug that is not there. The real fix is kernel TLS (kTLS: transmit path in Linux 4.13, receive in 4.17), which leaves the handshake in userspace but moves the symmetric encryption into the kernel or onto a NIC that offloads it, so sendfile works again; nginx wired sendfile and kTLS together in 1.21.4.

Second: sendfile blocks on a cold page cache. If the requested pages are not resident, the call performs the disk read inline, and on an event-loop server that means the worker handling thousands of connections stops dead — which is precisely the "one blocking call poisons the loop" failure from the epoll section, wearing a different costume. nginx's answer is the pairing you see in every tuned config: aio threads; alongside sendfile on;, plus sendfile_max_chunk so that one fast client pulling one enormous file cannot monopolise a worker.

Third: there is no snapshot. sendfile streams live page-cache contents. Rewrite the file mid-send and the client can receive a seam of old bytes followed by new ones, with no error anywhere. This is exactly why the pattern fits immutable data — Kafka's append-only segments, content-hashed build assets — and why mutable files want the write-to-temp-then-rename idiom, so an in-flight send keeps its original inode. Fourth: the sibling API for data that is already in your own buffers, MSG_ZEROCOPY (Linux 4.14), is not free either — pinning user pages and delivering completion notifications on the socket's error queue costs enough that the kernel's own documentation puts the break-even around 10 KB per write; below that, copying is faster. And fifth: for small responses the win evaporates anyway. On a 2 KB API response the headers, the syscall, and the TCP work dominate completely, and the copies you eliminated were never the problem.

When the bottleneck is…The mechanismWhat the cost becomes proportional to
Too many descriptors to ask about — 10,000 watched, 100 busyepollConnections that are ready, not connections that exist
Too many crossings — one syscall per read and per writeio_uringBatches submitted, not operations performed
Too many copies — bytes the application never readssendfile / splice + SG DMANothing: zero CPU copies

Read down that table and the through-line is one idea applied three times at three different layers: stop paying for work proportional to something you do not care about. And read across it and notice what never changed. The epoll instance is a descriptor. The io_uring is a descriptor. sendfile takes two descriptors and nothing else. Every one of these mechanisms — the ones that make a million connections and multi-gigabit static serving routine — is still expressed entirely in the vocabulary of section 01: open, read, write, close, small integers, streams of bytes. The abstraction held at four orders of magnitude more load than it was designed for. Only the accounting changed. For where these sit in a real serving stack, see OS fundamentals and networking; for what the transport underneath is doing, TCP vs UDP.

The escalation, step three

Zero-copy means zero CPU copies. When your program is only forwarding bytes, the fastest thing it can do is get out of the way — hand the kernel two descriptors and let DMA carry the data from the page cache to the card.

▶  Watch it explained

Zero-copy: how servers send files without touching the data

09

Pitfalls

The first practical trap is the file-descriptor leak. Descriptors are a finite per-process resource (there's a hard limit, often ~1024 by default), and every open, socket, or accepted connection you forget to close holds one forever. A long-running server that leaks descriptors eventually hits EMFILE — too many open files and can't accept new connections, a classic production outage. Always close what you open (or use language constructs that close automatically).

Two more that catch people. Pipe buffers are finite and can block: if a reader stops reading, the writer eventually fills the pipe buffer and blocks waiting for space — and if two processes each wait to read what the other must write, you get a deadlock. Pipelines feel like magic until backpressure bites. And zombie/orphan processes: after a child exits, its parent must "reap" it (read its exit status) or it lingers as a zombie in the process table; forget this in a server that spawns children and you leak process slots. The unifying lesson is that Linux's power comes from small, composable mechanisms with sharp edges — descriptors, pipes, processes are cheap and everywhere, which also means you must account for every one you create. Internalize "everything is a file," respect the lifetimes of descriptors and processes, and the system that runs the world stops being a mystery: it's open, read, write, close, forked and piped, all the way down.

Worth knowing

Close every descriptor you open (leaks end in EMFILE), remember pipes have finite buffers that block under backpressure, and reap child processes so they don't zombie. The uniform "everything is a file" model is powerful precisely because these resources are cheap — so you must track each one's lifetime.

Frequently asked

Quick answers

What does "everything is a file" mean?

Files, devices, sockets, pipes, kernel state — all reached through the same interface (open/read/write/close) behind a file descriptor, so one API works everywhere.

What is a file descriptor?

A small integer indexing a process's table of open resources. 0/1/2 are stdin/stdout/stderr; open returns the lowest free number, close frees it.

How does a | b work?

A pipe (kernel FIFO) connects a's stdout to b's stdin, so a's output becomes b's input — neither program knows the other exists.

What are fork and exec?

fork clones a process; exec replaces its program while keeping identity and descriptors. Between them, the shell wires up redirection and pipes.

Why do select and poll break down around 10,000 connections?

Both re-submit your entire descriptor list on every call and the kernel walks all of it, so cost scales with connections watched rather than connections active. At 10,000 watched and 100 ready, poll copies 80 KB each way and scans 10,000 entries to report on 100. epoll registers each descriptor once and returns a kernel-maintained ready list instead — cost scales with activity.

What is the difference between level-triggered and edge-triggered epoll?

Level-triggered reports a socket as ready for as long as data remains, like select and poll. Edge-triggered (EPOLLET) reports only the transition, so if you leave bytes unread you are never told again and the connection goes silent forever. Edge-triggered descriptors must be O_NONBLOCK and drained in a loop until read returns EAGAIN.

How is io_uring different from epoll?

epoll tells you which descriptors are ready; you still make a separate read or write syscall for each one. io_uring shares two ring buffers with the kernel, so you batch many requests into one io_uring_enter call and collect results by reading memory with no syscall at all. epoll removes the wasted scan; io_uring removes the per-operation kernel crossing.

Why is io_uring disabled in some environments?

Its asynchronous, deeply privileged surface has produced many kernel vulnerabilities. Google disabled io_uring across ChromeOS, Android, and its production servers in 2023, and Docker's default seccomp profile blocks the io_uring syscalls. Check whether your runtime permits them before designing around it.

What does zero-copy actually mean?

Zero CPU copies, not zero copies. Naive read plus write moves the bytes four times — two DMA transfers and two CPU copies the application never reads. sendfile removes the trip through user space (three copies, one syscall), and a NIC with scatter-gather DMA removes the last CPU copy, leaving two hardware DMA transfers the processor never touches.

Why does HTTPS break zero-copy?

Encryption means somebody has to read the bytes, and a userspace TLS library pulls them back into user memory — reinstating exactly the copies sendfile eliminated. The fix is kernel TLS (kTLS, transmit in Linux 4.13 and receive in 4.17), which keeps the handshake in userspace but moves symmetric encryption into the kernel or onto the NIC so sendfile works again.

Finished this one? 0 / 208 Handbooks done

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More Handbooks