fork-exec for Python programmers
- fork
- Am I inside the parent or the child?
- Waiting for children to exit
- Memory
- Files
- Inter-process communication
- Exec
fork
fork() can magically make your program do things twice. Don’t believe me? Let’s
run this small program and see for ourselves. Create a file called fork.py
and
save the following code in it.
import sys
import os
import time
sys.stdout.write('Ready to fork? (Press enter to continue) ')
sys.stdout.flush()
sys.stdin.readline()
os.fork()
print('I will print twice')
time.sleep(10)
print('I will also print twice')
Now run this program (make sure you use Python 3), press enter on the “Ready to
fork?” prompt and observe the output. Curiously, the print statements following
the fork()
call do indeed print twice!
What’s happening? To understand this, run the program again, but do not press
enter on the “Ready to fork?” prompt. Now open another terminal window, and
observe the output of the following ps -af
command.
The output would look something like this:
UID PID PPID C STIME TTY TIME CMD
ubuntu 80568 80012 0 23:31 pts/1 00:00:00 python3 fork.py
ubuntu 80571 80547 0 23:31 pts/0 00:00:00 ps -af
Now, press enter, then quickly switch to the other terminal window and and run
ps -af
again (before the 10 second sleep call runs out).
This time you will look like this:
UID PID PPID C STIME TTY TIME CMD
ubuntu 80568 80012 0 23:31 pts/1 00:00:00 python3 fork.py
ubuntu 80579 80568 0 23:33 pts/1 00:00:00 python3 fork.py
ubuntu 80580 80547 0 23:33 pts/0 00:00:00 ps -af
What’s happening here? Are we really running our program twice?
Well yes we are!
To understand this better, let’s first understand what ps
does. ps
just
lists the actively running processes on a system.
And what’s a process? A process is what an operating system creates when you ask it to run a program. A process usually consists of the following things:
- A representation of the program’s executable code in memory (the program in
this case is
python3
). - The processor state, i.e. the contents of all of its registers, including the instruction pointer.
- The call stack. The processor state combined with the call stack will usually tell you what a program is doing at any point of time.
- The heap, which is where all Python objects and data structures are stored (see memory management in Python).
- A list of external resources that may have been allocated to the process, for example, any open files or sockets.
- A process identifier, called the pid (see PID column in the output of the
ps
command)
When you call fork()
, the OS makes an almost identical copy of the current
process, which is called the child process. And the process in which the
fork()
call is made of course becomes the parent of this newly created child
process. In the output of the ps
command observe the values of the PID and
PPID (i.e. parent PID) columns for both the python processes.
The child process, after creation, continues execution from the point at which
fork()
returns. This is why you see duplicate output for both the print
statements in our program.
It is important to note that both the parent and the child process run in
parallel after the fork()
call is made, even on systems with only a single
core, single processor CPU. This is possible due to multitasking.
You might also have noticed that even though the print calls are made twice, the sleep lasts only for 10 seconds and not 20 seconds. This is a direct consequence of the processes running in parallel.
Exercise 1: Inside a running Python process, you can get its pid using the
os.getpid()
function. Modify the print statements above to also include pid
and observe the output.
Exercise 2: Remove the call to time.sleep()
in the program above and
observe the output.
Am I inside the parent or the child?
One problem with the code we’ve written till now is this - after fork()
returns, inside the respective processes, how do you identify which one is the
parent and which one is the child?
One possible solution is to do something like this:
import sys
import os
import time
PARENT_PID = os.getpid()
sys.stdout.write('Ready to fork? (Press enter to continue) ')
sys.stdout.flush()
sys.stdin.readline()
os.fork()
PID_AFTER_FORK = os.getpid()
if PID_AFTER_FORK == PARENT_PID:
print('Inside parent')
else:
print('Inside child')
This should work, but fork provides an easier way: the return value of the
fork()
call is 0
in the child process, and it is set to the pid of the child
in the parent process. That is, this should also work:
import sys
import os
import time
sys.stdout.write('Ready to fork? (Press enter to continue) ')
sys.stdout.flush()
sys.stdin.readline()
PID_AFTER_FORK = os.fork()
if PID_AFTER_FORK > 0:
print('Inside parent')
else:
print('Inside child')
Exercise 3: After the fork, let the parent run to completion but put the
child to sleep. Observe the output of ps -af
. What happens to the chlid’s
parent PID after the parent exits?
Exercise 4: If the child process prints something after the parent process exits, what happens to its output?
Exercise 5: Write a function, launch_child
, that takes a function fn
and
any number of positional and keyword arguments as params. This function should
create a child process, call fn
inside the child process and pass it all the
positional and keyword arguments that were passed to it. After fn
finishes
running, the child process should exit.
To test your launch_child
function, use the following program:
import sys
import os
import time
def launch_child(fn, *args, **kwargs):
# Your implementation of launch_child here
def print_with_pid(*args, **kwargs):
print(os.getpid(), *args, **kwargs)
sys.stdout.write('Ready to fork? (Press enter to continue) ')
sys.stdout.flush()
sys.stdin.readline()
PID_OF_CHILD = launch_child(print_with_pid, 'This prints inside child')
print_with_pid('child pid is', PID_OF_CHILD)
It is important that child process must exit immediately after fn
returns. Which means that the “child pid is …” line MUST NOT print inside the
child process.
Waiting for children to exit
Two things that might be important for a parent process - it might want to wait till a child process completes, and it might want to know whether a child process run successfully or not.
Success or failure of a process is usually indicated by a number which is called its exit status. You can set the exit status of a Python process by calling sys.exit(). Calling this function gracefully terminates your Python process (by ensuring that the finally clauses of the try statement are run), and sets the exit status to the value passed to it.
An exit status can be between 0 and 127. 0 means success, everything else indicates failure.
A parent process can wait for a child process by using the os.waitpid() call. waitpid() takes a child pid as argument alongwith an integer specifying options (usually set to 0). It returns a tuple containing the child pid and exit status indication. The exit status indication is a 16-bit number whose low byte is the signal number that killed the process, and whose high byte is the exit status (if the signal number is zero). For now we will only worry about the exit status.
import sys
import os
import time
sys.stdout.write('Ready to fork? (Press enter to continue) ')
sys.stdout.flush()
sys.stdin.readline()
PID_AFTER_FORK = os.fork()
if PID_AFTER_FORK > 0:
print('Inside parent')
status_encoded = os.waitpid(PID_AFTER_FORK, 0)[1]
print('Inside parent, child exited with code', status_encoded >> 8)
else:
print('Inside child')
time.sleep(2)
sys.exit(127)
Exercise 6: Write a program that creates multiple children, and then waits for them. If any child exits, your program should print the pid of the child that exited. (Hint: check the different ways to specify the pid in the waitpid() call).
Exercise 7: Which process is the parent of the parent Python process? You
can figure this out by using the ps
command.
Exercise 8: How can you check the exit status of the last program that was run by a unix shell (e.g. bash).
Exercise 9: In bash, how do you run a series of commands one after another? The only constraint is that a command should run only if the previous one succeeded. That is, the pipeline should stop on first failure.
Exercise 10: Conversely, how do you run a pipeline of commands which should stop on first success?
Memory
When a child is forked, it gets an almost identical copy of all the memory segments of the parent. However, it is a copy - once forked, any further modifications made to any memory location by the parent process does not reflect in the child, and vice versa. This can be tested with the simple program below.
import sys
import os
import time
X = 100
Y = dict(foo=123)
if os.fork() > 0:
print('Inside parent')
X = 200
Y['foo'] = 456
print('Inside parent, X:', X)
print('Inside parent, Y:', Y)
# wait for child to complete
time.sleep(3)
else:
print('Inside child')
time.sleep(2)
print('Inside child, X:', X)
print('Inside child, Y:', Y)
Files
Memory isolation between processes is fairly easy to grasp. What may not be so easy to understand is how external resources like files work when a fork happens.
Exercise 11: Consider the following program that writes to a file from two processes:
import sys
import os
import time
with open(sys.argv[1], 'w') as f:
if os.fork() > 0:
for i in range(10):
print('writing from parent, chunk:', i)
f.write('aaa\n')
time.sleep(1)
else:
time.sleep(0.5)
for i in range(10):
print('writing from child, chunk:', i)
f.write('bbb\n')
time.sleep(1)
Notice that the same file handle, f
, is open and available inside both the
child and the parent.
Without running it, can you say what this program will do? Keep in mind the fact that you are dealing with buffered I/O.
- Now run it, what do you observe?
- If you move the initial sleep() from the chld to the parent, does it change what gets written to the file?
- If you randomize the sleep timings inside the loop, does it change anything?
- What happens if you flush the output after every write?
Exercise 12: Consider the following program that reads a file linewise from two processes:
import sys
import os
import time
with open(sys.argv[1], 'r') as f:
if os.fork() > 0:
for line in f:
print('reading from parent:', line, end='')
time.sleep(1)
else:
time.sleep(0.5)
for line in f:
print('reading from child:', line, end='')
time.sleep(1)
Again, keeping in mind that you are dealing with buffered I/O, what do you think will happen when this program is run?
- Run this program with a small file as input - perhaps one with fewer than 10 lines, or the file generated by the program in the previous exercise. Explain why the program behaves the way it does.
- Now run it against a large file. A good candidate would be the words file. Again, explain why it behaves the way it does.
Exercise 13: If, instead of reading a file, we instead tried to read the standard input linewise in both the parent and the child, what would happen? Modify the program in the previous exercise to read from stdin instead and explain the behaviour.
Inter-process communication
There are many ways for two processes on the same system to communicate with one another. One way to do it us to use pipes. Pipes are most commonly used in the shell to send ouptut of one command to another. For example,
ps -eaf | grep python | less
The following program uses a pipe to send a message from the child process to the parent:
import sys
import os
import time
read_fd, write_fd = os.pipe()
if os.fork() > 0:
# Close the write fd in parent, since we don't need it here
os.close(write_fd)
print('In parent, waiting for child to write something')
bytes_read = os.read(read_fd, 10)
print('In parent, child wrote:', bytes_read)
os.close(read_fd)
else:
# Close the read fd in child, since we don't need it here
os.close(read_fd)
time.sleep(1)
print('In child, writing something')
os.write(write_fd, b'hello')
os.close(write_fd)
Here’s how this works: the function pipe() returns two file
descriptors - read_fd
and write_fd
. Any data written to write_fd
can be
read on read_fd
.
File descriptors, or “fds” in short, are positive integers that actually power many operations on Unix - including files, sockets and pipes, among others. In fact, the high level file API in Python is actually built on top of file descriptors and the following system calls:
- os.open() opens a file and returns the fd. As the fd is only an integer, the data structures that manage the state of the open file are not available to the user process - these are managed by the operating system itself.
- os.close() cleans up any resources (data structures, etc.) allocated by the operating system for this file.
- os.read() reads from a file. This is a raw unbuffered API that only returns bytes and not strings.
- os.write() writes to a file. Again, this is a raw unbuffered API that only works with bytes.
The high level buffered API provided by Python is built on top of the raw
unbuffered API provided by os.read()
and os.write()
.
When a fork happens, any file descriptors open in the parent process remain open in the child process. This is actually why files opened in a parent processs remain open in a child, as we covered in the previous section.
Now back to pipes - in our case, the child process wants to send a message to
the parent process. So child will write to write_fd
and
the parent reads from read_fd
.
Also, we want to close the fds we don’t need. As the parent process has no use
for write_fd
, it closes this fd immediately after the fork. And as the child
process has no use for read_fd
, it closes this fd as soon as it is created.
After everything is done, the other fd is also closed by both the processes.
Pipes is not the only way for two processes to communicate with each other. The wikipedia page on IPC lists the different approaches available.
Exercise 14: Write a program that launches multiple child processes. Provide a unique writable fd to each child. Whenever any child writes to its writable fd, the parent should print the byte string to console. You may need to use select() for this.
Exercise 15: The function map
takes at least two arguments - another
function and an iterable. It applies the given function to each element in the
iterable, and returns a new iterator with the result.
>>> map(round, [1.4, 3.5, 7.8])
<map object at 0x10df15470>
>>> list(_)
[1, 4, 8]
Write a new function, pmap
(parallelized map) that works similarly to
map
. It should take a function and an iterable as an argument. The difference
is that it should apply the function to each element in a separate child
process. The parent should then assemble the results in a new list and return.
You will need to use the pickle module to serialize object values between
parent and child processes - pickle.dumps()
and pickle.loads()
should be
sufficient.
Exec
exec is another magical piece of functionality in Unix systems. exec is how you run an executable in unix. It causes the program that is currently being run by the calling process to be replaced with a new program, with newly initialized stack, heap, and (initialized and uninitialized) data segments.
In other words, the new executable is loaded into the current process, and will have the same process id as the caller.
Let’s see it in action:
import sys
import os
sys.stdout.write('''Provide program name and args to run like you would in a shell.
Examples:
ls
ls -al
ls -l file1 file2 file3
$ ''')
sys.stdout.flush()
program_and_arguments = sys.stdin.readline().rstrip().split()
program = program_and_arguments[0]
arguments = program_and_arguments[1:]
os.execlp(program, program, *arguments)
sys.stdout.write('I executed a program\n')
sys.stdout.flush()
The exec functionality here is provided by os.execlp()
. Run the program above
and provide program name and args to run - what happens? Did you see the string
“I executed a program” in the output? If no, why not?
The Python interface to exec is provided by the os
module, and is documented
here. You will notice that
exec is not a single function but a family of functions. All these variants
provide the same functionality, differing only in one or more of the following:
- How arguments are passed
- How the executable is looked up i.e. whether to consult
PATH
or not. - Whether the environment is modified or not
The modifiers e, l, p and v appended to the name “exec” tell us what combination of the above functionality is provided by a given variant. The documentation explains this in greater detail.
One thing you might have noticed is that in the invocation of execlp()
above
the program name was given twice. The first one tells execlp which program to
run. The second one actually becomes the first argument (arg0) to the
program. It is recommended that the first argument is always the name of the
program, but this is not enforced.
You can test this by compiling and running the following C program from our
program above, and passing a different arg0 rather than the program name (Python
does some funky stuff with sys.argv[0]
, which is why we are using a C program
as our target here):
#include<stdio.h>
int main(int argc, char **argv) {
printf("No of arguments: %d\n", argc);
for (int i = 0; i < argc; ++i) {
printf("argv[%d]: %s\n", i, argv[i]);
}
}
Since exec replaces the current process with a different program, how do we launch another program yet retain our current process? Simple, fork and then exec. This is the classic Unix-y way of launching a new process, and is in fact what your shell probably does. We will attempt to do the same in the exercise that follows.
Exercise 16: Can you verify that the process running before and after exec
is the same i.e. the pid remains the same before and after the call to exec
?
Exercise 17: Create a function, launch_program(program_name, *args)
that
takes a program name and its arguments, if any. It should run the program in a
separate process, wait for the program to exit, and after it does exit, return
its exit status in the parent process.
Exercise 18: (Optional) Create a function, pipeline(commands)
. commands
should be a list of commands. Each command is of the form [program_name, arg0,
arg1, ...]
i.e. it names a program and its arguments. pipeline()
should
launch each of these commands in parallel, and pipe the output of the first
command to the second, the second command to the third, and so on. That is, the
following,
pipeline(["ls", "-al"], ["grep", "-F', ".py"], ["wc", "-l"])
should work the same as
ls -al | grep -F .py | wc -l
The function should wait for all the commands to exit, and return their exit status codes in an array.
Besides using fork, exec, pipe and wait, you will need one more function to make
this work: dup2. dup2
is
also pretty special - it allows you to duplicate a given fd to a target fd of
your choice. This means you can duplicate one of the pipe fds to stdin or
stdout as required. This setting up of pipes will probably need to be done
between the calls to fork and exec.