CS 134

Unix Design Philosophy: Simplicity in Action

  • BlueRobot speaking

    Let's dive deeper into the Unix design philosophy, which embodies many of the principles we've discussed so far. This philosophy has shaped the systems you'll be working with in this course, including OS/161.

Key Principles of Unix Philosophy

Keeping things simple is a core tenet of Unix design. We already saw it show up in the system call interfaces provided by the kernel—for example, fork() doesn't take any arguments at all, yet it can be used in a variety of ways.

Simplicity also shows up in other ways. It's quite common for Unix-like systems to have some fixed limits on, say, the number of open files a process can have, or the number of processes that can be running at once. These limits are often set quite low, and they can be changed by recompiling the kernel (or in more modern systems, changing a kernel parameter while the system is running). This approach allows the kernel to use simple data structures, such as fixed-size arrays, rather than more complex data structures like linked lists or trees. It might seem like a waste of space to have a table where most of the entries are empty, but it's a lot easier to understand and work with than a more complex data structure.

  • Duck speaking

    Wait, I always wondered why file descriptors were just small integers, and now I think I get it. They're just array indices!

  • PinkRobot speaking

    Right, they're actually just indexes into a table of open files. The kernel can use a simple array for each process to keep track of open files, which is much easier to manage than a more complex data structure.

  • Rabbit speaking

    When you open a file, the kernel just performs a linear search through this table to find the first available slot.

  • Duck speaking

    What? That's so inefficient!

  • Goat speaking

    Meh. Who cares?

  • PinkRobot speaking

    Well, Goat is kinda right here. The table is usually pretty small so the linear search is fast enough. Good enough is good enough!

Everything Is a File

Unix also adopted the idea that “everything is a file.” This simplification means that the same kind of code can read from a file in the filesystem, the user's keyboard, a pipe connected to the output of another program, a network socket, or even information about the operating system itself. The contents of a file are just an unstructured stream of bytes (often just human-readable text, although the kernel doesn't care one way or the other about what the bytes mean). This approach contrasted with many other systems at the time, which had different interfaces for different kinds of data, and supported a wide variety of structured file formats that the operating system itself natively understood.

graph LR A[File-Like] --> F[Regular Files] A --> B[Devices] --> K[Keyboard] B --> M[Mouse] A --> C[Pipes] A --> D[Network Sockets] A --> E[Processes]

This abstraction simplifies the interface between the kernel and user space:

  • open(filename, flags, mode) can be used to open a file, device, or socket.
    • /dev/xxx is a common convention for device files, such as
      • /dev/ttyXXX for terminal devices
      • /dev/sdX for disk devices
      • /dev/null for a special device that discards all data written to it
      • /dev/zero for a special device that produces an infinite stream of zeros
      • /dev/random and /dev/urandom for random data
      • /dev/fd/0, /dev/fd/1, and /dev/fd/2 provide a way to specify the program's already-open file descriptors as if they were new files (0 is standard input, 1 is standard output, and 2 is standard error)
    • /proc is a common convention for process information, such as
      • /proc/self/... for information about the current process
      • /proc/NNNN/... for information about a specific process, where NNNN is the process ID
  • read(fd, buffer, count) and write(fd, buffer, count) can be used to read from and write to files, devices, or sockets.
  • close(fd) can be used to close a file, device, or socket.
  • fcntl(fd, cmd, arg) can be used to perform various operations on a file descriptor, such as duplicating it or changing its properties.
  • ioctl(fd, request, arg) can be used to perform device-specific operations on a file descriptor.

Using the same interface for different types of data makes it easier to write programs that work with a wide variety of data sources and sinks.

  • Duck speaking

    Wait, how can a process be a file? That doesn't make sense!

  • PinkRobot speaking

    It's more accurate to say that processes are represented by files. In systems like Linux, each process has a directory in /proc that contains files representing its state and properties.

  • Goat speaking

    Sounds complicated. Why bother?

  • PinkRobot speaking

    This abstraction allows for a unified interface. You can use the same system calls (like read and write) to interact with files, devices, and even inter-process communication.

  • Hedgehog speaking

    So, it's about making things simpler for programmers?

  • PinkRobot speaking

    Exactly! It's a great example of the Unix philosophy in action.

