Time-slicing applications with SIGCONT and SIGSTOP

Python's subprocess.Popen class and signal library provide powerful ways to communicate with and send signals to a running subprocess.

We'll look at at using the SIGSTOP and SIGCONT signals to pause and resume the execution of a program. As an example, we'll launch another Python process that runs a print_time script. The print_time script takes two arguments, a time delta $n$ and a total time $t$. The script prints its process ID and the Unix time every $n$ seconds and completes after $t$ seconds.

The print_time script looks like

import os
import sys
import time

if __name__ == "__main__":

    sleep_time = float(sys.argv[-2])
    run_time = float(sys.argv[-1])

    end_time = time.time() + run_time

    while time.time() < end_time:
        print(f"{os.getpid()}: {time.time()}")
        time.sleep(sleep_time)

Even though we're running other Python processes in this example, the concept of using signals applies to any program. The same script could've been implemented in any language or even with a mix of bash and Unix programs.

In [1]:
import signal
from subprocess import Popen, PIPE
import time

A few notes about the Popen class which launches a running subprocess. The first argument is a list of strings containing the program to run and its arguments. This line

Popen(["python", "print_time.py", "1" "5"])

Is equivalent to opening your terminal and running

python print_time.py 1 5

The subprocess will begin running as soon as a Popen object is constructed and the Popen.wait() method will wait for the subprocess to terminate.

The stdout argument sets where the process's output will be written. By using subprocess.PIPE, it will be written to the Popen.stdout attribute which can be read like a file. The encoding argument sets stdout to be interpreted as a stream of text rather than a stream of bytes.

In the example below, we'll run the print_time program for five seconds with the Unix time printed at one second intervals. Two seconds in, we'll use the SIGSTOP signal to pause the program's execution and then resume it two seconds later with the SIGCONT signal.

In [2]:
p = Popen(["python", "print_time.py", "1", "5"], stdout=PIPE, encoding="utf-8")
time.sleep(2)
p.send_signal(signal.SIGSTOP)
time.sleep(2)
p.send_signal(signal.SIGCONT)
p.wait();  # Semi-colon is so the output of p.wait() is not printed

Note in the output below that the program runs for two seconds, pauses for a two second gap, then runs for two more seconds.

In [3]:
for line in p.stdout.readlines():
    print(line.strip())
1416: 1584328282.3045828
1416: 1584328283.304825
1416: 1584328286.266311
1416: 1584328287.269263

For a more involved example, we'll launch three processes running print_time and stop all of them immediately. We'll then use SIGSTOP and SIGCONT to allow each of them to run one at a time in round-robin fashion until they've all exited. This functions like a time-slicing mechanism in which each program gets equal execution time and none of the programs run concurrently.

In [4]:
processes = [
    Popen(["python", "print_time.py", "1", "7"], stdout=PIPE, encoding="utf-8")
    for _ in range(3)
]
for process in processes:
    process.send_signal(signal.SIGSTOP)

The Popen.poll() method returns None if a process is still running. We'll use this to check whether any of the processes are still running and, if so, continue to run each process one-by-one.

In [5]:
while any(process.poll() is None for process in processes):
    for process in processes:
        process.send_signal(signal.SIGCONT)
        time.sleep(2)
        process.send_signal(signal.SIGSTOP)

To see how the processes ran in a non-overlapping fashion, we can combine the output of each process into one list and then sort the list by the printed Unix time value. This will reconstruct which process was running at each timestamp.

In [6]:
lines = [
    line.strip()
    for process in processes
    for line in process.stdout.readlines()
]
lines.sort(key=lambda line: float(line.split()[-1]))

In the output below, you can see each process run for two seconds before switching to a different process.

In [7]:
print("\n".join(lines))
1417: 1584328288.389765
1417: 1584328289.389979
1418: 1584328290.388866
1418: 1584328291.3892632
1419: 1584328292.398747
1419: 1584328293.4039948
1417: 1584328294.359136
1417: 1584328295.3628201
1418: 1584328296.361504
1418: 1584328297.3635771
1419: 1584328298.366812
1419: 1584328299.372034

Parting thoughts

The example above does, in a very rough way, what an ARINC 653 operating system does in partitioning applications by time.

A fun exercise would be to write a program that takes a config file containing programs with a time-slice allocation and coordinates their execution. It would be interesting to come up with techniques for precise timing control and measuring how well the OS and Python interpreter are able to switch between the running programs.

My idea for this came while watching this command-line environment lecture which does a great job giving an overview of signals and a number of other things. The entire Missing Semester lecture series is really fantastic and something that I wish I'd taken a college course on. I found the lectures on editors, data wrangling, and version control to be particularly useful.