The Pipes
File Descriptors
To understand file descriptors (FDs), you have to look at one of the core design philosophies of Unix and Linux: “Everything is a file.” Whether a process is interacting with a regular text file, a directory, a hardware device (like a hard drive), a network socket, or an in-memory pipe, the kernel represents all of them as “files.”
A file descriptor is simply the lightweight handle that user-space processes use to talk to these files. Here is the low-level architectural breakdown of how they work.
1. The Per-Process File Descriptor Table
A file descriptor is not a complex data structure; it is literally just a non-negative integer (0, 1, 2, 3…).
Inside the kernel, every process is represented by a structure called struct task_struct. Inside that structure is a pointer to a File Descriptor Table. You can think of this table as a simple array, where the integer file descriptor is the index into the array.
When an application calls a system call like read(3, buf, 1024), it is telling the kernel: “Look at index 3 of my file descriptor table, figure out what file is sitting there, and read 1024 bytes from it.”
2. The Standard Three (0, 1, 2)
By convention, whenever a new process is spawned, the shell automatically populates the first three slots (indices 0, 1, and 2) of its file descriptor table:
0(Standard Input -stdin): Where the process reads its input data (usually hooked up to your keyboard).1(Standard Output -stdout): Where the process writes its normal output data (usually hooked up to your terminal screen).2(Standard Error -stderr): Where the process writes its error logs/messages (also hooked up to your screen, but kept separate from stdout so you can redirect logs independently).
Any file you open after the process starts will grab the lowest available integer, which usually starts at 3.
Process A
FD Table
3
\
\
▼
+----------------------+
| struct file |
|----------------------|
| f_pos = 100 |
| flags |
| file_operations |
| inode* --------------+----------------+
+----------------------+ |
|
▼
+-------------+
| inode |
|-------------|
| metadata |
| size |
| permissions |
| timestamps |
| address_space|
+-------------+
Layer 1: The Per-Process FD Table
As mentioned, this lives inside the process’s metadata. If Process A opens a file, it gets FD 3. If Process B opens the exact same file, it might also get FD 3 (or FD 4). These numbers only have meaning inside that specific process.
Layer 2: struct file (The System-Wide Open File Table)
File descriptors point to the struct file objects we’ve seen before.
It contains things like
struct file {
loff_t f_pos; // current offset
f_flags;
struct inode *inode;
struct file_operations *f_op;
...
}
Suppose
fd = open("notes.txt");
creates
FD3
↓
struct file
offset = 0
Now
read(fd,100);
changes
offset = 100
No inode changed. Only this file object changed.
The pointers in the per-process table point to this global kernel table. This table tracks how the file is currently being used. It stores:
- The file status flags (e.g., Is it opened as read-only, write-only,
O_DIRECT, etc.?). - The Current File Offset: The exact byte position where the next read() or write() will occur.
Why this matters for fork(): When a parent process forks a child, the child copies the parent’s FD table exactly. Both the parent’s FD 3 and the child’s FD 3 point to the exact same entry in this global Open File Table. Therefore, if the child reads 10 bytes, the offset moves forward, and if the parent reads next, it automatically continues where the child left off.
Layer 3: The VFS Inode Table
Finally, the entry in the Open File Table points to the inode structure in the Virtual Filesystem layer. The inode represents the actual underlying object. It doesn’t care who is reading it or what the current offset is; it contains the file permissions, file size, and the function pointers (f_ops) showing how to pull the actual blocks off the disk or out of a pipe buffer.
Process Communication
The primary objective of Inter-Process Communication (IPC) is to allow User Mode processes to synchronize actions and safely exchange data.
While kernel control paths can synchronize internally using kernel-specific primitives (like spinlocks or mutexes), User Mode processes are isolated in their own virtual address spaces. Therefore, they must explicitly rely on the kernel as a secure, neutral intermediary to facilitate communication.
The Inefficiency of File-Based IPC
Before looking at specialized primitives, it is technically possible for processes to communicate using standard files combined with VFS file locking (e.g., flock() or fcntl()).
- The Mechanism: Process A writes data to a temporary file on disk, locks it, unlocks it, and Process B reads it.
- The Bottleneck: This approach is incredibly costly. It forces the kernel to interact with the actual disk filesystem, incurring massive block I/O overhead and latency.
- The Solution: Unix kernels implement dedicated IPC system calls that bypass the disk subsystem entirely, keeping data transfers confined strictly to volatile memory (RAM).
Pipes and FIFOs: The Producer/Consumer Architecture
Among all the IPC mechanisms provided by Linux, pipes and FIFOs are uniquely optimized for linear data streaming.
- Core Paradigm: They are explicitly designed to implement the classic producer/consumer interaction between distinct processes.
- Data Flow: The architecture enforces a unidirectional or synchronized flow of data. One or more “producer” processes continuously write data into the pipe, while one or more “consumer” processes pull data out of the pipe.
- The Structural Split: The book categorizes this mechanism into two distinct variations based on visibility and lifetime:
- Anonymous Pipes (Pipes): Unnamed, transient memory streams typically used to link closely related processes (like a parent and child process via
fork()). - Named Pipes (FIFOs): Explicit entities that appear as special files within the VFS directory tree, allowing completely unrelated processes to discover each other and communicate.
The Upcoming Architectural Blueprint
As you dive deeper into this chapter, UTLK shifts away from how these look to the user and focuses on how the kernel orchestrates them. To prepare for the upcoming sections, keep in mind that the kernel must solve three core problems for a pipe:
- Memory Abstraction: How to represent a file-like object that only exists in RAM.
- Synchronization: How to block a consumer process when the pipe is empty, and how to block a producer process when the pipe is full.
- Atomicity: Ensuring that if multiple processes write to the same pipe simultaneously, their data bytes do not get interleaved and corrupted.
THe Pipes
1. Core Architectural Definition of a Pipe
A pipe is an Inter-Process Communication (IPC) mechanism providing a unidirectional (one-way) data stream between processes. Data written by a producer process is routed entirely within kernel memory to a consumer process.
- VFS Integration: Pipes are treated as open files by the kernel, but they are unique because they have no physical image on any mounted disk filesystem. They exist entirely within volatile RAM.
- Efficiency over Files: Unlike disk-backed file redirection (e.g.,
ls > temp; more < temp), pipes avoid the overhead of filesystem metadata modifications, disk sector writes, and disk deletions.
2. The Duplex Spectrum: How Linux Differs
Different Unix implementations handle the directional mechanics of pipes differently:
- POSIX Standard (Strict Half-Duplex): Mandates unidirectional channels. Even though
pipe()returns two descriptors, a process must explicitly close the unused end to prevent undefined behavior or deadlocks. - System V Release 4 (Full-Duplex): Implements bidirectional channels where both descriptors can be simultaneously read from and written to.
- The Linux Approach: Linux implements unidirectional file descriptors, but it relaxes the POSIX restriction. It is not functionally mandatory to close the unused descriptor before utilizing the active one, though doing so remains an application-layer best practice.
3. Low-Level Plumbing of a Shell Pipeline
When a shell executes a command pipeline like $ ls | more, the kernel orchestrates file descriptor (FD) duplication and process creation via a precise sequence.
Phase 1: Parent Shell Setup
- The shell invokes the
pipe()system call. - The kernel allocates the pipe structures and returns two new file descriptors to the shell:
FD 3: The read channel (input).FD 4: The write channel (output).
- The shell calls
fork()twice to spawn two child processes. - The parent shell immediately calls
close(3)andclose(4)to release its own hold on the pipe’s descriptors.
Phase 2: Child 1 Execution (ls)
- Redirection: Invokes
dup2(4, 1). This copies the file descriptor properties ofFD 4(pipe write) over toFD 1(Standard Output). - Cleanup: Invokes
close(3)andclose(4)to clean up the redundant descriptors. - Transformation: Invokes
execve()to load and execute thelsbinary. Whenlswrites data to stdout (FD 1), it is now writing straight into the pipe’s kernel buffer.
Phase 3: Child 2 Execution (more)
- Redirection: Invokes
dup2(3, 0). This copies the file descriptor properties ofFD 3(pipe read) over toFD 0(Standard Input). - Cleanup: Invokes
close(3)andclose(4). - Transformation: Invokes
execve()to executemore. By default,morepolls stdin (FD 0), which is now hooked directly to the reading end of the pipe.
Note on Multiplexing: While shell pipelines typically link only two processes, a single kernel pipe can technically support an arbitrary number of readers and writers. However, if multiple processes write or read simultaneously without application-level synchronization (like
fcntlfile locks or IPC semaphores), data bytes will interleave and corrupt the stream.
When fork() happens parent closes its end of file descriptors as the child would have already received a copy of its file descriptor table.
Parent
3 ─────────┐
│
4 ─────────┤
│
▼
Pipe
▲
│
3 ─────────┤
│
4 ─────────┘
Child
Why does child also closes its file descriptors after duplicating?
Suppose child has
FD Table
0 → keyboard
1 → terminal
2 → terminal
3 → pipe read
4 → pipe write
Now execute dup2(4, 1);
It means “Make FD 1 refer to the same thing as FD 4.”
Afterwards
FD Table
0 → keyboard
1 ──────-──┐
│
4 ─────────┘
│
▼
Pipe Write End
Notice Terminal disappeared, stdout now is the pipe. Nothing inside ls changes.
It still does printf(...) which writes to FD 1 But FD 1 no longer means terminal, it means pipe.
That’s why Unix redirection is so beautiful. Programs don’t know they’re redirected. They just write to stdout.
Then why close(4)?
After dup2(4,1) both 1 and 4 point to exactly the same open file. Keeping both is pointless. So child does close(4);. Now only FD 1 remains.
4. C Library High-Level Wrappers: popen() & pclose()
To spare developers from manually orchestrating pipe(), fork(), dup2(), and execve(), the standard C library (glibc in Linux) provides the high-level wrappers popen() and pclose(). They return a standard file stream pointer (FILE *) instead of raw integer file descriptors.
Under the Hood: popen(pathname, type)
- Allocation: Calls the raw
pipe()system call. - Spawning: Forks a child process.
- Directional Routing:
- If
typeis"r"(Read): The child duplicates the pipe’s write channel into its standard output (FD 1). - If
typeis"w"(Write): The child duplicates the pipe’s read channel into its standard input (FD 0).
- Execution: The child calls
execve()on the target program specified bypathname. - Parent Cleanup: * If
"r", the parent closes the write channel (since it only needs to read).
- If
"w", the parent closes the read channel (since it only needs to write).
- Return: Returns the address of the
FILEstructure wrapping the single pipe descriptor that remains open in the parent.
Under the Hood: pclose(FILE *stream)
- Closes the open file stream.
- Invokes the
wait4()system call to block execution until the child process spawned bypopen()terminates, harvesting its exit status and preventing zombie processes.
Pipe Data Structures
1. The VFS Setup and pipefs
Even though an anonymous pipe only exists in RAM, Linux refuses to break its “everything is a file” VFS architecture.
To handle pipes natively, the kernel mounts a hidden, internal-only filesystem called pipefs during boot. Because it has no mount point in the root directory tree (/), users cannot see it.
When a pipe is created, the kernel creates three VFS objects inside pipefs:
- One
inode: Represents the physical pipe object in memory. - Two
fileobjects: One configured strictly for reading, one configured strictly for writing. (This is whypipe()returns two file descriptors that point back to the same underlying inode).
Inside that inode is a field called i_pipe, which points to the master control structure: pipe_inode_info.
2. The Master Controller: pipe_inode_info
This structure tracks the state, synchronization, and data of the pipe.
In older Linux kernels (pre-2.6.11), a pipe was just a single 4KB page. Modern Linux upgraded this to an array of 16 pipe buffers (acting as a 64KB circular buffer) to massively increase throughput for large data streams.
Here are the critical fields controlling the flow:
| Field | Purpose |
|---|---|
wait | The Wait Queue: If a reader tries to read an empty pipe, it is put to sleep here. If a writer tries to write to a full pipe, it sleeps here. |
readers / writers | Counters tracking how many file descriptors are currently open for reading/writing. If writers drops to 0, the reader gets an EOF (End of File). |
bufs[16] | The Payload: An array of 16 pipe_buffer structures containing the actual data. |
curbuf | The index (0-15) of the first buffer currently holding data to be read. |
nrbufs | The total number of buffers currently containing unread data. |
The Circular Math
Because it is a circular buffer, the kernel uses simple modulo arithmetic to figure out where to read and write:
- Next byte to read: Located in
bufs[curbuf]. - Next empty slot to write: Located at index
(curbuf + nrbufs) % 16.
3. The splice() Connection: struct pipe_buffer
You were entirely correct in your suspicion. The secret to how splice() achieves zero-copy I/O is hidden inside the bufs[16] array.
Each element in that array is a pipe_buffer object, which looks like this:
| Field | Type | Description |
|---|---|---|
page | struct page * | A pointer to the physical page frame in RAM. |
offset | unsigned int | The starting byte of the actual data inside that page. |
len | unsigned int | The length of the valid data inside that page. |
ops | struct pipe_buf_operations * | Method pointers (map, unmap, release). |
The “Smoking Gun” for Zero-Copy
Notice that the pipe_buffer does not contain a char data[4096] array. It only contains a struct page * (a memory pointer).
This changes everything:
- Standard
write(): If a user process callswrite(), the kernel allocates a brand new page frame in RAM, sets thepagepointer to it, and physically copies the user’s data into that page. - Zero-Copy
splice(): If a process callssplice(file_fd, pipe_fd), the kernel looks at thefile_fd’s data already sitting in the Page Cache. It takes the existing Page Cache pointers and literally just drops them into the pipe’spagefields.
No data is copied. The pipe simply borrows the memory addresses of the Page Cache. When the network card later reads from the pipe, it follows those pointers directly back to the Page Cache to pull the data over the wire.
The ops Table (Method Overriding)
Because a pipe might contain standard copied pages or borrowed Page Cache pages via splice(), the kernel needs to know how to safely destroy them when the pipe is emptied.
It handles this via the ops pointer table.
- If it is a normal pipe page, the
releasemethod recycles the page back into thetmp_pagecache for the next write. - If it is a
splice()page borrowed from the Page Cache, thereleasemethod simply drops the pipe’s reference count to the page, leaving the original Page Cache completely untouched.
The Splice Call
To understand splice(), we first have to look at the massive performance tax it was designed to eliminate: the user-space copy.
1. The Traditional I/O Tax (Why read/write hurts)
Imagine you are writing a web server, and you need to send a file from your hard drive to a user over the network (to the NIC - Network Interface Card). The traditional approach is to call read() and then write().
Under the hood, this requires four data movements and two CPU-intensive copies:
- Disk to Page Cache: Hardware DMA (Direct Memory Access) pulls the file from the disk into the kernel’s Page Cache. (Zero CPU cost).
- Page Cache to User Space: The CPU physically copies the data from the kernel’s RAM into your application’s user-space buffer. (High CPU cost).
- User Space to Socket Buffer: The CPU physically copies the data back into kernel space, this time into the network stack’s socket buffer. (High CPU cost).
- Socket Buffer to NIC: Hardware DMA pulls the data from the socket buffer to the physical network card. (Zero CPU cost).
The data just made a completely unnecessary round-trip into user space. The application didn’t actually do anything with the data; it just acted as a middleman to move it from one kernel subsystem (filesystem) to another (network).
2. The splice() Solution (Zero-Copy I/O)
The splice() system call was introduced to eliminate the middleman. It allows user-space applications to tell the kernel: “Move data from this file descriptor to that file descriptor, but do all the work internally. Do not hand the data to me.”
By keeping the data entirely inside kernel space, you eliminate the CPU copies and the memory bandwidth waste. This is what developers mean when they talk about Zero-Copy I/O.
3. The Pipe Connection
Here is the strict architectural rule of splice(): At least one of the two file descriptors involved MUST be a pipe.
Why? Because the kernel needs a staging ground to orchestrate the transfer, and as we saw earlier, a Linux pipe is perfectly designed for this. A pipe is essentially just an array of memory pages inside pipefs.
When you use splice(), the pipe stops holding actual data and instead starts holding pointers to data.
4. Page Cache to NIC: The Exact Mechanism
Here is exactly how splice() moves a file directly from the Page Cache to the NIC using a pipe, without the CPU ever touching the payload.
Step 1: Splice File to Pipe
splice(file_fd, NULL, pipe_write_fd, NULL, length, flags);
Instead of copying the file’s contents into the pipe, the kernel looks at the Page Cache where the file currently sits. It takes the memory addresses (pointers) of those specific pages and drops those pointers into the pipe’s internal array. The pipe now references the Page Cache directly.
Step 2: Splice Pipe to Socket
splice(pipe_read_fd, NULL, socket_fd, NULL, length, flags);
The kernel reads the pointers out of the pipe and passes them directly to the network stack’s socket buffer.
Step 3: DMA to the NIC The network stack now has a list of physical RAM addresses pointing directly to the original file sitting in the Page Cache. It hands these addresses to the Network Interface Card (NIC). The NIC’s hardware DMA engine reaches straight into the Page Cache, grabs the data, and sends it out over the wire.
Summary Comparison
| Metric | Traditional read() + write() | Zero-Copy with splice() |
|---|---|---|
| Context Switches | 4 | 2 |
| CPU Data Copies | 2 | 0 |
| User Space Buffer Required? | Yes | No |
| Throughput | Bottlenecked by CPU/RAM speed | Bottlenecked only by hardware (Disk/NIC) |
Note: In modern Linux, you will often see developers use the sendfile() system call for this exact File-to-Network scenario. Under the hood, modern sendfile() is actually implemented using the exact same internal pipe-pointer plumbing as splice()!
An important question
this is aother para added now, here i understand what’s going on, but what bothers me is this line At least one of the two file descriptors involved MUST be a pipe.
So my question is why can’t i just do splice(fd1, fd2), where let’s say fd1 is a file with some data and fd2 is a newly created file, it should be possible for kernel to shuffle the pointers internally right? because kernel is the owner of page cache?
A beautiful answer:
This is the question to ask. In fact, the Linux kernel developers asked exactly this question years later, and the answer is why we now have copy_file_range().
The short answer is:
Yes, the kernel absolutely can shuffle page pointers directly. The pipe requirement is not a fundamental limitation—it was a design decision of the original
splice()API.
Let me explain the history.
Imagine we remove the pipe
Suppose we write
splice(fd1, NULL, fd2, NULL, len, 0);
where
fd1 → regular file
fd2 → regular file
What do we want?
Conceptually
Page Cache (file1)
Page #123
↓
Page Cache (file2)
Page #456
Since both page caches belong to the kernel,
the kernel could simply say
“Copy Page #123 into Page #456.”
No userspace.
No pipe.
No problem.
So your intuition is correct.
Then why did splice() require pipes?
Because splice wasn’t designed as “copy between files.”
It was designed as
“Move data between arbitrary file descriptors.”
Think about the kinds of things Linux wanted:
File → Socket
Socket → File
Socket → Socket
Pipe → Socket
Pipe → File
...
Now imagine trying to write generic code for every pair.
File → Socket
uses filesystem code
Socket → File
uses socket code
File → File
uses filesystem code
Socket → Socket
uses TCP code
...
That becomes
N × N
combinations.
Messy.
Instead Linux said
Let’s make everybody speak one language.
That language is
Pipe
So instead of
File
↓
Socket
every transfer becomes
File
↓
Pipe
↓
Socket
or
Socket
↓
Pipe
↓
File
Now every file type only has to implement
to_pipe()
from_pipe()
instead of implementing direct transfers to every other object.
This is a classic software engineering trick
Suppose there are
10 object types
Without pipes,
everyone must understand everyone else.
File ↔ Socket
File ↔ Device
File ↔ Pipe
Device ↔ Socket
...
That’s roughly
10²
relationships.
With pipes,
everyone only understands pipes.
File ↔ Pipe
Socket ↔ Pipe
Device ↔ Pipe
Only
2×10
relationships.
Much simpler.
Why are pipes the perfect intermediate?
Because after everything you learned today,
a pipe is no longer
char buffer[]
It’s
pipe_buffer
↓
struct page *
↓
ops
That’s an incredibly generic container.
It can reference
- page cache pages
- anonymous pages
- DMA pages
- gifted pages
- whatever future Linux invents
without caring.
But wait…
You asked
it should be possible for kernel to shuffle the pointers internally right?
Yes.
And Linux eventually realized that.
That’s why Linux added
copy_file_range()
Its purpose is almost exactly
copy_file_range(fd1, fd2);
No pipe.
No userspace.
Kernel-to-kernel copy.
Because
- source is a regular file
- destination is a regular file
The kernel has enough knowledge to optimize directly.
Even more interesting…
Sometimes it doesn’t even copy pages.
Modern filesystems can do
File A
↓
File B
by simply changing metadata.
Imagine
inode A
page cache
disk extents
Instead of copying 10 GB,
the filesystem just says
“File B now points at the same extents.”
Milliseconds instead of minutes.
Filesystems like Btrfs and XFS can do tricks like this.
So why keep splice()?
Because
File → File
is actually the boring case.
The interesting ones are
File → Socket
Socket → File
Pipe → Socket
Pipe → Device
Socket → Pipe
A pipe remains an excellent universal exchange format.
I think there’s an even deeper pattern here
Notice what Linux keeps doing.
VFS introduced
struct file
as the common abstraction.
Page cache introduced
address_space
as the common abstraction.
Now splice() introduces
pipe_buffer
as the common abstraction for moving data.
Rather than teaching every subsystem how to talk directly to every other subsystem, Linux invents a shared intermediate representation that everyone understands.
One small correction to a sentence in your notes
You currently write:
“the kernel needs a staging ground”
I don’t think that’s the deepest reason anymore.
A better phrasing would be:
At least one endpoint must be a pipe because the original
splice()API uses the pipe as a universal transfer abstraction. Apipe_buffercan represent data from many different sources (page cache pages, anonymous pages, gifted pages, etc.) through a common interface (pipe_buf_operations), allowing filesystems, sockets, and devices to exchange data without every pair of subsystems implementing direct transfer logic.
That captures why the API was designed that way. The pipe isn’t just temporary storage—it’s the common language that lets otherwise unrelated kernel subsystems exchange ownership or references to memory efficiently.