Let's take a look at an example—we'll look at /proc/$$/status. The shell will expand $$ to its own process ID, so you can run this command to see information about the shell process itself:

cat /proc/self/status
Name:   zsh
Umask:  0076
State:  S (sleeping)
Tgid:   2791
Ngid:   0
Pid:    2791
PPid:   2790
TracerPid:  0
Uid:    24641   24641   24641   24641
Gid:    42424   42424   42424   42424
FDSize: 64
Groups: 201 523 6050 13400 13456 14750 20000 20069 41607 41713 41818 41910 42102 42107 42208 42424 60114 60123 60166 60179 60209 60216
NStgid: 2791
NSpid:  2791
NSpgid: 2791
NSsid:  2791
Kthread:    0
VmPeak:    21512 kB
VmSize:    20252 kB
VmLck:         0 kB
VmPin:         0 kB
VmHWM:      8064 kB
VmRSS:      6096 kB
RssAnon:        2256 kB
RssFile:        3840 kB
RssShmem:          0 kB
VmData:     3180 kB
VmStk:       292 kB
VmExe:       604 kB
VmLib:      2408 kB
VmPTE:        68 kB
VmSwap:        0 kB
HugetlbPages:          0 kB
CoreDumping:    0
THP_enabled:    1
untag_mask: 0xffffffffffffffff
Threads:    1
SigQ:   0/1031282
SigPnd: 0000000000000000
ShdPnd: 0000000000000000
SigBlk: 0000000000000002
SigIgn: 0000000000384000
SigCgt: 0000000008013003
CapInh: 0000000000000000
CapPrm: 0000000000000000
CapEff: 0000000000000000
CapBnd: 000001ffffffffff
CapAmb: 0000000000000000
NoNewPrivs: 0
Seccomp:    0
Seccomp_filters:    0
Speculation_Store_Bypass:   thread vulnerable
SpeculationIndirectBranch:  conditional enabled
Cpus_allowed:   ffff,ffffffff
Cpus_allowed_list:  0-47
Mems_allowed:   00000000,00000003
Mems_allowed_list:  0-1
voluntary_ctxt_switches:    1785
nonvoluntary_ctxt_switches: 23

We can think about how this output is made inside the kernel, it's probably the moral equivalent of a bunch of print statements that dump out the contents of a struct that represents the process. That's pretty easy to code, and if we decide we want to add more information to this file, it's pretty easy to do that, too. This approach does make it a bit more annoying for code that wants to use this data, as it needs to parse the text output, but that's the trade-off we make for simplicity in the kernel.

  • Hedgehog speaking

    Do I need to understand all of this output?

  • PinkRobot speaking

    Not at all! The point is that the kernel provides a simple interface to access information about processes.

  • Rabbit speaking

    FWIW, not all Unix-like systems have /proc. Some use different mechanisms to expose process information. macOS, for example, uses the sysctl interface as well as some low-level “Mach” system calls.

  • Cat speaking

    So not everything is a file?

  • PinkRobot speaking

    We might have overreached a bit in saying everything is a file. But many things can be conveniently represented as data sources or sinks, and accessed through a file-like interface.

  • Rabbit speaking

    The experimental operating system Plan 9 took this idea even further, being even more zealous in its application of the “everything is a file” philosophy.

The Shell: A Simple, Powerful Interface

At the time Unix was developed, most operating systems had some kind of interface to start programs and control the computer. Often this interface was a quite complex program that was a central part of the operating system. Unix took a more minimalist approach. The initial goal for the shell was for it to be a simple user-mode program that parsed lines typed by the user and called separate external programs to do the work.

In the Unix shell, when you type

ls -l /tmp

what happens is that the shell forks a new process, and then the child process uses execve to load the /bin/ls program into memory and run it with the arguments ls, -l, and /tmp (by convention, the first argument is the name of the program itself). The shell then waits for the ls program to finish, and then prints the prompt again.

It wasn't much more complex to add I/O redirection, so you could write

