Skip to content

CSCI 340 - Lecture: Process Termination, IPC, and Pipes (03/26)

This lecture covers fundamental concepts in operating systems process management, including:

  • Process termination and exit status handling
  • Zombie processes and the process lifecycle
  • Parent-child process relationships and reparenting
  • Inter-process communication (IPC) mechanisms
  • Shared memory and pipes implementation

Every process has a parent, creating a hierarchical tree structure of processes in the system. When a signal such as Ctrl+C is delivered to a process, it affects not only that process but also all other processes in the foreground process group.

For example, when you press Ctrl+C to terminate the bash shell, the signal is delivered to bash and to every child process in the foreground, terminating all of them together.

init (PID 1)
├── bash (shell process)
│ ├── program1
│ └── program2
└── other_services
└── ...

The reason for this hierarchical structure is to handle signal delivery efficiently: when you want to terminate a bash session, you terminate the entire process tree associated with that session.


There are four essential system calls you must know for process management:

  1. fork() - To create a clone of the current process (duplicates the entire process)
  2. execve() - To load and execute a new program on top of an existing process (overlay execution)
  3. waitpid() - To wait for the termination of a child process and retrieve its exit status
  4. exit() - To terminate the current process and return an exit status

These four system calls form the foundation of all process creation, execution, and management in Unix-like operating systems.


When a process calls exit():

  1. The process’s memory space is deallocated (reclaimed by the operating system)
  2. All open file descriptors and other resources are closed
  3. However, the Process Control Block (PCB) is not deleted immediately
  4. The process transitions to the zombie state

The exit() system call takes an exit status as a parameter. This status is:

  • Typically an integer value passed as a parameter to exit()
  • By convention: 0 indicates successful termination
  • Non-zero values indicate errors or abnormal conditions
  • Stored in the PCB (not lost when memory is freed)
  • Exit Status 0: Successful execution - no errors occurred
  • Non-zero Exit Status: An error occurred during execution

This is how bash scripts work: they check the exit status of commands to determine the next action. For example, in a bash script, you might write:

Terminal window
if command_that_might_fail; then
# Command succeeded (exit status 0)
echo "Success!"
else
# Command failed (non-zero exit status)
echo "Error!"
fi
Running → exit() called → Zombie state → waitpid() called → Process cleaned up
PCB still exists
Exit status stored

A zombie process is a process that has terminated but whose Process Control Block (PCB) still exists in the system. Specifically:

  • The process is no longer active or running
  • The PCB remains in the process table
  • The exit status is stored in the PCB
  • The PCB persists until the parent process calls waitpid() or wait() to retrieve the exit status and clean it up

The zombie state exists because the operating system must preserve the exit status information until the parent process retrieves it. The parent needs this status to:

  • Determine how the child process terminated
  • Take appropriate actions based on success or failure
  • Implement synchronization between parent and child processes

The PCB cannot be immediately freed because the operating system guarantees that when the parent calls waitpid(), it will be able to retrieve the child’s exit status.

Zombie processes appear in the process list with a <defunct> label. You can see this when listing processes with commands like ps. The defunct keyword indicates that a process is in the zombie state.

Consider a parent process that creates four child processes:

Parent Process (PID 698) - calls fork() 4 times
├── Child 1 (PID 699) → prints message → calls exit(0) → becomes zombie
├── Child 2 (PID 700) → prints message → calls exit(1) → becomes zombie
├── Child 3 (PID 701) → prints message → calls exit(2) → becomes zombie
└── Child 4 (PID 702) → prints message → calls exit(3) → becomes zombie
[ Parent is now waiting for keyboard input (before calling waitpid) ]

At this point:

  • All 4 children have terminated and are in zombie state
  • Their PCBs are stored in the process table with exit statuses 0, 1, 2, 3
  • All are listed as “defunct” in the process list
  • The parent can call waitpid() to retrieve each child’s exit status and clean them up

This example demonstrates that multiple zombie processes can exist simultaneously until the parent explicitly waits for them.


Every process except the initial process has a parent:

  • The parent process created the child via fork()
  • The parent is responsible for calling wait() or waitpid() on the child
  • The child’s exit status is provided to the parent through the waitpid() system call

When a parent process terminates without waiting for its children, the OS automatically reassigns those children to the init process (PID 1). This process is called reparenting.

Reparenting ensures that:

  • No child process is left without a parent
  • The init process acts as the “parent of last resort”
  • Zombie processes can eventually be cleaned up (init will call waitpid() on them)
  • The system maintains process tree consistency
  • Orphaned processes can complete their execution with a proper parent

