Week 5: Interprocess communication
Last week we learned how to create new processes. But, aside from waitpid
, the processes couldn't interact with each other. This week, we'll learn some techniques for communicating between different processes – interprocess communication, or IPC. We'll cover pipes and shared memory with synchronization via semaphores. Later in the course, we'll also talk about Unix domain sockets and signals.
Pipes
A pipe is a one-way data stream. The writer writes to one end and the reader reads from the other end (and there can be multiple writers and readers, though one of each is the typical case).
You may have heard of pipes in the context of shell scripting, such as ps aux | grep myprocess
. The pipes we are talking about are a lower-level OS primitive. In the homework exercises, we'll see how shell pipelines are implemented using OS pipes.
To create a pipe, call pipe2
:
int pipe2(int pipefd[2], int flags);
And you get back a file descriptor for the read end in pipefd[0]
, and a file descriptor for the write end in pipefd[1]
. Then you can use read
and write
as if it were an actual file.
You can use both ends of the pipe in the same process, and occasionally that is useful. But normally you want one end of the pipe in one process and the other end in another. Solution: call pipe2
, then fork
:
int pipefd[2];
int r = pipe2(pipefd, 0);
if (r < 0) { bail("pipe2"); }
pid_t pid = fork();
if (pid < 0) {
bail("fork");
} else if (pid == 0) {
// child
close(pipefd[0]);
// write to pipefd[1]
} else {
// parent
close(pipefd[1]);
// read from pipefd[0]
}
When you fork, you end up with both file descriptors open in both processes. Each process only needs one (the read end or the write end), so it can close the one it doesn't need. File descriptors are per-process, so closing one in the child process does not close it in the parent process, or vice versa.
An important limitation of pipes is that they require the two processes to be related to each other, e.g. parent and child. There is a similar primitive called FIFOs (for "first-in, first-out"; also known as named pipes) which function the same as pipes except that they have an entry in the filesystem so that unrelated processes can discover and use them. See the homework exercises for more.
Shared memory
Shared memory is an efficient form of IPC that avoids copying by mapping the same memory region into multiple processes' address spaces. This lets us transparently share in-memory data structures between unrelated processes, just as if they were threads of the same process. Of course, we'll need some way to synchronize access. For that, we can use a kernel synchronization primitive called named semaphores.
Creating a shared memory region is a three-step process. First, we need to open the shared memory object itself with shm_open
, which looks very similar to open
:
int fd = shm_open("/my-program-mem", O_CREAT | O_EXCL | O_RDWR, 0600);
The name is not a file path, as shared memory objects have their own namespace (though it may be mounted at some special place in the filesystem such as /dev/shm
). The name should always begin with a slash and contain no other slashes.
The shared memory object starts out empty, so we must resize it:
int r = ftruncate(fd, sizeof my_data_structure);
Finally, we can map it into our process's address space:
struct my_data_structure* s = mmap(
NULL, sizeof my_data_structure, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0,
);
if (s == MAP_FAILED) {
// handle error
}
Now, we can use s
normally, and any writes will be visible to any other process using it (and we will likewise see any other process's writes).
If you are casting shared memory to and from C structs, it is very important that all programs use the same struct layout. Imagine what would happen if program 1 had 8 bytes of padding at the end of the struct, but program 2 had no padding.
To ensure that doesn't happen, use fixed-width integer types like uint32_t
, and _Alignas
or equivalent compiler annotation to ensure consistent alignment.
Shared memory and threading
We'll cover multithreading in depth in a later week. For now, just know that a process can have multiple threads of execution, each running independently but sharing the same address space and memory. Which raises the question, why would you use multiple processes and shared memory, when you could have multiple threads in the same process? One reason is to separate concerns between different programs. For instance, you might have a high-performance network application that writes metrics to a shared memory region, and a metrics archiver that reads the same memory region and persists the metrics to disk.
Semaphores
Shared memory lets us share data structures between processes, but we still need a way to synchronize access in the case of multiple writers and readers. For that, we use semaphores:
sem_t* sem = sem_open("/my-program-sem", O_CREAT | O_EXCL, 0600, 1);
Similar to shm_open
, the name passed to sem_open
is not a file path. The fourth argument is the initial value of the semaphore – setting it to 1 makes the semaphore effectively a lock that only one process can hold at a time.
We can then wait for the semaphore to be available:
int r = sem_wait(&sem);
And when we are done, release it:
int r = sem_post(&sem);
Finally, at the end of our program we should clean everything up:
close(fd);
shm_unlink("/my-program-mem");
sem_unlink("/my-program-sem");
Putting it all together:
const char* mem_pathname = "/my-program-mem";
const char* sem_pathname = "/my-program-sem";
void writer() {
// NOTE on error handling: At various points we bail without cleaning up, e.g.
// calling `shm_unlink`. A more robust program should still clean up resources
// even in case of error.
int fd = shm_open(mem_pathname, O_CREAT | O_EXCL | O_RDWR, 0600);
if (fd < 0) { bail("shm_open"); }
int r = ftruncate(fd, sizeof my_data_structure);
if (r < 0) { bail("ftruncate"); }
struct my_data_structure* s;
s = mmap(NULL, sizeof my_data_structure, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (s == MAP_FAILED) { bail("mmap"); }
// After we `mmap`, we can close the shared-memory file descriptor.
r = close(fd);
if (r < 0) { bail("close"); }
sem_t* sem = sem_open(sem_pathname, O_CREAT | O_EXCL, 0600, 1);
if (sem == SEM_FAILED) { bail("sem_open"); }
r = sem_wait(&sem);
if (r < 0) { bail("sem_wait"); }
// ... use shared data structure ...
r = sem_post(&sem);
if (r < 0) { bail("sem_post"); }
r = sem_unlink(sem_pathname)
if (r < 0) { bail("sem_unlink"); }
r = shm_unlink(mem_pathname);
if (r < 0) { bail("shm_unlink"); }
}
void reader() {
int fd = shm_open(pathname, O_RDONLY);
if (fd < 0) { bail("shm_open"); }
struct my_data_structure* s;
s = mmap(NULL, sizeof my_data_structure, PROT_READ, MAP_SHARED, fd, 0);
if (s == MAP_FAILED) { bail("mmap"); }
// After we `mmap`, we can close the shared-memory file descriptor.
int r = close(fd);
if (r < 0) { bail("close"); }
sem_t* sem = sem_open(sem_pathname, 0);
if (sem == SEM_FAILED) { bail("sem_open"); }
r = sem_wait(&sem);
if (r < 0) { bail("sem_wait"); }
// ... use shared data structure ...
r = sem_post(&sem);
if (r < 0) { bail("sem_post"); }
}
Final project milestone
No milestone this week. Take some time to catch up and work on the homework exercises!
Homework exercises
Note: Not all languages expose shared memory and semaphore primitives directly, so you may need to use a third-party library. For Python, check out posix_ipc
in conjunction with the standard library mmap
module.
- (★) List the key differences between pipes and shared memory as forms of IPC.
- (★) What format should be used for the names of shared memory objects and semaphores?
- (★) Explain how two different processes end up with opposite ends of a pipe.
- (★★) Do we get any atomicity guarantees when working with pipes? Read
man 7 pipe
to find out. - (★★) What could go wrong if we don't use semaphores to synchronize access to shared memory? Write an example program to demonstrate the problem.
- (★★) We briefly mentioned another IPC primitive called FIFOs (first-in, first-out) that overcome some of the limitations of pipes. Research them (
man 7 pipe
andman 7 fifo
may help). What system calls do they use? What are the differences from regular pipes? - (★★) What is the buffering behavior of pipes? Pose a hypothesis, then write a test program to find out.
- (★★) We used
mmap
for shared memory, but the system call is more versatile than just that. Read the man page and find out what else it can be used for. - (★★★) Shared memory is often used for high-performance concurrent applications. Implement a ring buffer in shared memory with one producer process putting work into the buffer, and multiple consumer processes reading from it.
- (★★★) High-level languages have a way for a parent process to capture its child's stdout and stderr (e.g.,
capture_output=True
insubprocess.run
in Python). Usepipe2
,fork
, andexecve
to spawn an external program, e.g.,echo hello world
, and capture its output in a string in the parent program. Bonus: How can you use the same technique to implement shell pipelines?Hint
You'll need to usedup2
.