How Bash Process Substitution Works in Linux
Process substitution is a Bash feature that allows the output of a
command to be passed to another program as a standard file argument
rather than a stream via standard input. Using the
<(command) syntax, Linux enables programs that typically
require explicit file paths—such as diff or
comm—to read directly from the output of background
processes. This mechanism relies on core Linux operating system
primitives: subshells, anonymous pipes, the /dev/fd/
virtual filesystem, and asynchronous inter-process communication.
Subshell Forking and Anonymous Pipes
When Bash parses a command containing <(command), it
prepares an inter-process communication channel before executing the
enclosed commands.
- Creating the Pipe: Bash invokes the
pipe()system call to create a unidirectional data channel in kernel memory. This system call returns two file descriptors: one for reading and one for writing. - Spawning the Subshell: Bash forks a child process (a subshell) to run the command inside the parentheses.
- Redirecting Standard Output: Inside this child
subshell, Bash replaces the standard output (file descriptor
1) with the write end of the pipe using thedup2()system call, then closes the unused read descriptor. The subshell then executes the command, sending its stdout directly into the pipe buffer.
Path Generation via
/dev/fd/
Traditional shell pipes (|) only connect the stdout of
one command directly to the stdin of another. However, many commands
expect input to be provided as a file path argument (for example,
diff file1 file2).
To satisfy this requirement without creating temporary files on disk,
Bash uses the /dev/fd/ filesystem. In Linux,
/dev/fd/ is a symbolic link to /proc/self/fd/,
which exposes open file descriptors for the current process as file
paths.
Bash takes the read end of the anonymous pipe, assigns it to an open
file descriptor in the main shell (often a high descriptor number, such
as 63), and substitutes the <(...)
expression with the string path to that descriptor:
diff <(cat list1.txt) <(cat list2.txt)To the operating system, this command is transformed into:
diff /dev/fd/63 /dev/fd/62Execution and Kernel Data Transfer
Once the string substitution is complete, the outer command executes
with /dev/fd/<n> passed as normal command-line
arguments.
When the target program calls open() on
/dev/fd/<n>, the Linux Virtual File System (VFS)
resolves the path to the corresponding open file description in the
kernel, which points directly to the read end of the pipe.
Data flows entirely through the Linux kernel's memory buffer (typically 64 KB by default) from the writing child subshell to the reading command. If the writing subshell produces data faster than the reader consumes it, the kernel blocks the subshell until buffer space becomes available. Conversely, if the reader reads faster than the subshell writes, the reader blocks until new data arrives.
Cleanup and Fallback Mechanisms
When the command inside the subshell completes, its standard output closes, which signals an End of File (EOF) to the reading process on the other end of the pipe.
Once the outer command finishes reading and exits:
- The kernel releases the pipe and reclaims the memory buffer.
- Bash closes the file descriptors allocated for the substitution.
- Bash collects the exit status of the background subshells using the
wait()system call to prevent zombie processes.
While modern Linux distributions rely on /dev/fd/ for
process substitution, systems lacking support for /dev/fd/
cause Bash to fall back on creating temporary named pipes (FIFOs) via
the mkfifo() system call in /tmp, which are
subsequently unlinked after execution finishes.