“You are clever, O Samana.”

CS644 week 3: Process control

Last week

Link: /week2

Review

Solutions

How a process is run

A program is a file on disk containing executable code. The file, which is typically in the ELF format on Linux, has machine code encoded in binary, as well as static data such as string literals.

The CPU can't run code directly from disk, though, so before a program is run the OS has to load it into memory. A program that is currently executing on a CPU (or waiting to execute) is called a process. A computer often has more processes running than physical CPU cores; the scheduler, a part of the kernel, decides what processes to run and when. A process is allowed to run for a fixed time slice, and then control returns to the OS (or maybe earlier, if the process made a syscall) and the scheduler decides what to do next.

Every process has a parent (the process who spawned it), so the set of running processes on a system is organized as a tree. The process at the root of the tree is a special system process, traditionally called init but nowadays most often systemd. It is not the kernel, but rather the first process that the kernel spawns in userspace.

PIDs and UIDs

Processes are identified by an integer called a PID. A process's PID is unique while it is running, but may eventually be reused. Two syscalls, getpid and getppid, let you discover your own and your parent's PIDs. Unusually for syscalls, they cannot return an error.

# https://docs.python.org/3/library/os.html#os.getpid
os.getpid() -> int
# https://docs.python.org/3/library/os.html#os.getppid
os.getppid() -> int

A process executes as a particular user (and group). In fact, it has both a real UID and an effective UID (and likewise for GIDs):

# https://docs.python.org/3/library/os.html#os.getuid
os.getuid() -> int
# https://docs.python.org/3/library/os.html#os.getgid
os.getgid() -> int

# https://docs.python.org/3/library/os.html#os.geteuid
os.geteuid() -> int
# https://docs.python.org/3/library/os.html#os.getegid
os.getegid() -> int

The real UID is the "actual" user, while the effective user is the user for purposes of access control. Usually they are the same, but not always: if you run sudo sleep 30 & and then ps -eo pid,euser,ruser, you will see that the process's real UID is your UID, but its effective UID is root.

The way this works is that the file /usr/bin/sudo has a special bit set called the set-uid bit. When a program with the set-uid bit is executed, its effective UID is set to the owner of the file rather than the user who launched it. There's an analogous bit for GIDs called the set-gid bit.

The set-uid and set-gid bits are a mechanism for controlled privilege escalation. A set-uid binary lets users assume elevated privileges in a controlled fashion, and for this reason set-uid programs must be written very carefully to avoid granting unintentional privileges. It makes sense that sudo is a set-uid binary as privilege escalation is the essence of what it does. Historically, ping was the classic (and counterintuitive) example of a set-uid binary: despite its innocuous function, it needed privileged access to low-level network interfaces. On our Linux box, though, ping is just a regular binary.

Launching processes

Spawning a child process is accomplished by a pair of syscalls: fork and execve.

os.fork() -> int

fork is the syscall that creates a new child process.

It is a very ancient Unix syscall with a clever design: when it returns, it returns into both processes. In the child process it returns 0, and in the parent process it returns the child's PID. (No real process has PID 0; the init process is assigned PID 1 and all other processes have a higher PID.)

You can imagine that the original process has split and cloned itself: the child process is running the same program, with a complete copy of the variables, call stack, etc. of the original process.

Normally you'll see:

pid = os.fork()
if pid == 0:
    # child
    ...
else:
    # parent
    ...

It's a little hard to wrap your head around.

A child process copies a lot of state from the parent. Notably:

Regardless of if the original process was multi-threaded, the forked process will have only one thread.

Most of the time, you fork because you want to run a different program (e.g., invoke a shell command) – if you actually want to run two copies of the same program, you're probably better off using multithreading, which we'll cover later in the course.

To execute a different program, call execve in the child process:

os.execve(path: str, args: List[str], env: Dict[str, str]) -> NoReturn

execve replaces the current program with the one at path, passing it the arguments in args and the environment variables in env. It does not create a new process – that's what fork did.

A few sharp edges:

If execve succeeds, it will never return – the old program is "switched out" with the new one.

Why such a confusing design?

fork and exec is a very confusing way to launch processes. Why not just launch_process("prog", "arg1", "arg2")?

In fact, there is an API like this, called posix_spawn. But looking at the function signature gives a clue to why fork and exec persist:

# https://docs.python.org/3/library/os.html#os.posix_spawn
os.posix_spawn(path, argv, env, *, file_actions=None, setpgroup=None, resetids=False, setsid=False, setsigmask=(), setsigdef=(), scheduler=None)

That's a lot of arguments! The bottom line is, there is potentially a lot of things you may want to do in a child process before calling exec: opening or closing file descriptors, changing the working directory, setting the signal mask, etc. With fork, you can run whatever code you want before calling exec, and the APIs stay simple. But if you want a single API like posix_spawn, then you have to support all the different ways someone might want to customize their child process.

Waiting for processes

Usually, you'll want a way for the parent to communicate with its child. We'll cover a number of ways to do so next week; today, we'll just look at waitpid, which is how a parent waits for its child to finish.

# https://docs.python.org/3/library/os.html#os.waitpid
os.waitpid(pid: int, options: int) -> Tuple[int, int]

pid can be -1 to wait for any child, or a positive number to wait for a specific child. There are a few more possibilities documented in man 2 waitpid. The main useful value for the options parameter is os.WNOHANG, if you just want to check if a process has exited but don't want to block on waiting for it.

os.waitpid returns a tuple of (pid, waitstatus). The original exit code can be extracted from the wait status with os.waitstatus_to_exitcode.

When a child exits, the kernel sends a SIGCHLD signal to the parent process – we'll talk more about signals later in the course.

If a parent exits before its child, the child process is reassigned to the process with PID 1 as its parent (a system service called init). After a child exits but before its parent calls waitpid, the process is in what is called a zombie state. The OS still has to maintain some metadata about the process, so it's important to "reap" child processes by calling waitpid to free up these resources.

Exiting a process

The last part of a process's lifecycle is exiting:

# https://docs.python.org/3/library/os.html#os._exit
os._exit(n: int) -> NoReturn

Calling _exit terminates the process immediately, and registers a status code that can later be retrieved by the parent process when it calls waitpid. Traditionally, an exit code of 0 indicates success and any non-zero value indicates failure.

You more likely want to use the library function os.exit() (no leading underscore), which calls exit handlers, flushes output buffers, etc. The one time when os._exit is appropriate is after forking (why?).

Exercise: Re-implement subprocess.run

Exercise: subprocess.run is the standard way to invoke a subprocess in Python. Let's implement a basic version ourselves using os.fork and os.execve. It should take in a program to execute and a list of arguments, and return the child process's exit status.

Follow-up

Final project milestone

There is no final project milestone this week. I encourage you instead to catch up on the course material, read over the solutions from the past two weeks, and explore some of the bonus material

Bonus material