How do pipes and FIFOs work on Linux?
— October 9, 2018In my earlier post, I mentioned briefly that I’m working on a shell in Go. I’ve completed a working prototype and I’m looking into implementing pipes. After doing some readings, I think I understand how anonymous pipes and named pipes (FIFOs) work now.
Motivation
Here’s an example on executing a set of bash commands sequentially. We will first list all files in the current directory line by line, and then sort them in ascending order.
This is done through the pipe character (|
), which is also known as the vertical bar. The idea to using pipes is very simple: the output of one process is used as the input to another process. This method of communication allows processes to transfer data between them without the need of temporary files. Note that pipes are always half-duplex (i.e. data flows in one direction).
How it works?
Pipe, as the name suggests, contains two ends: one for reading, and one for writing. We can create pipes by using the pipe2() system call. This will create two file descriptors referring to the ends of the pipe. Data written to the write end of the pipe is buffered by the kernel in memory until it is read from the other end of the pipe.
I’m going to dive a little bit into the Linux kernel source code: linux/fs/pipe.c.
Let’s first look into writing to a pipe. Pipes in Linux have limited capacity. In recent Linux versions, the max pipe size is 16 pages (i.e., 65,536 bytes in a system with a page size of 4096 bytes).
Attempting to write to the pipe when it is full will block (or fail with EAGAIN
if the O_NONBLOCK
flag is set when creating a new pipe).
We can also see the following snippet of code in the pipe_write()
function. When a process attempts to write to a pipe in which all readers on the other end of the pipe have been closed, a SIGPIPE
signal will be generated. This is actually the “Broken pipe” error!
We’ll now look into reading from a pipe. When a process attempts to read from a pipe that has no buffered data, blocking occurs until we have a new event (see pipe_wait(pipe)
). Now, if all of the writers on the write end of the pipe have been closed, reading from the pipe will return EOF
. This makes sense because there won’t be any more input in the future.
Implementation
We will attempt to implement the following set of bash commands in C and Go.
General Idea:
- With
n
processes, we will needn - 1
pipes. - The parent process will make
n - 1
system calls to create the respective pipes (This might be inefficient, but we’ll discuss this later.) - The parent process then forks itself
n
times using the fork() system call. - For every single forked process:
- The parent process will wait for all the forked process to finish.
In the example above, the parent process handles all the pipe creation. This set of file descriptors gets duplicated across all forked processes, which will then need to be closed. We can potentially avoid this overhead by creating just one pipe in every single forked process (except the last one) to connect the current and next process, but there will be more complexity in the implementation.
Here’s an example on how we would implement it in C: https://gist.github.com/imjching/61b4f07abaf3a079d704a24c4a7406b8
Here’s a similar example in Go. In this case, instead of forking processes, we will use exec.Command
to spawn a new process. Go does not offer a classic Unix fork
function since goroutines, spawning processes, and exec’ing processes covers most use cases for fork
(see https://gobyexample.com/execing-processes).
Note that io.Pipe()
is making a pipe2()
system call under the hood (see src/os/pipe_linux.go). It returns two File types, which represent open file descriptors.
// Pipe returns a connected pair of Files; reads from r return bytes written to w.
// It returns the files and an error, if any.
func Pipe() (r *File, w *File, err error) {
var p [2]int
e := syscall.Pipe2(p[0:], syscall.O_CLOEXEC)
// pipe2 was added in 2.6.27 and our minimum requirement is 2.6.23, so it
// might not be implemented.
if e == syscall.ENOSYS {
...
} else if e != nil {
return nil, nil, NewSyscallError("pipe2", e)
}
return newFile(uintptr(p[0]), "|0", kindPipe), newFile(uintptr(p[1]), "|1", kindPipe), nil
}
Named pipes (FIFOs)
Unlike anonymous pipes above, named pipes are created as special files in the file system. Any process with sufficient access privileges can open that file for reading or writing. Data written to these pipes are not persisted in the file system. However, the named pipe file itself needs to be removed manually.
Named pipes can be created using mkfifo command or system call.
The above command creates the corresponding device on the file system.
Since the named pipe is just like a normal file, we can delete the pipe that we have just created by calling rm mypipe
.
Takeaways
-
EAGAIN
is an error type that is often raised when performing a non-blocking I/O.
-
The Linux source code is MASSIVE. There’s a lot that I don’t know about it.
-
The
file
command allows you to determine the file type of a specific file.
Readings!
Here are some useful resources that I have encountered while reading about pipes and FIFOs:
-
Chapter 44 (pages 889 - 920) of TLPI (The Linux Programming Interface)!
- It seems like pipes are implemented using virtual file systems (e.g.
pipefs
for Linux) based on what I have read so far. I don’t understand file systems well enough to explain it, so I would suggest reading this Quora post if you want to know how Unix pipes are implemented under the hood. -
Notes on fork() and pipe()
- How do I use execve in C?
Fun fact: It seems like Firefox uses pipes under the hood for small messages when communicating between two actors (See IPC Protocols)!