The init process (PID 1) is special:

  • Started by the kernel at boot time
  • Serves as the ancestor of all other user processes
  • Periodically calls waitpid() on all of its children
  • Ensures that no zombie processes accumulate indefinitely in the system
  • Handles clean shutdown of all user processes during system shutdown

Without reparenting and init, orphaned child processes would remain in the zombie state indefinitely, wasting kernel resources.


Inter-Process Communication allows separate processes to exchange data and coordinate their actions. The operating system provides several IPC mechanisms:

  1. Shared Memory - Direct memory regions accessible by multiple processes
  2. Pipes - Unidirectional communication channels between related processes
  3. Message Queues - Queued message passing between arbitrary processes
  4. Sockets - Network-based communication
  5. Signals - Asynchronous notifications

This lecture focuses on shared memory and pipes, both of which allow processes running on the same machine to communicate.


Shared memory allows multiple processes to access the same physical memory region. This is one of the fastest IPC mechanisms because it avoids copying data between processes—the data exists in a single location that multiple processes can access directly.

Each process in the system has its own address space. Shared memory creates a region that is mapped into multiple process address spaces:

Process A Process B
┌──────────────────┐ ┌──────────────────┐
│ Address Space A │ │ Address Space B │
├──────────────────┤ ├──────────────────┤
│ Private Memory │ │ Private Memory │
├──────────────────┤ ├──────────────────┤
│ │ │ │
│ Shared Memory │◄──────►│ Shared Memory │
│ (1 KB) │ │ (1 KB) │
│ │ │ │
├──────────────────┤ ├──────────────────┤
│ More Private │ │ More Private │
└──────────────────┘ └──────────────────┘
│ ▲
└────────────────────────────┘
Both point to same
physical memory region

The typical workflow for shared memory:

  1. Create: One process creates the shared memory segment with a specified size and a unique identifier (e.g., 1024 bytes with ID 100)
  2. Attach: Each process that wants to use it calls attach, which maps the shared memory into its address space
  3. Use: Processes read from and write to the shared memory
  4. Detach: When done, processes detach the shared memory
  5. Delete: When no longer needed, someone must explicitly delete the shared memory segment

A common pattern is a producer-consumer model with shared memory:

  • Writer Process: Creates or attaches to shared memory, writes messages into it
  • Reader Process(es): Attach to the same shared memory, read messages from it

The message format might include:

  • A “ready” flag (e.g., 4 bytes) to indicate if a message is available
  • A message buffer (e.g., 1020 bytes) containing the actual data

Reader’s loop:

while (ready_flag is 0):
sleep for 50 milliseconds // busy-wait with timeout
// Message is ready, process it
read_message()
set_ready_flag(0) // Clear for next message

Writer’s operation:

wait_for_user_input()
write_message_to_shared_memory()
set_ready_flag(1) // Signal to reader
  • Fast: No copying needed—processes access the same physical memory
  • Synchronization Required: Multiple processes accessing the same data need coordination (otherwise data corruption can occur)
  • Persistence: Data persists until explicitly deleted by administrator or cleanup process
  • System Calls: Primary calls are shmget() (create), shmat() (attach), shmdt() (detach), shmctl() (control)
  • Same Machine Only: Both processes must be running on the same operating system

Shared memory enables the producer-consumer pattern:

  • Producers write data into the shared memory
  • Consumers read data from the shared memory
  • Can scale to N producers and M consumers

A pipe is a unidirectional communication mechanism that allows one process to send data to another process through a byte stream. Pipes are fundamental to Unix process communication.

Pipes characteristics:

  • Unidirectional: Data flows in one direction only
  • Sequential/FIFO: Data is read in the order it was written (First In, First Out)
  • Buffered: The kernel maintains a buffer for pipe data
  • Related Processes: Typically used between parent and child processes
Writer Process Reader Process
↓ ↑
│ │
└──────────────[PIPE]◄────────┘
kernel buffer
(typically 4KB-64KB)

Data enters at the write end and exits at the read end in FIFO order.

The pipe() system call creates a pipe and returns two file descriptors in an array:

int fd[2];
pipe(fd);

After calling pipe():

  • fd[0] = file descriptor for the read end of the pipe
  • fd[1] = file descriptor for the write end of the pipe

The typical pattern for inter-process communication via pipes:

  1. Create the pipe: Parent calls pipe() to create the pipe
  2. Fork: Parent calls fork() to create a child process
  3. Inherit: Child inherits copies of both file descriptors (pointing to the same pipe)
  4. Close unnecessary ends:
    • Parent closes the read end (close(fd[0])) if it only writes
    • Child closes the write end (close(fd[1])) if it only reads
  5. Communicate: Parent and child use write() and read() with the remaining file descriptors
  6. Close when done: Each process closes its file descriptors when finished
  7. Wait: Parent calls waitpid() to clean up the child process