ls -l /tmp > /tmp/ls-output

to redirect the output of the ls program to a file. Or you could write

ls -l /tmp | grep foo

to connect the output of the ls program to the input of the grep program (via a communication channel called a pipe).

The shell provides another example of the “Worse is Better” philosophy. It began very simply, and yet even in its simplest form it was quite powerful. But over time, “the shell” has evolved into new types that add more features and capabilities (e.g., control structures, variables, and functions) that make it even more powerful.

  • Duck speaking

    So a shell is just a regular user program? Could I write my own?

  • PinkRobot speaking

    Absolutely! The shell is just a program that reads lines from the user and runs other programs. You could write your own shell in any language you like! Code it in Python to throw it together quickly, or old school C for that retro-cool vibe.

  • Rabbit speaking

    Today, there are several popular shells; Linux defaults to a shell called bash, whereas macOS defaults to a shell called zsh (which is also what our CS 134 Linux server defaults to). Other users sometimes choose to use other shells like fish or rc. Wikipedia has a whole list.

  • Duck speaking

    I'm going to write my own amazing new shell in Rust!

  • Goat speaking

    Meh. You do you.

Simple Commands, Powerful Combinations

Keeping it simple isn't just about the kernel. Unix tools and utilities also embody this philosophy. The Unix command line is a powerful environment for text processing, thanks to a few key principles:

  • Write programs that do one thing and do it well.
  • Write programs that can work together.
  • Write programs to handle text streams, because that is a universal interface.

Let's look at an example:

cat /var/log/syslog | grep "error" | sort | uniq -c

This command

  1. Outputs the contents of a log file (cat)
  2. Filters for lines containing the string error (grep)
  3. Sorts the results (sort)
  4. Counts unique occurrences (uniq -c)

Each command is simple, but together they form a powerful data-processing pipeline.

  • Goat speaking

    Ugh, command line. Why can't we just use a GUI?

  • PinkRobot speaking

    GUIs are great for many tasks, but command-line tools offer unparalleled flexibility and composability.

  • Duck speaking

    What do you mean by composability?

  • PinkRobot speaking

    It's the ability to combine simple tools to solve complex problems. Like building with LEGO bricks!

  • Dog speaking

    That sounds fun! Can we try some examples?

  • PinkRobot speaking

    Absolutely! Let's explore a few more in the next section.

Text Streams: A Universal Interface

Unix tools often use plain text as their input and output format. This approach has several advantages:

  1. Easy to read and write for both humans and programs
  2. Language and tool agnostic
  3. Simple to process and transform

For example, consider the /etc/passwd file, which stores user account information:

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin

This format is

  • Human-readable
  • Easy to parse with tools like cut or awk
  • Simple to generate from scripts or programs

Can you think of a task that might be difficult to accomplish using only text-based interfaces? Which of these might not be a good fit?

Despite the limitations, can you envision a command-line tool that might actually be helpful for a domain you said would be difficult? Remember the Unix philosophy: We want a tool that is simple, composable, and just doing one thing well.

Conclusion

The Unix design philosophy, with its emphasis on simplicity, modularity, and text-based interfaces, has proven to be remarkably enduring. As you work with OS/161 and other Unix-like systems in this course, keep these principles in mind:

  1. Simplicity in design and implementation
  2. Modularity and composability
  3. Plain text as a universal interface

These ideas will not only help you understand existing systems but also guide you in designing and implementing your own software solutions.

  • Duck speaking

    This all sounds great, but are there any downsides to this approach?

  • PinkRobot speaking

    Good question! While the Unix philosophy has many strengths, it's not without limitations. For example, dealing with binary data or complex structured information can be less efficient.

  • Goat speaking

    So it's not always the best approach?

  • PinkRobot speaking

    Right. Like any design philosophy, it's about trade-offs. The key is understanding when to apply these principles and when a different approach might be more appropriate.

  • Hedgehog speaking

    I'm excited to try this out in our projects!

  • PinkRobot speaking

    That's the spirit! Remember, the best way to understand these principles is to apply them in practice.

(When logged in, completion status appears here.)