Introduction:
I was working on MIT's xv6 OS lab - the pingpong program. The idea was simple: parent sends a byte to child through a pipe, child reads it and prints "received ping", sends a byte back, parent reads it and prints "received pong".
I wrote the code. Ran it manually inside QEMU. Worked perfectly.
Then I ran the automated test. It just hung. No output. No error.Just silence until timeout.
Two hours later I found the problem. It was two lines.
What is a Pipe:
A pipe is a one way communication channel between two processes. In Unix every pipe has two ends:
int p[2];
pipe(p);
// p[0] → read end (you read from here)
// p[1] → write end (you write here)
Parent writes into p[1], Child reads from p[0]. Simple.
For two way communication like pingpong you need two pipes, one for each direction.
int p[2]; // parent → child
int p1[2]; // child → parent
pipe(p);
pipe(p1);
What is a Deadlock:
A deadlock is when two processes are both waiting for each other and neither can move forward.
Think of it like two people standing in a narrow corridor. Each one is waiting for the other to move first. Nobody moves. Forever.
In pipes it happens when:
- Process A is waiting for Process B to finish
- Process B is waiting for Process A to send data
- Neither moves
My Mistake - wait() in the Wrong Place:
This was parent code:
} else {
wait(0); // ← waiting for child to finish FIRST
read(p[1], &c, 1);
write(p1[0], &d, 1);
}
And my child code:
if(id == 0){
read(p[0], &c, 1); // ← waiting for parent to send data
write(p1[1], &c, 1);
}
See the problem?
- Parent calls wait(0) - it stops and waits for child to exit
- Child calls read(p[0]) - it stops and waits for parent to send data
- Parent is waiting for child. Child is waiting for parent.
- Nobody moves. Forever.
This is a deadlock. The process just hangs silently until the test runner kills it with a timeout.
My Second Mistake - Not Closing Pipe Ends:
Every process gets both ends of the pipe after fork(). If you don't close the ends you don't use, read() never sees EOF and hangs waiting for more data even after the writer is done.
// Wrong - child keeps both ends open
if(id == 0){
read(p[0], &c, 1);
}
// Correct - child closes the end it doesn't use
if(id == 0){
close(p[1]); // child doesn't write to p
read(p[0], &c, 1);
}
Rule: every process must close every pipe end it doesn't use. Always.
The Fix:
int main(){
int p[2], p1[2];
pipe(p); // parent writes, child reads
pipe(p1); // child writes, parent readsint id = fork();
if(id == 0){
// child
close(p[1]); // child doesn't write to p
close(p1[0]); // child doesn't read from p1char c;
read(p[0], &c, 1); // receive from parent
printf("%d: received ping\n", getpid());
write(p1[1], &c, 1); // send back to parentclose(p[0]);
close(p1[1]);
exit(0);
} else {
// parent
close(p[0]); // parent doesn't read from p
close(p1[1]); // parent doesn't write to p1char c = 'x';
write(p[1], &c, 1); // send to child
read(p1[0], &c, 1); // receive from child
printf("%d: received pong\n", getpid());close(p[1]);
close(p1[0]);
wait(0); // wait AFTER all pipe I/O is done
exit(0);
}
}
- wait(0) moved to after all pipe communication is done
- Every unused pipe end is closed immediately after fork
The Rules That Stuck With Me:
- Always close pipe ends you don't use → otherwise read() hangs waiting for EOF forever
- wait() always comes after pipe I/O, never before → otherwise parent and child wait for each other forever
- pipe[0] is always read end, pipe[1] is always write end → never mix these up
What I'm Building Next:
This was Lab 1 of MIT 6.1810 - the xv6 RISC-V operating systems course. I'm working through all the labs on Arch Linux with a RISC-V cross compiler and QEMU.
Next up is Lab 2 - system calls. I'll be adding new syscalls directly into the xv6 kernel.
All my code is on GitHub: github.com/Dj-pages/xv6-labs
If you're learning systems programming, MIT xv6 is the best hands-on resource out there. Everything breaks, and that's exactly the point.



Top comments (0)