Important: Pipes are byte streams with no message boundaries. This means:

  • If the writer sends “hello” and then “world”, the receiver sees “helloworld”
  • The receiver has no way to know where one message ends and another begins
  • The receiver must implement its own message framing if discrete messages are needed

For example:

Writer sends: "hello" + "world"
[Pipe buffer]
Reader reads: "helloworld" (no boundaries preserved)

Proper pipe closure is essential for correct process synchronization:

  • Closing write end: When a writer closes its write file descriptor, any reader on the pipe sees EOF (end-of-file) when it has read all available data
  • Closing read end: When a reader closes its read file descriptor, any writer on the pipe receives a SIGPIPE signal (broken pipe)
  • Automatic cleanup: The kernel automatically removes the pipe when the last file descriptor referencing it is closed
  • Buffering: The kernel maintains a buffer for pipe data (typically 4KB to 64KB depending on the OS)
  • Blocking I/O: If a writer writes more data than the buffer can hold, the write() call blocks until the reader reads some data
  • Read Blocking: If a reader tries to read from an empty pipe, the read() call blocks until data is available
  • Atomicity: Writes smaller than the pipe buffer are guaranteed to be atomic (not interleaved with writes from other processes)
  • Related Processes Only: Pipes can only be used between related processes (those with a parent-child relationship via fork)

Pipes are simpler and more efficient than message queues, but with trade-offs:

CharacteristicPipesMessage Queues
Message BoundariesNo (byte stream)Yes (discrete messages)
DirectionUnidirectionalBidirectional
Related ProcessesYes (parent-child via fork)No (any processes)
Max Message SizePipe buffer (~4-64KB)OS-defined (often 8KB)
CleanupAutomaticExplicit required

pipe(fd); // Create pipe, fd[0]=read, fd[1]=write
fork(); // Create child
// Parent process:
close(fd[0]); // Close read end (not needed)
write(fd[1], ...); // Write data to child
// Child process:
close(fd[1]); // Close write end (not needed)
read(fd[0], ...); // Read data from parent
pipe(fd); // Create pipe
fork(); // Create child
// Parent process:
close(fd[1]); // Close write end (not needed)
read(fd[0], ...); // Read data from child
// Child process:
close(fd[0]); // Close read end (not needed)
write(fd[1], ...); // Write data to parent

Pattern 3: Two-Way Communication (Two Pipes Needed)

Section titled “Pattern 3: Two-Way Communication (Two Pipes Needed)”
pipe(pipe1); // Parent writes, child reads
pipe(pipe2); // Child writes, parent reads
fork();
// Parent:
close(pipe1[0]); close(pipe2[1]);
write(pipe1[1], ...); // Send to child
read(pipe2[0], ...); // Receive from child
// Child:
close(pipe1[1]); close(pipe2[0]);
read(pipe1[0], ...); // Receive from parent
write(pipe2[1], ...); // Send to parent

When you run a shell command like:

Terminal window
cat file.txt | grep "pattern"

The shell internally does:

  1. Creates a pipe
  2. Forks twice: one process for cat, one for grep
  3. Connects the write end of the pipe to cat’s stdout
  4. Connects the read end of the pipe to grep’s stdin
  5. Waits for both processes to complete

Process Creation → Running → exit() → Zombie → waitpid() → Cleaned Up
Exit status stored
  1. Exit Status: Essential for parent-child synchronization and error reporting
  2. Parent Responsibility: Parents must call waitpid() on their children to avoid leaving zombies
  3. Reparenting: Orphaned children are automatically reassigned to init for cleanup
  4. Zombie Prevention: A properly written program always waits for its children

Shared Memory:

  • Use when you need fast access to shared data
  • Requires explicit synchronization
  • For processes on the same machine
  • Data persists until explicitly deleted

Pipes:

  • Use for unidirectional communication between parent and child
  • Simple and automatic cleanup
  • Suitable for producer-consumer patterns
  • Message boundaries must be handled by application
  • Blocking I/O for synchronization
  • fork(): Create a child process
  • execve(): Load a new program
  • exit(): Terminate with exit status
  • waitpid(): Wait for child and retrieve exit status
  • pipe(): Create a unidirectional communication channel
  • shmget(): Create/access shared memory
  • shmat(): Attach shared memory
  • shmdt(): Detach shared memory