Software Design Philosophy
In the realm of software design, particularly in operating systems, competing philosophies have shaped the landscape we see today.
Design Goals
Major Design Goals for Software Systems
Completeness, correctness, consistency, and simplicity are key design goals for software systems, but these goals are often in tension with each other. Worse, what they even mean is often in the eye of the beholder.
Aren't there other goals, like efficiency, security, and maintainability?
Of course, but let's keep it simple for now.
Any choice you made is valid here, but let's look at two contrasting prioritizations of these goals.
The “The Right Thing” Approach
Historically, many computer scientists and engineers adhered to a design philosophy that prioritized completeness, correctness, consistency, and then simplicity—in that order. This approach, sometimes called “The Right Thing”, aimed to create comprehensive, flawless systems that covered all possible use cases.
A prime example of this philosophy was the Multics (MULTIplexed Information and Computing Service) operating system, developed in the mid-1960s. Multics aimed to be a comprehensive, secure, and highly consistent system. Its design priorities might be ordered as
- Completeness: Cover all possible use cases and scenarios.
- Correctness: Ensure the system behaves correctly in all situations.
- Consistency: Maintain a uniform interface and behavior across the system.
- Simplicity: Within these constraints, make the interfaces (APIs) as straightforward as possible for the programmer using the operating system. And avoid overcomplicating the implementation.
While noble in intent, this approach often led to complex, difficult-to-implement systems. Multics, despite its technical merits, struggled with delays, cost overruns, and complexity that hindered its widespread adoption.
One thing that makes this prioritization difficult is that at the outset, when a system is being designed, it is very hard to know exactly how people will want to use it in the future. It's very easy to come up with a laundry list of features that seem useful, but some of those features might rarely if ever be used, yet vastly increase the complexity of the system. Avoiding implementation complexity comes last on this priority list, but trying to build the perfect system all at once means that you won't be able to ship anything until the whole thing is done.
Brian Kernighan and Rob Pike, two computer scientists at Bell Labs, watched the debacle of the ever delayed Multics project (which was unlikely to ever run on their small minicomputer because it was too large and complex) and decided to take a different approach…
The “Worse is Better” Approach
Kernighan and Pike prioritized getting something working over getting everything perfect. As a joke, and a pun the name of the trying-to-do-too-much Multics operating system, they called their system “Unix”.
Their approach has come to be known as “Worse is Better” (a name coined by Richard Gabriel in 1989). They prioritized implementation simplicity over other aspects. Specifically,
- Simplicity: The design must be simple, both in implementation and interface. _It is more important for the implementation to be simple than the interface. Simplicity is the most important consideration in a design.
- Correctness: The implementation should be correct in all readily observable aspects. Since we define what correct behavior is, we can avoid specifying behaviors that would be difficult to implement correctly. “It's not an incorrect design, it's a unique design feature!”.
- Consistency: The design shouldn't be overly inconsistent. Consistency can be sacrificed for simplicity in some cases, but it's better to drop those parts of the design that deal with less common circumstances.
- Completeness: The design must cover as many important situations as is practical. All reasonably expected cases should be covered, but completeness can be sacrificed whenever implementation simplicity is jeopardized.
I don't get it. Why is it called “Worse is Better”?
Because it's worse! They're sacrificing everything in favor of implementation simplicity and users don't even see the implementation. It's all about cutting corners.
But it's also better for the OS writers. They can actually get something working and out the door.
And that's actually better for users. Better to have a system today that actually does something than to wait forever for the perfect system that claims to do everything.
I love the idea that doing a crappy job is better!
Well, what's actually interesting is that the quest for implementation simplicity sometimes leads to elegant interfaces as well.
And growth and change by evolution. Once you have something in front of users, you begin to see where the rough edges are and hopefully you can smooth them out.
“Worse is Better” in Unix
Here are a few ways in which the Unix operating system embodies the ”Worse is Better” philosophy:
- Simple file abstraction: Everything is treated as a file, sacrificing some consistency for simplicity.
- Limited but versatile command-line utilities: Each tool does one thing well, favoring simplicity over completeness.
- Minimal kernel design: Only essential services in the kernel, pushing complexity to user space.
These choices made Unix easier to implement, port, and understand. As a result, it spread rapidly and became the foundation for many modern operating systems, including Linux and macOS.
Impact on API Design
The influence of “Worse is Better” extends to API design; particularly evident in the contrast between Unix-like systems and more comprehensive approaches. Let's examine a concrete example: process creation.
Example: Creating a Process
When you create a new process to run a command, there are variety of things you might want to set up for that process:
- What directory should it start in?
- What environment variables should it have?
- What file descriptors should have been opened for it to read from or write to?
- What should its user and group IDs be?
- What should its scheduling priority be?
- What should its signal mask and signal handling behavior be?
- What should its process group be?
This list probably isn't exhaustive, which shows a key issue in “The Right Thing” design philosophy: it's hard to know what to include in the API.
We'll consider the task of creating a new process to run echo hello world and redirect its output to a file named hello.txt.
Unix fork/exec Approach
For Unix, to make the implementation of the kernel simpler as much of the setup for the new process as possible is done in user space. When a program wants to run another program, it divides itself into two almost identical processes, the parent and the child that are running the same code at the same place, via the fork system call. The only difference is that the return value of fork is different in the parent and the child. The parent gets the process ID of the child, and the child gets 0. The child still knows everything the parent knew, and can run whatever code we want to set up the new program. Once it has set things up, it uses the execve system call to replace its memory image with the new program.
Here's the code:
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/wait.h>
extern char **environ;
int main() {
int status;
pid_t pid = fork();
if (pid == 0) { // Child process
char *argv[] = {"echo", "hello", "world", NULL};
int fd = open("hello.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd != -1) {
dup2(fd, STDOUT_FILENO); // Redirect stdout to the file
close(fd);
}
execve("/bin/echo", argv, environ); // Should never return!
_exit(1); // In case exec fails
}
// Parent process
if (pid == -1) {
perror("fork failed");
return 1;
}
printf("Created child process ID: %d\n", pid);
waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
printf("Child exited with status: %d\n", WEXITSTATUS(status));
} else {
printf("Child exited abnormally\n");
}
return 0;
}
Try this code in Online GDB:
This approach is simple and flexible. There isn't one way to open files for yourself and another way to open files for a command you're running. And the fork mechanism is actually general and flexible—although we can fork and then run exec to start a new program, we could also use fork in other ways where we keep running the same code in both the parent and the child.
On the other hand, there are some subtle drawbacks to this approach.
Although the fork/exec approach is simple and flexible, it has some drawbacks. The fork system call is relatively expensive, as it involves copying the entire process memory. Additionally, error handling can be challenging, as the parent process can't distinguish between different types of failures; specifically, it's hard for the child to tell the parent that exec failed. Normally, we could use exit status, but there is no way to distinguish exit status from before the exec and after the exec.
There is a workaround, it just results in more complexity (for the programmer, not the kernel!). You can set up an extra communication channel between parent and child to send information back to the parent. You can try it out here!
Meh. No thanks.
Whether you look at the code or not, the key thing is that Unix does provide enough of a mechanism to do what we need, but some edge cases result in trickier code for the programmer using the OS.
Comprehensive spawn Approach
The alternative approach is to provide a direct “create a new process to run a new program” API, where we provide all the information the new process needs to run. Microsoft Windows adopts this approach with its CreateProcess function, described in the API documentation as follows:
BOOL CreateProcessA(
[in, optional] LPCSTR lpApplicationName,
[in, out, optional] LPSTR lpCommandLine,
[in, optional] LPSECURITY_ATTRIBUTES lpProcessAttributes,
[in, optional] LPSECURITY_ATTRIBUTES lpThreadAttributes,
[in] BOOL bInheritHandles,
[in] DWORD dwCreationFlags,
[in, optional] LPVOID lpEnvironment,
[in, optional] LPCSTR lpCurrentDirectory,
[in] LPSTARTUPINFOA lpStartupInfo,
[out] LPPROCESS_INFORMATION lpProcessInformation
);
Eventually, after many years, as part of a standardization effort (POSIX), Unix adopted a similar approach with the posix_spawn family of functions. But this function is probably implemented in user space as part of the C library, rather than inside the kernel. In other words, it's not a system call, just a set of helper functions—the key system calls remain as fork and exec.
#include <stdio.h>
#include <unistd.h>
#include <spawn.h>
#include <fcntl.h>
#include <sys/wait.h>
extern char **environ;
int main() {
posix_spawn_file_actions_t actions;
posix_spawnattr_t attr;
int success, status;
pid_t pid;
char *argv[] = {"echo", "hello", "world", NULL};
posix_spawn_file_actions_init(&actions);
posix_spawn_file_actions_addopen(&actions, STDOUT_FILENO, "hello.txt",
O_WRONLY | O_CREAT | O_TRUNC, 0644);
posix_spawnattr_init(&attr);
success = posix_spawn(&pid, "/bin/echo", &actions, &attr, argv, environ);
if (success != 0) {
perror("posix_spawn failed");
return 1;
}
posix_spawn_file_actions_destroy(&actions);
posix_spawnattr_destroy(&attr);
printf("Created child process ID: %d\n", pid);
waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
printf("Child exited with status: %d\n", WEXITSTATUS(status));
} else {
printf("Child exited abnormally\n");
}
return 0;
}
You can run this code here
Pros and Cons
Advantages of “Worse is Better”:
- Faster implementation and deployment
- Easier to understand and maintain
- More adaptable to changing requirements
- Encourages modular design
Disadvantages:
- May lead to technical debt over time
- Can result in incomplete or inconsistent solutions
- Might require more user expertise to use effectively
Modern Relevance
The “Worse is Better” philosophy continues to influence modern OS and software design:
- Linux: Follows many Unix principles, prioritizing simplicity and modularity
- Web technologies: RESTful APIs often favor simplicity over completeness
- Microservices architecture: Breaking complex systems into simpler, independent services
Conclusion
As future OS designers and software engineers, it's crucial to understand the trade-offs involved in design philosophies like “Worse is Better”. While simplicity and pragmatism can lead to widespread adoption and easier maintenance, they may also result in limitations or inconsistencies.
As you progress in your studies and career, consider these questions:
- When is it appropriate to prioritize simplicity over completeness?
- How can we balance user needs with implementation constraints?
- In what scenarios might a more complete, consistent design be preferable?
Good design often involves finding the right balance for your specific context.
What about OS/161? Is that a “Worse is Better” system?
OS/161 is modelled after classic Unix-style systems, including Linux and BSD, and very much embodies the philosophy of keeping the implementation simple.
In the next section, we'll look more closely at the coding style of OS/161.
Meh. I don't care about coding style.
Staying consistent with the coding style and design philsophy of a codebase is a vital skill, and an important part of playing well with others.
I don't care about playing well with others.
Not even me…?
Well… maybe you're okay.
(When logged in, completion status appears here.)