IPC — Inter-Process Communication
How isolated Unix processes pass data to each other — anonymous pipes (pipe()), I/O redirection (dup2()), named pipes (FIFO), and shared memory (mmap()).
Why do processes need to communicate?
Imagine two workers in separate sealed rooms. They cannot see each other's desks, cannot touch each other's papers — they are completely isolated. To exchange information, they need a channel: a walkie-talkie, a pneumatic tube, or a shared whiteboard visible through a window. In Unix, processes are those workers, and IPC mechanisms are those channels. A pipe is the walkie-talkie — unidirectional, one worker talks while the other listens. Shared memory is the whiteboard — both can read and write simultaneously, but they must agree on turn-taking.
After fork(), a child process gets a copy of the parent's address space. They share nothing — writing to a variable in one process does not affect the other. This isolation is fundamental to Unix process safety. But many programs need cooperation: a shell needs to connect ls's output to grep's input, a web server needs to hand a request off to a worker process, a database needs to pass query results back to the caller. This is the problem IPC solves.
The kernel provides several IPC mechanisms. Each has different trade-offs in complexity, performance, and use-case fit:
- Created with
pipe() - Unidirectional (one-way)
- Only between related processes (parent/child)
- In-kernel buffer (~64 KB)
- Simplest to use
- EOF when write-end closed
- Created with
mmap()orshmget() - Both processes read and write same region
- Fastest IPC (no kernel copy)
- Works between unrelated processes
- Requires synchronization (mutexes/semaphores)
- Manual lifecycle management
- Created with
mkfifo() - Appears as a file in the filesystem
- Works between unrelated processes
- Still unidirectional
- Both ends must open before data flows
- Persists until explicitly removed
The producer/consumer model underpins almost all IPC. One process produces data (writes to the pipe / writes to shared memory), another consumes it (reads from the pipe / reads from shared memory). The key question is always: how does the consumer know data is ready, and how does the producer know there is room to write? Pipes solve this automatically — read() blocks until data arrives, write() blocks until there is room. Shared memory does not — you must add explicit synchronization yourself.
File descriptors are the common currency. Pipes are accessed through file descriptors, just like files. The same read() and write() system calls work on both. This is the Unix "everything is a file" philosophy. When you run ls | grep foo in the shell, the shell creates an anonymous pipe, connects ls's stdout (fd 1) to the pipe's write end, and connects grep's stdin (fd 0) to the pipe's read end. The tools themselves never know they are connected to a pipe — they just use their normal stdin/stdout.
Every file descriptor you do not need must be close()d immediately. If the parent keeps the write-end open while waiting for the child to finish writing, the child's read() will block forever — the kernel only signals EOF on a pipe when all write-ends are closed. Forgetting to close unused ends of a pipe is the #1 source of deadlocks in IPC code.
pipe() = kernel tube, bytes in one end come out the other. filedes[0] = read end, filedes[1] = write end. Close the end you don't use. dup2() = wire a file descriptor to stdin/stdout. mmap(MAP_SHARED) = shared whiteboard between processes. All are accessed with the same read()/write() syscalls.
pipe(), dup2(), and close() — annotated
pipe() — create an anonymous pipe
/* pipe() — fills a 2-element array with two file descriptors */ int pipe(int filedes[2]); ^ ^ ^ | | └── int array of exactly 2 elements — filled by the OS | └────────── system call name (in <unistd.h>) └────────────── returns 0 on success, -1 on failure (check errno) int fds[2]; pipe(fds); │ ├── fds[0] = READ end (your process reads data from here) └── fds[1] = WRITE end (your process writes data here) /* Data flows: write into fds[1] →→→→→→ out of fds[0] */ /* Direction: fds[1] ─────────────────────────► fds[0] */
dup2() — redirect a file descriptor
/* dup2() — make newfd a copy of oldfd, closing newfd first if open */ int dup2(int oldfd, int newfd); ^ ^ ^ ^ | | | └── the fd number you want to USE (e.g. 0=stdin, 1=stdout) | | └──────────── the fd that contains the data source/sink you want | └──────────────────── system call name (in <unistd.h>) └──────────────────────── returns newfd on success, -1 on failure /* Classic pattern: redirect stdout into pipe write-end */ dup2(fds[1], 1); /* fd 1 (stdout) now points at fds[1] (pipe write end) */ ^ ^ | └── STDOUT_FILENO = 1 — what we want to reassign └─────── the pipe write-end fd we created with pipe() /* After dup2: anything printf()s goes into the pipe */ close(fds[1]); /* now close the original — dup2 made a copy, this is now redundant */
dup2(oldfd, newfd), both oldfd and newfd point to the same underlying file/pipe. You must then close(oldfd) so there is only one reference. If you don't, you'll have a dangling file descriptor — and the pipe write-end will never reach EOF.
close() discipline — the golden rules
/* GOLDEN RULE: close every fd you won't use — immediately after fork() */ /* In PARENT (acts as writer): */ close(fds[0]); /* close the READ end — parent only writes */ write(fds[1], buf, n); close(fds[1]); /* close after done writing — signals EOF to child */ /* In CHILD (acts as reader): */ close(fds[1]); /* close the WRITE end — child only reads */ read(fds[0], buf, n); /* blocks until data arrives or write-end is closed */ /* read() returns 0 (EOF) when all write-ends are closed */ close(fds[0]); /* WHY: the kernel counts references to each pipe end. Only when ALL write-end fds are closed does read() get EOF. If parent forgot to close fds[1], child blocks forever. */
mmap() — shared memory between processes
/* mmap() — map memory; with MAP_SHARED + MAP_ANONYMOUS, creates shared region */ void *mmap(void *addr, /* NULL = let kernel choose address */ size_t length, /* bytes to map */ int prot, /* PROT_READ | PROT_WRITE */ int flags, /* MAP_SHARED | MAP_ANONYMOUS */ int fildes, /* -1 for anonymous (no backing file) */ off_t offset); /* 0 for anonymous */ /* Usage: create before fork(), then both parent and child share it */ int *shared = mmap(NULL, sizeof(int), PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0); /* Returns MAP_FAILED on error — check with: if (shared == MAP_FAILED) ... */ /* Cleanup: munmap(shared, sizeof(int)); */
MAP_SHARED means writes are visible to all other processes that have mapped the same region. MAP_PRIVATE would give copy-on-write behavior (changes invisible to others). For IPC you always want MAP_SHARED.
shm_open / shm_unlink — POSIX shared memory for independent processes
/* POSIX Shared Memory — for INDEPENDENT processes (not just parent-child) */ /* Requires: #include <sys/mman.h>, #include <fcntl.h>, link with -lrt on Linux */ /* Process A (creator): */ int fd = shm_open("/my_shm", O_CREAT | O_RDWR, 0666); ftruncate(fd, sizeof(SharedData)); /* set size — REQUIRED before mmap */ SharedData *p = mmap(NULL, sizeof(SharedData), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); close(fd); /* fd no longer needed after mmap — the mapping keeps the object alive */ /* Process B (consumer) — same shm name, no O_CREAT: */ int fd = shm_open("/my_shm", O_RDWR, 0666); SharedData *p = mmap(NULL, sizeof(SharedData), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); close(fd); /* Cleanup (once, by whoever finishes last): */ munmap(p, sizeof(SharedData)); shm_unlink("/my_shm"); /* removes the name from /dev/shm — like unlink() for files */
• Named: any process that knows the name
/my_shm can attach — path must start with /. Used in /dev/shm on Linux.
• Persistent: survives
fork(); the object lives until shm_unlink() removes the name (even after all processes detach, the data stays until explicitly removed).
• ftruncate required: a new shm object has size 0. You must call
ftruncate(fd, size) to set its size before mapping — mmap on a zero-length object fails.
• Independent processes: unlike
MAP_ANONYMOUS | MAP_SHARED (only works across fork()), shm_open lets two unrelated programs share memory using just the name.
mprotect() — change memory page protection
/* mprotect() — change protection of memory pages containing [addr, addr+len) */ int mprotect(void *addr, size_t len, int prot); ^ ^ ^ ^ ^ | | | | └── new protection flags (same as mmap prot) | | | └──────────── byte length of the region to change | | └──────────────────────── start address — MUST be page-aligned | └────────────────────────────────── system call (in <sys/mman.h>) └────────────────────────────────────── returns 0 on success, -1 on error /* prot flags (same set as mmap): */ /* PROT_NONE — no access at all (any access = SIGSEGV) */ /* PROT_READ — pages are readable */ /* PROT_WRITE — pages are writable */ /* PROT_EXEC — pages are executable */ /* Example: make a page read-only, catch writes as SIGSEGV */ void *page = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0); /* ... write data to page ... */ mprotect(page, 4096, PROT_READ); /* now read-only */ /* Any write to 'page' now triggers SIGSEGV */ /* Catch it with sigaction SA_SIGINFO to handle gracefully */
mprotect is how you intentionally trigger a SIGSEGV for testing signal handlers. Map a page with PROT_READ|PROT_WRITE, write data, then call mprotect(..., PROT_READ) to lock it. Any subsequent write raises SIGSEGV — caught by a sigaction handler registered with SA_SIGINFO to inspect the faulting address via si_addr. Also used to implement memory-access guards (e.g. stack canary pages, read-only data sections, JIT compilers toggling PROT_EXEC).
FIFO / Named Pipe — the filesystem pipe
/* mkfifo() — create a named pipe (appears as a file in the filesystem) */ int mkfifo(const char *path, mode_t mode); e.g. "/tmp/myfifo" 0666 = rw-rw-rw- /* Writer process: */ int fd = open("/tmp/myfifo", O_WRONLY); /* BLOCKS until reader also opens */ write(fd, buf, n); close(fd); /* Reader process (different program, different terminal): */ int fd = open("/tmp/myfifo", O_RDONLY); /* BLOCKS until writer also opens */ read(fd, buf, n); close(fd); /* Cleanup: unlink("/tmp/myfifo"); — removes the filesystem entry */
open() until the other side also opens — a built-in rendezvous point.
IPC Mechanisms — Quick Reference Table
| Mechanism | Syscall(s) | Direction | Related procs? | Header |
|---|---|---|---|---|
| Anonymous pipe | pipe(fds) |
Unidirectional | Yes (fork) | <unistd.h> |
| Named pipe (FIFO) | mkfifo(), open() |
Unidirectional | No | <sys/stat.h> |
| Shared memory (mmap) | mmap(), munmap() |
Bidirectional | Yes (fork) | <sys/mman.h> |
| SysV Shared mem | shmget(), shmat() |
Bidirectional | No | <sys/shm.h> |
| I/O redirect | dup2(oldfd, newfd) |
N/A (wiring) | Yes | <unistd.h> |
| Signals | kill(), sigaction() |
Notification only | No | <signal.h> |
| POSIX shm (shm_open) | shm_open(), ftruncate(), mmap(), shm_unlink() |
Bidirectional | No (by name) | <sys/mman.h>, <fcntl.h> |
| mprotect | mprotect(addr, len, prot) |
N/A (protection) | N/A | <sys/mman.h> |
Complete C programs you can compile and run
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h> /* pipe(), fork(), read(), write(), close() */
#include <sys/wait.h> /* waitpid() */
int main(void) {
int fds[2]; /* fds[0] = read end, fds[1] = write end */
/* 1. Create the pipe BEFORE fork() so both processes inherit both ends */
if (pipe(fds) == -1) {
perror("pipe");
return 1;
}
pid_t pid = fork();
if (pid == -1) { perror("fork"); return 1; }
if (pid == 0) {
/* ── CHILD PROCESS (reader) ── */
close(fds[1]); /* close the write end — child only reads */
char buf[64];
ssize_t n = read(fds[0], buf, sizeof(buf) - 1);
/* read() blocks here until parent writes or closes fds[1] */
if (n > 0) {
buf[n] = '\0'; /* null-terminate what we read */
printf("Child received: \"%s\" (%zd bytes)\n", buf, n);
}
close(fds[0]); /* done reading */
exit(0);
} else {
/* ── PARENT PROCESS (writer) ── */
close(fds[0]); /* close the read end — parent only writes */
const char *msg = "Hello from parent!";
write(fds[1], msg, strlen(msg));
close(fds[1]); /* closing write end sends EOF to child */
waitpid(pid, NULL, 0); /* wait for child to finish */
printf("Parent: child has exited.\n");
}
return 0;
}
Parent: child has exited.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
/* This simulates what a shell does for: child_prog | parent_reads
The child's printf() output is captured into the pipe.
The parent reads whatever the child printed. */
int main(void) {
int fds[2];
if (pipe(fds) == -1) { perror("pipe"); return 1; }
pid_t pid = fork();
if (pid == -1) { perror("fork"); return 1; }
if (pid == 0) {
/* ── CHILD: redirect stdout (fd 1) → pipe write end ── */
close(fds[0]); /* child won't read — close read end */
/* dup2(oldfd, newfd): makes newfd point to same file as oldfd.
Here: make fd 1 (stdout) point to fds[1] (pipe write end). */
if (dup2(fds[1], 1) == -1) { perror("dup2"); exit(1); }
close(fds[1]); /* original pipe write end is now redundant */
/* Now printf writes to the pipe instead of the terminal */
printf("Line 1 from child\n");
printf("Line 2 from child\n");
printf("Line 3 from child\n");
/* printf uses stdout (fd 1), which now goes to the pipe */
exit(0);
} else {
/* ── PARENT: read everything the child printed ── */
close(fds[1]); /* parent won't write — close write end */
char buf[256];
ssize_t n;
printf("Parent reading from pipe:\n");
/* read() loop: read() returns 0 (EOF) when all write ends closed */
while ((n = read(fds[0], buf, sizeof(buf) - 1)) > 0) {
buf[n] = '\0';
printf("%s", buf); /* print what we received */
}
close(fds[0]);
waitpid(pid, NULL, 0);
printf("Parent: done.\n");
}
return 0;
}
Line 1 from child
Line 2 from child
Line 3 from child
Parent: done.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h> /* mmap(), munmap(), MAP_SHARED, MAP_ANONYMOUS */
#include <sys/wait.h>
int main(void) {
/* Allocate a shared integer BEFORE fork() */
int *shared_val = mmap(
NULL, /* let kernel choose address */
sizeof(int), /* size of shared region */
PROT_READ | PROT_WRITE, /* readable and writable */
MAP_SHARED | MAP_ANONYMOUS, /* shared between processes, no file */
-1, 0 /* -1 fd for anonymous, 0 offset */
);
if (shared_val == MAP_FAILED) { perror("mmap"); return 1; }
*shared_val = 0; /* initialize before forking */
pid_t pid = fork();
if (pid == -1) { perror("fork"); return 1; }
if (pid == 0) {
/* CHILD: increment the shared value */
printf("Child: shared_val before = %d\n", *shared_val);
*shared_val = 42;
printf("Child: set shared_val to 42\n");
exit(0);
} else {
/* PARENT: wait for child, then observe the change */
waitpid(pid, NULL, 0);
printf("Parent: shared_val after child = %d\n", *shared_val);
/* With MAP_PRIVATE (copy-on-write) this would still be 0! */
munmap(shared_val, sizeof(int)); /* release the mapping */
}
return 0;
}
Child: set shared_val to 42
Parent: shared_val after child = 42
Practice problems with solutions
Without running the code, trace the execution. What does each process print? Does the child's read() ever return? Explain why or why not.
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>
int main(void) {
int fds[2];
pipe(fds);
if (fork() == 0) {
/* child */
char buf[16];
ssize_t n = read(fds[0], buf, 15);
buf[n] = '\0';
printf("child got: %s\n", buf);
_exit(0);
}
/* parent — NOTE: fds[0] is NOT closed here */
write(fds[1], "ping", 4);
close(fds[1]);
wait(NULL);
printf("parent done\n");
return 0;
}
child got: ping
parent done
1.
pipe(fds) creates the pipe. Both parent and child inherit both ends after fork().2. Child calls
read(fds[0], ...) — blocks because the pipe is empty.3. Parent writes
"ping" to fds[1]. This wakes the child's read(). Child reads 4 bytes, prints child got: ping.4. Parent calls
close(fds[1]). Now all write-ends are closed (child never had fds[1] open — it only inherited it but never explicitly closed it; since child called _exit(0) all its fds are closed automatically). The child's read() already returned 4 bytes before this point.5. Parent calls
wait(NULL), child is already done. Parent prints parent done.Note: The parent does not close
fds[0]. This is a minor file descriptor leak but does not cause a deadlock here because the parent is the writer, not the reader. The issue would arise if the parent tried to read() — then it would block forever because it holds the write-end open itself.
A single pipe() is unidirectional. Implement a parent-child program where: the parent sends a number to the child, the child doubles it and sends it back, and the parent prints the result. Use two pipes: one for each direction.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
int p2c[2]; /* parent-to-child pipe */
int c2p[2]; /* child-to-parent pipe */
if (pipe(p2c) == -1 || pipe(c2p) == -1) {
perror("pipe"); return 1;
}
pid_t pid = fork();
if (pid == -1) { perror("fork"); return 1; }
if (pid == 0) {
/* ── CHILD ── */
close(p2c[1]); /* won't write to parent-to-child */
close(c2p[0]); /* won't read from child-to-parent */
int val;
read(p2c[0], &val, sizeof(int)); /* receive number from parent */
close(p2c[0]);
val *= 2; /* double it */
write(c2p[1], &val, sizeof(int)); /* send back */
close(c2p[1]);
exit(0);
} else {
/* ── PARENT ── */
close(p2c[0]); /* won't read from parent-to-child */
close(c2p[1]); /* won't write to child-to-parent */
int send_val = 21;
write(p2c[1], &send_val, sizeof(int));
close(p2c[1]); /* signal child we're done sending */
int recv_val;
read(c2p[0], &recv_val, sizeof(int));
close(c2p[0]);
printf("Sent: %d, Received: %d\n", send_val, recv_val);
waitpid(pid, NULL, 0);
}
return 0;
}
pipe() calls for bidirectional communication. Each process closes the end it does not use from each pipe — 4 close calls total in each process. The critical discipline: close p2c[1] after writing so the child's read() can return. Without this, the child would block waiting for more data even after receiving the integer.
This program is supposed to have the parent write "hello" and the child read it. But it deadlocks — the child's read() never returns even though the parent finished writing. Find the bug and fix it.
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
int fds[2];
pipe(fds);
if (fork() == 0) {
/* child */
char buf[16];
ssize_t n = read(fds[0], buf, 15); /* deadlocks here! */
buf[n] = '\0';
printf("child: %s\n", buf);
_exit(0);
}
/* parent */
write(fds[1], "hello", 5);
close(fds[1]);
wait(NULL);
return 0;
}
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
int fds[2];
pipe(fds);
if (fork() == 0) {
/* child — FIX: close the write end before reading */
close(fds[1]); /* ← THIS WAS MISSING */
char buf[16];
ssize_t n = read(fds[0], buf, 15);
buf[n] = '\0';
printf("child: %s\n", buf);
close(fds[0]);
_exit(0);
}
/* parent */
close(fds[0]); /* also good practice: close read end in parent */
write(fds[1], "hello", 5);
close(fds[1]);
wait(NULL);
return 0;
}
fork(), the child inherits both ends of the pipe: fds[0] and fds[1]. The parent closes its fds[1], but the child still has fds[1] open. The kernel only delivers EOF to read(fds[0]) when all write-end file descriptors are closed. Since the child holds a reference to fds[1], the reference count never reaches zero, and read() blocks forever waiting for more data that will never come. The fix: child must close(fds[1]) immediately after fork, before reading.
Write a program where the parent writes the string "hello world\n" into a pipe, and the child uses dup2() to make that pipe its stdin (fd 0), then calls exec to run /bin/cat. The cat program should read from stdin and print what it receives — demonstrating how a shell implements echo "hello world" | cat.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
int fds[2];
if (pipe(fds) == -1) { perror("pipe"); return 1; }
pid_t pid = fork();
if (pid == -1) { perror("fork"); return 1; }
if (pid == 0) {
/* ── CHILD: wire stdin to pipe read end, then exec cat ── */
close(fds[1]); /* child won't write */
/* dup2: make fd 0 (stdin) point to fds[0] (pipe read end) */
if (dup2(fds[0], 0) == -1) { perror("dup2"); exit(1); }
close(fds[0]); /* original fd no longer needed */
/* Now fd 0 (stdin) is the pipe read end.
cat reads from stdin, which is now our pipe. */
execlp("cat", "cat", NULL);
perror("execlp"); /* only reached if exec fails */
exit(1);
} else {
/* ── PARENT: write data into pipe, then close write end ── */
close(fds[0]); /* parent won't read */
const char *data = "hello world\n";
write(fds[1], data, strlen(data));
close(fds[1]); /* close sends EOF to cat's stdin */
waitpid(pid, NULL, 0);
}
return 0;
}
dup2(fds[0], 0) makes fd 0 (stdin) an alias for fds[0] (pipe read end). After exec, all file descriptors except those marked close-on-exec survive — so cat inherits fd 0 pointing at the pipe. When the parent closes its fds[1], cat's read(stdin) gets EOF and terminates. This is exactly how Unix shells implement the pipe operator |.
Explain: (a) how a FIFO is created and used compared to pipe(), (b) why you cannot use pipe() between two completely unrelated processes (different parent), and (c) one scenario where you must use a FIFO instead of an anonymous pipe.
/* Creating and using a FIFO */
/* Step 1: create (one-time setup, e.g. at startup or via shell) */
mkfifo("/tmp/myfifo", 0666);
/* Step 2: writer process (any process that knows the path) */
int wfd = open("/tmp/myfifo", O_WRONLY); /* blocks until reader opens */
write(wfd, "data", 4);
close(wfd);
/* Step 3: reader process (any process that knows the path) */
int rfd = open("/tmp/myfifo", O_RDONLY); /* blocks until writer opens */
char buf[16];
read(rfd, buf, 16);
close(rfd);
/* Cleanup */
unlink("/tmp/myfifo"); /* remove from filesystem */
pipe()) exists only in kernel memory — it has no filesystem path. A FIFO appears as a special file in the filesystem (created with mkfifo()). Both use the same kernel pipe buffer underneath. FIFOs are opened with open() using a path; anonymous pipes are accessed through file descriptors returned directly by pipe().(b) Why pipe() can't span unrelated processes:
pipe() returns two file descriptors in the current process. The only way another process can get those file descriptors is through fork() — the child inherits open file descriptors. Two completely unrelated processes (different lineages) cannot share the pipe descriptors because they have no common ancestor to inherit from.(c) Scenario requiring FIFO: Imagine a logging daemon and multiple independent programs that want to send log messages to it. Each program can be started independently by different users or scripts — they have no common ancestor. A FIFO at a well-known path (e.g.
/var/run/myapp.fifo) lets all of them send data to the daemon without needing a shared parent process.
You are to complete part of a simulation for n ducks in flight. The ducks fly in a single-line formation in 1D space. Each has an x coordinate, a velocity, and a reference to the duck in front. All ducks begin with velocity 1.0f facing the same direction. Positions are float; velocity is both speed and direction (positive or negative).
typedef int (*quack_f)(int);
struct duck {
float position[1]; /* x coordinate */
float velocity[1]; /* x in m/s */
struct duck *duck_in_front;
quack_f quack;
};
extern quack_f happy_quack, sad_quack, angry_quack, neutral_quack;
extern int same_direction(struct duck *, struct duck *);
extern void update_position(struct duck **, int, float, int *);
The simulation loop calls events then update_position(). Your task is to implement same_direction() and all five parts of update_position().
Part 1 (5 marks): Write same_direction(struct duck *d1, struct duck *d2).
Returns >0 if both ducks move in the same direction. Returns 0 if either duck's velocity is zero. Returns <0 otherwise. If d1 or d2 are NULL, behaviour is undefined.
Parts 2–5 are implemented inside:
void update_position(struct duck **ducks, int n, int max, float timestep, int *died)
Arguments: ducks — array of struct duck * of size max with n active ducks; timestep — always > FLOAT_EPS; died — pointer to counter (non-NULL). NULL elements in the array must be skipped. You cannot duplicate the O(n) memory of the ducks array.
Part 2 (13 marks) — Update duck position:
Update each duck's position p using p' = p + vΔt. A duck cannot occupy the area of another duck: if the new position p' is within radius 2.5 of another duck, iteratively attempt p' + v×k (k=1,2,...) until no other duck is within the radius.
Part 3 (13 marks) — Overtaking:
Overtaking occurs when CurrDuck and DuckInFront move in the same direction (per same_direction()) and CurrDuck's new position is further in that direction than DuckInFront's position. If so: DuckInFront's duck_in_front becomes CurrDuck; CurrDuck's duck_in_front becomes DuckInFrontInFront. CurrDuck's quack becomes angry_quack; DuckInFront's quack becomes sad_quack. Repeat until no more overtaking (may require revisiting earlier ducks).
Part 4 (6 marks) — Flying separate ways:
If CurrDuck and DuckInFront do NOT move in the same direction (per same_direction()), set CurrDuck's duck_in_front to NULL and set DuckInFront's quack to sad_quack.
Part 5 (13 marks) — Dead duck:
A duck dies when fabs(velocity) < 0.005: free it and set its array element to NULL. If any living duck has sad_quack and its duck_in_front is a dead duck, it also dies (cascade; repeat until stable). Living ducks with references to dead ducks set duck_in_front = NULL. If *died == 0 after all deaths, set all remaining ducks to happy_quack; otherwise neutral_quack. Increment *died for each dead duck.
Parts must be performed in order (2, 3, 4, 5). You may write helper functions. Useful: double fabs(double x).
/* ===== PART 1: same_direction ===== */
int same_direction(struct duck *d1, struct duck *d2) {
/* Returns 0 if either velocity is zero */
if (d1->velocity[0] == 0.0f || d2->velocity[0] == 0.0f)
return 0;
/* Both positive or both negative → same direction → positive result */
/* One positive one negative → negative result */
if ((d1->velocity[0] > 0.0f && d2->velocity[0] > 0.0f) ||
(d1->velocity[0] < 0.0f && d2->velocity[0] < 0.0f))
return 1;
return -1;
}
/* ===== PARTS 2–5: update_position ===== */
/* Helper: returns 1 if two ducks are within radius 2.5 of each other */
static int ducks_clash(struct duck *a, struct duck *b) {
float diff = a->position[0] - b->position[0];
if (diff < 0.0f) diff = -diff;
return diff < 2.5f;
}
void update_position(struct duck **ducks, int n, int max,
float timestep, int *died) {
*died = 0;
/* ===== PART 2: Update positions (p' = p + v*dt, resolve clashes) ===== */
for (int i = 0; i < max; i++) {
if (ducks[i] == NULL) continue;
struct duck *curr = ducks[i];
float new_pos = curr->position[0] + curr->velocity[0] * timestep;
int k = 0;
/* Keep stepping forward by velocity until no clash */
int clash = 1;
while (clash) {
clash = 0;
for (int j = 0; j < max; j++) {
if (j == i || ducks[j] == NULL) continue;
/* Temporarily check if new_pos clashes with ducks[j] */
float diff = new_pos - ducks[j]->position[0];
if (diff < 0.0f) diff = -diff;
if (diff < 2.5f) {
clash = 1;
k++;
new_pos = curr->position[0]
+ curr->velocity[0] * timestep
+ curr->velocity[0] * k;
break;
}
}
}
curr->position[0] = new_pos;
}
/* ===== PART 3: Overtaking ===== */
/* Repeat until no overtakes remain (re-scan on any overtake) */
int overtook = 1;
while (overtook) {
overtook = 0;
for (int i = 0; i < max; i++) {
if (ducks[i] == NULL) continue;
struct duck *curr = ducks[i];
struct duck *front = curr->duck_in_front;
if (front == NULL) continue;
/* Overtake condition: same direction AND curr is further than front */
if (same_direction(curr, front) > 0) {
int curr_overtook = 0;
if (curr->velocity[0] > 0.0f) {
/* Positive direction: higher position = further */
curr_overtook = (curr->position[0] > front->position[0]);
} else {
/* Negative direction: lower position = further */
curr_overtook = (curr->position[0] < front->position[0]);
}
if (curr_overtook) {
struct duck *front_in_front = front->duck_in_front;
/* Swap: front's duck_in_front becomes curr */
front->duck_in_front = curr;
/* curr takes front's old front */
curr->duck_in_front = front_in_front;
/* Update quack functions */
curr->quack = angry_quack;
front->quack = sad_quack;
overtook = 1;
/* Assumption: restart the scan to catch cascading overtakes */
break;
}
}
}
}
/* ===== PART 4: Flying separate ways ===== */
for (int i = 0; i < max; i++) {
if (ducks[i] == NULL) continue;
struct duck *curr = ducks[i];
struct duck *front = curr->duck_in_front;
if (front == NULL) continue;
/* If NOT same direction (includes one velocity = 0) */
if (same_direction(curr, front) <= 0) {
curr->duck_in_front = NULL;
front->quack = sad_quack;
}
}
/* ===== PART 5: Dead ducks ===== */
/* Mark initially dead ducks (|velocity| < 0.005) */
int changed = 1;
while (changed) {
changed = 0;
for (int i = 0; i < max; i++) {
if (ducks[i] == NULL) continue;
struct duck *curr = ducks[i];
/* Primary death condition */
int should_die = 0;
float v = curr->velocity[0];
if (v < 0.0f) v = -v;
if (v < 0.005f) should_die = 1;
/* Cascade: sad_quack duck whose duck_in_front is dead (NULL after free) */
/* We track dead ducks by checking if duck_in_front points to a freed slot.
To detect this, we check if duck_in_front is non-NULL but not in the array */
if (!should_die && curr->quack == sad_quack
&& curr->duck_in_front != NULL) {
/* Check if duck_in_front is a dead duck (not in the array anymore) */
int found = 0;
for (int j = 0; j < max; j++) {
if (ducks[j] == curr->duck_in_front) { found = 1; break; }
}
if (!found) should_die = 1;
}
if (should_die) {
free(curr);
ducks[i] = NULL;
(*died)++;
changed = 1;
}
}
}
/* Fix up: living ducks with references to dead ducks → set duck_in_front to NULL */
for (int i = 0; i < max; i++) {
if (ducks[i] == NULL) continue;
struct duck *curr = ducks[i];
if (curr->duck_in_front == NULL) continue;
int found = 0;
for (int j = 0; j < max; j++) {
if (ducks[j] == curr->duck_in_front) { found = 1; break; }
}
if (!found) curr->duck_in_front = NULL;
}
/* Set quack function based on deaths */
quack_f final_quack = (*died == 0) ? happy_quack : neutral_quack;
for (int i = 0; i < max; i++) {
if (ducks[i] != NULL)
ducks[i]->quack = final_quack;
}
}
Part 2 — Position update: Apply p' = p + vΔt. Then check against all other non-NULL ducks. If within radius 2.5, increment k and try p_original + v*(Δt + k). Loop until no clash. This is O(n²) per duck but correct.
Part 3 — Overtaking: The key insight is "further in that direction": for positive velocity, further means larger position; for negative velocity, further means smaller position. After an overtake, swap the
duck_in_front pointers. The re-scan loop handles cascading overtakes (an overtaken duck might now need to overtake another). Assumption: restarting the outer loop on any overtake is correct for handling cascades.Part 4 — Flying separate ways:
same_direction() <= 0 covers both the zero-velocity case and the opposite-direction case. Set CurrDuck's front to NULL, DuckInFront gets sad_quack.Part 5 — Dead ducks: The cascade requires a loop: in each pass, kill primary-dead ducks and sad-quack ducks whose front is already dead. Track "dead" by whether the pointer appears in the ducks array. After all deaths, update all surviving ducks' quack based on whether anyone died. The n active ducks constraint means we iterate up to max to check all slots.
A program creates n processes P0, P1, P2, ..., Pn−1. P0 is the initial process. Pi is the parent of Pi+1 (linear chain). n is known at program start. Design a scheme where any process can send a message to any specific process using only anonymous pipes (no shared memory, no named pipes, no sockets).
For example, with 8 processes, P6 should be able to send a message to P3.
Part i (8 marks): Explain how all pipes are set up. Write void create_children(...) and describe which process(es) call it.
Part ii (10 marks): Explain message encoding/decoding. Write:
void send_message(int dest_process, char *msg, int msg_len)void receive_message(char *buffer, int *nread, int buffer_max)
Part iii (4 marks): Write void free_resources(...) called on SIGTERM.
Part iv (4 marks): Describe how your method prevents cycles (P6 must not receive the message it sends).
Part v (4 marks): Describe how the operation converges (P3 receives the message exactly once; other processes handle it at most once).
Messages must include 50 bytes for the content. Example: "Reload Data".
/*
* DESIGN OVERVIEW
* ===============
* Linear chain: P0 → P1 → P2 → ... → Pn-1
* Each adjacent pair Pi and Pi+1 shares TWO pipes:
* up_pipe[i] : Pi+1 writes → Pi reads (child-to-parent direction)
* down_pipe[i] : Pi writes → Pi+1 reads (parent-to-child direction)
*
* For n processes there are (n-1) adjacent pairs → 2*(n-1) pipes total.
*
* MESSAGE PACKET FORMAT (fixed 56-byte struct):
* int src; // originating process index
* int dest; // destination process index
* char msg[50]; // message payload
* int hop_dir; // +1 = travelling toward higher index, -1 = toward lower
*
* ROUTING: Each process Pi, upon receiving a packet:
* - If dest == i: consume it (call user handler).
* - If dest > i: forward down (to Pi+1) if a down pipe exists.
* - If dest < i: forward up (to Pi-1) if an up pipe exists.
* - Never forward back to the pipe it arrived from (prevents cycles).
*
* Each process only ever forwards a packet once (it came from one direction
* → it goes in the other direction). The packet converges in O(n) hops.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#define MAX_N 64
/* Pipe file descriptors — each row is one pipe: [0]=read, [1]=write */
static int down_pipe[MAX_N][2]; /* down_pipe[i]: P_i → P_{i+1} */
static int up_pipe[MAX_N][2]; /* up_pipe[i]: P_{i+1} → P_i */
static int my_index; /* this process's index */
static int n_procs; /* total number of processes */
/* Message packet */
typedef struct {
int src;
int dest;
char msg[50];
} packet_t;
/* ===== Part i: create_children ===== */
/*
* Called by P0 initially; P0 creates P1, which then creates P2, etc.
* Alternatively: P0 calls create_children in a loop.
* Here we use the chain: each Pi forks Pi+1.
*
* All pipes for the entire chain are created by P0 BEFORE any fork,
* so all processes inherit all pipe fds.
* Each process then closes the ends it doesn't need.
*/
void create_children(int n, int my_idx) {
n_procs = n;
my_index = my_idx;
/* P0 creates all pipes before any forking */
if (my_idx == 0) {
for (int i = 0; i < n - 1; i++) {
pipe(down_pipe[i]);
pipe(up_pipe[i]);
}
}
if (my_idx < n - 1) {
/* Fork the next process in the chain */
pid_t pid = fork();
if (pid == 0) {
/* Child becomes P_{my_idx + 1} */
create_children(n, my_idx + 1);
/* Child stays in its event loop — does not return */
}
/* Parent continues to close unused fds and run its event loop */
}
/* Close all pipe ends we don't need:
* Pi only uses:
* down_pipe[i-1] read end (receive from parent)
* up_pipe[i-1] write end (send to parent)
* down_pipe[i] write end (send to child)
* up_pipe[i] read end (receive from child)
* Everything else gets closed.
*/
for (int i = 0; i < n - 1; i++) {
int is_sender = (i == my_idx); /* down: we write to child */
int is_receiver = (i == my_idx - 1); /* down: we read from parent */
int is_up_recv = (i == my_idx); /* up: we read from child */
int is_up_send = (i == my_idx - 1); /* up: we write to parent */
if (!is_receiver) close(down_pipe[i][0]);
if (!is_sender) close(down_pipe[i][1]);
if (!is_up_recv) close(up_pipe[i][0]);
if (!is_up_send) close(up_pipe[i][1]);
}
}
/* ===== Part ii: send_message and receive_message ===== */
/*
* Send a message from this process to dest_process.
* Writes the packet onto the appropriate pipe (up or down from here).
*/
void send_message(int dest_process, char *msg, int msg_len) {
packet_t pkt;
pkt.src = my_index;
pkt.dest = dest_process;
memset(pkt.msg, 0, sizeof(pkt.msg));
int copy_len = msg_len < 50 ? msg_len : 49;
memcpy(pkt.msg, msg, copy_len);
if (dest_process > my_index) {
/* Send toward higher index: write to down_pipe[my_index] write end */
write(down_pipe[my_index][1], &pkt, sizeof(pkt));
} else if (dest_process < my_index) {
/* Send toward lower index: write to up_pipe[my_index-1] write end */
write(up_pipe[my_index - 1][1], &pkt, sizeof(pkt));
}
/* dest_process == my_index: deliver locally (no pipe needed) */
}
/*
* Called when data is available on one of our incoming pipes.
* Reads one packet, handles or forwards it.
* 'buffer' is filled with the message if we are the destination.
* *nread is set to the message length; 0 if we forwarded (not for us).
*/
void receive_message(char *buffer, int *nread, int buffer_max,
int from_fd) {
packet_t pkt;
ssize_t r = read(from_fd, &pkt, sizeof(pkt));
if (r <= 0) { *nread = 0; return; }
if (pkt.dest == my_index) {
/* We are the destination — deliver to caller */
int len = strnlen(pkt.msg, 50);
int copy = len < buffer_max - 1 ? len : buffer_max - 1;
memcpy(buffer, pkt.msg, copy);
buffer[copy] = '\0';
*nread = copy;
} else {
/* Forward toward destination — but never back on the same pipe */
*nread = 0;
if (pkt.dest > my_index && my_index < n_procs - 1) {
write(down_pipe[my_index][1], &pkt, sizeof(pkt));
} else if (pkt.dest < my_index && my_index > 0) {
write(up_pipe[my_index - 1][1], &pkt, sizeof(pkt));
}
}
}
/* ===== Part iii: free_resources (called on SIGTERM) ===== */
void free_resources(void) {
/* Close all open pipe ends held by this process */
for (int i = 0; i < n_procs - 1; i++) {
close(down_pipe[i][0]);
close(down_pipe[i][1]);
close(up_pipe[i][0]);
close(up_pipe[i][1]);
/* (harmless double-close on already-closed fds will just fail silently) */
}
/* Wait for any children if we forked one */
while (waitpid(-1, NULL, WNOHANG) > 0);
}
static void sigterm_handler(int sig) {
(void)sig;
free_resources();
_exit(0);
}
/* Register the handler */
void setup_sigterm(void) {
struct sigaction sa;
sa.sa_handler = sigterm_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGTERM, &sa, NULL);
}
Part ii — Message encoding: A fixed-size
packet_t struct (56 bytes: src int + dest int + 50-byte message) is written atomically into the pipe. The routing rule is simple: if dest > my_index, send down; if dest < my_index, send up. send_message() initiates from the originating process; receive_message() either consumes or forwards based on whether dest == my_index. The from_fd parameter tells the function which pipe the data arrived on so it can never reflect back.Part iii — free_resources: Close all inherited pipe fds and reap any children with
WNOHANG. Registered via sigaction() as the SIGTERM handler.Part iv — No cycles: The linear chain is acyclic by design. A packet always travels monotonically toward its destination (higher or lower index). Each intermediate process checks
dest vs my_index and forwards in exactly one direction. The forwarding direction is always away from the pipe the packet arrived on, so a process can never receive the same packet twice. P6 sends into the chain toward lower indices; P6's own down-pipe (from which packets come from P7) is a different pipe, and P6 never writes back to the pipe it received from.Part v — Convergence: The chain has at most n−1 hops. Each intermediate process forwards the packet exactly once (it reads one packet → writes one packet → done). The destination (P3) receives the packet once and does not forward it. Because every process either consumes or forwards (never both), and there are no cycles, the packet is guaranteed to reach P3 in exactly |6 − 3| = 3 hops and is handled exactly once by each intermediate process.
12 key IPC concepts to memorize
Test your understanding
pipe(fds), which element is used for reading?LO2fork() but does not close the write end, the child's read() on the read end will block forever even after the parent closes its write end.LO2dup2(fds[1], 1) do?LO2read() returns ___ on a pipe when all write-end file descriptors have been closed.LO2int fds[2]; pipe(fds);
if (fork() == 0) {
/* child: intended to read */
char buf[8];
read(fds[0], buf, 4); /* DEADLOCKS */
_exit(0);
}
write(fds[1], "test", 4);
close(fds[1]);
wait(NULL);
mmap() creates a shared memory region that is visible to both parent and child processes after fork(), with no backing file?LO3mmap(MAP_ANONYMOUS|MAP_SHARED) does not need?LO3