Building a Unix Shell Part 2: Background Processes and the C++ Migration
In Part 1, we built a basic shell capable of executing commands using fork() and exec(). It worked, but it had a major limitation: it was strictly synchronous. You ran a command, the shell froze, and you waited.
For Part 2, the goal was to implement Background Processes. I wanted to be able to run sleep 20 & echo hello, where "hello" prints immediately while the sleep command runs silently in the background.
However, before diving into the process management logic, I had to address some technical debt.
The Migration: C to C++
In the first iteration, I wrote everything in C. While C is the language of Unix, manual string manipulation with char**, malloc, and strtok became a nightmare. I found myself fighting memory leaks rather than focusing on shell logic.
So, I refactored the entire project to use the C++ Standard Library. This allowed me to replace complex pointer arithmetic with std::vector and std::string.
Tokenization Refactor
The new tokenizer became trivial using std::stringstream:
std::vector<std::string> tokenize(const std::string &line){
std::vector<std::string> tokens;
std::string token;
std::stringstream ss(line);
while (ss >> token)
{
tokens.push_back(token);
}
return tokens;
}
However, Unix system calls like execvp still demand old-school char* arrays (terminated by a nullptr). To bridge the gap between my nice C++ strings and the C API, I wrote a helper to convert the input just before execution:
std::vector<char *> convert_input(const std::vector<std::string> &args){
std::vector<char *> c_args;
c_args.reserve(args.size() + 1);
for (const auto &arg : args)
{
c_args.push_back(const_cast<char *>(arg.c_str()));
}
c_args.push_back(nullptr); // The syscall expects a null terminatorreturn c_args;
}
Implementing Background Processes
With the plumbing fixed, I moved to the core feature: the & operator.
I introduced a struct to track the type of process we are running. This helps the parser decide if the shell needs to wait for the process or let it run free.
enum ProcessType {
BACKGROUND,
SERIAL_FOREGROUND,
};
struct Command {
ProcessType process_type;
std::vector<std::string> tokens;
};
The Execution Logic
The core logic of a shell lives in the fork() branch.
- Child: Executes the command.
- Parent: Decides whether to wait.
If it's a Foreground process, we use waitpid to pause the shell until the child is done.
If it's a Background process, we don't wait. We just print the PID and return control to the user immediately.
// Inside execute_command...if (pid == 0) {
// Child processif (execvp(tokens[0], tokens.data()) != 0) {
exit(1);
}
} else {
// Parent processif (command.process_type != ProcessType::BACKGROUND) {
int status;
waitpid(pid, &status, 0); // Blocking wait
} else {
// Non-blocking: just announce the PIDstd::cout << "[Background PID: " << pid << "]" << std::endl;
}
}
The Problem: Zombie Processes and Signals
If we don't wait for a background process, who cleans it up when it finishes? If we do nothing, the child becomes a "Zombie" process—taking up an entry in the process table even though it's dead.
We need to "reap" these processes. My first thought was to use waitpid(-1, &status, WNOHANG) inside the main loop. This checks if any child has finished without blocking.
But there is a flaw: Latency. The shell would only clean up zombies when the user hit "Enter" to trigger the next loop iteration. We need the OS to notify us the moment a child dies.
Enter SIGCHLD
When a child process terminates, the OS sends the SIGCHLD signal to the parent. We can catch this signal to clean up processes asynchronously.
However, writing safe signal handlers is tricky. You generally shouldn't perform I/O (like std::cout) inside a signal handler because it is not "async-signal-safe." If the program was in the middle of a malloc or printf when the signal hit, calling it again inside the handler could corrupt memory.
The Solution: The Async Flag Pattern
Instead of doing heavy work in the handler, I used a volatile sig_atomic_t flag. The handler simply reaps the process via waitpid and sets a flag. The main loop sees the flag and handles the printing safely.
Here is the signal handler:
volatile sig_atomic_t finished_child_detected = 0;
void on_complete_signal_handler(int sig){
int saved_errno = errno;
// Reap all available children (handling multiple simultaneous exits)while (waitpid(-1, NULL, WNOHANG) > 0);
finished_child_detected = 1;
errno = saved_errno;
}
Handling Race Conditions (Signal Masking)
There is a subtle race condition: What if the child finishes immediately after fork() returns, but before we record its PID or set up our logic? Or what if a signal interrupts our waitpid for a foreground process?
To solve this, we use sigprocmask to block signals during critical sections and unblock them when it's safe.
- Block SIGCHLD before forking.
- Unblock SIGCHLD inside the child (so it behaves normally).
- Unblock SIGCHLD in the parent only after we have decided how to handle the process.
Here is the robust execution flow:
sigset_t mask, old_mask;
sigemptyset(&mask);
sigaddset(&mask, SIGCHLD);
// 1. Block SIGCHLD to prevent race conditions
sigprocmask(SIG_BLOCK, &mask, &old_mask);
int pid = fork();
if (pid == 0) {
// 2. Unblock in child
sigprocmask(SIG_UNBLOCK, &old_mask, NULL);
// ... execvp ...
} else {
if (command.process_type != ProcessType::BACKGROUND) {
// Foreground: Wait for specific PIDint status;
waitpid(pid, &status, 0);
// 3. Restore signals after wait is done
sigprocmask(SIG_SETMASK, &old_mask, NULL);
} else {
// Background: Announce and continuestd::cout << "[Background PID: " << pid << "]" << std::endl;
sigprocmask(SIG_SETMASK, &old_mask, NULL);
}
}
The Main Loop
Finally, the main loop needs to know when a signal interrupted input. std::getline will fail if a signal arrives, setting errnoto EINTR. We catch this to print our notification.
if (!std::getline(std::cin, line))
{
if (errno == EINTR && finished_child_detected)
{
std::cout << "\n Background process finished \n";
std::cin.clear();
clearerr(stdin);
finished_child_detected = 0;
continue;
}
break;
}
Conclusion
By moving to C++, I cleared up the memory management noise and could focus on the operating system concepts. Implementing background processes requires a careful dance of fork, waitpid, and signals. The key takeaway? Never trust that your code runs sequentially when signals are involved. Block them, handle them atomically, and reap your zombies!
The full source code for this shell is available Github.