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:
  1. Anonymous Pipes (Pipes): Unnamed, transient memory streams typically used to link closely related processes (like a parent and child process via fork()).
  2. 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

  1. The shell invokes the pipe() system call.
  2. 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).
  1. The shell calls fork() twice to spawn two child processes.
  2. The parent shell immediately calls close(3) and close(4) to release its own hold on the pipe’s descriptors.

Phase 2: Child 1 Execution (ls)

  1. Redirection: Invokes dup2(4, 1). This copies the file descriptor properties of FD 4 (pipe write) over to FD 1 (Standard Output).
  2. Cleanup: Invokes close(3) and close(4) to clean up the redundant descriptors.
  3. Transformation: Invokes execve() to load and execute the ls binary. When ls writes data to stdout (FD 1), it is now writing straight into the pipe’s kernel buffer.

Phase 3: Child 2 Execution (more)

  1. Redirection: Invokes dup2(3, 0). This copies the file descriptor properties of FD 3 (pipe read) over to FD 0 (Standard Input).
  2. Cleanup: Invokes close(3) and close(4).
  3. Transformation: Invokes execve() to execute more. By default, more polls 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 fcntl file 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)

  1. Allocation: Calls the raw pipe() system call.
  2. Spawning: Forks a child process.
  3. Directional Routing:
  • If type is "r" (Read): The child duplicates the pipe’s write channel into its standard output (FD 1).
  • If type is "w" (Write): The child duplicates the pipe’s read channel into its standard input (FD 0).
  1. Execution: The child calls execve() on the target program specified by pathname.
  2. 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).
  1. Return: Returns the address of the FILE structure wrapping the single pipe descriptor that remains open in the parent.

Under the Hood: pclose(FILE *stream)

  1. Closes the open file stream.
  2. Invokes the wait4() system call to block execution until the child process spawned by popen() 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:

  1. One inode: Represents the physical pipe object in memory.
  2. Two file objects: One configured strictly for reading, one configured strictly for writing. (This is why pipe() 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:

FieldPurpose
waitThe 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 / writersCounters 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.
curbufThe index (0-15) of the first buffer currently holding data to be read.
nrbufsThe 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:

FieldTypeDescription
pagestruct page *A pointer to the physical page frame in RAM.
offsetunsigned intThe starting byte of the actual data inside that page.
lenunsigned intThe length of the valid data inside that page.
opsstruct 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:

  1. Standard write(): If a user process calls write(), the kernel allocates a brand new page frame in RAM, sets the page pointer to it, and physically copies the user’s data into that page.
  2. Zero-Copy splice(): If a process calls splice(file_fd, pipe_fd), the kernel looks at the file_fd’s data already sitting in the Page Cache. It takes the existing Page Cache pointers and literally just drops them into the pipe’s page fields.

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 release method recycles the page back into the tmp_page cache for the next write.
  • If it is a splice() page borrowed from the Page Cache, the release method 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:

  1. Disk to Page Cache: Hardware DMA (Direct Memory Access) pulls the file from the disk into the kernel’s Page Cache. (Zero CPU cost).
  2. 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).
  3. 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).
  4. 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

MetricTraditional read() + write()Zero-Copy with splice()
Context Switches42
CPU Data Copies20
User Space Buffer Required?YesNo
ThroughputBottlenecked by CPU/RAM speedBottlenecked 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. A pipe_buffer can 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.