Graceful handling of SIGINT when using Python's multiprocessing.Process
Today I learned something about Python's (at least CPython's) multiprocessing and signal handling, and I would like to share it here. Basically my situation was such (when developing pydoc that powers this blog):
- I would like to serve the blog with an HTTP server while auto-regenerating for changes;
- The auto-regeneration is handled in the main process with a while loop, whereas the HTTP server (requiring little human intervention) is put in a
multiprocessing.Processand launched withhttp.server.HTTPServer.serve_forever(); - Upon sending
SIGINT, both processes need to clean up and quit; in particular, the server needs to exit itsserve_forever()loop (which can be done viashutdown(), but how to invoke the method is a problem, sinceserve_forever()blocks); - Handling of
SIGINTmust be graceful in the main process — there might be an ongoing build that must not be interrupted until finishing.
Given this context, I learned the following two critical concepts (at least true in the current version of CPython) through trial and error:
- A user-triggered
SIGINTis sent to both processes — the main process and themultiprocessing.Processinstance; - Except for the defined interfaces, a
multiprocessing.Processinstance is almost completely separated from the main process, sharing as little resources as possible; by "defined interfaces" I mean the defined attributes and methods of aProcessinstance, as well as defined communication channels likemultiprocessing.Pipeormultiprocessing.Queue. And to expand on resource sharing: yes, the two processes have their own copies of global variables, so using global variables as state registers is a no-go.
Both concepts can be used to one's benefit or detriment. Below is how I solved my problem, using the two concepts. Observe that without a custom handler, Python translates a SIGINT to a KeyboardInterrupt exception; therefore, I use the default KeyboardInterrupt to interrupt the HTTP server in its own process (through handling the exception and calling shutdown()), but instead install a custom SIGINT handler in the main process that translates SIGINT to setting a sigint_raised flag that can be picked up by the while loop once the current build (if any) is finished. The proof of concept script is as follows (the production code is here):
#!/usr/bin/env python3
import http.server
import multiprocessing
import signal
import sys
import time
class HTTPServerProcess(multiprocessing.Process):
def run(self):
httpd = http.server.HTTPServer(
("", 8000), http.server.SimpleHTTPRequestHandler)
try:
httpd.serve_forever()
except KeyboardInterrupt:
httpd.shutdown()
def do_things():
for i in range(10):
sys.stderr.write(".")
sys.stderr.flush()
time.sleep(1)
sys.stderr.write("\n")
def main():
server_process = HTTPServerProcess()
server_process.start()
# define and install custom SIGINT handler
sigint_raised = False
def sigint_mitigator(signum, frame):
nonlocal sigint_raised
sigint_raised = True
signal.signal(signal.SIGINT, sigint_mitigator)
while not sigint_raised:
do_things()
server_process.join()
if __name__ == "__main__":
main()Beware that with this solution, if there are external programs or OS level operations happening in the main process, then the operation at the time of SIGINT will still be interrupted1 (for example, in the script above, the time.sleep(1) at the exact point of SIGINT is still interrupted, but otherwise do_things is carried on to its completion). I'm not sure how to explain this — maybe the handler isn't capturing the signal fast enough?2 Anyway, one single early interruption is at least more acceptable than a completely corrupted build3, and certainly more graceful.
CPython's
multiprocessingis written in C, so the behavior might depend on the OS. I'm talking about OS X here. I haven't inspected and won't inspect the C source code.↩︎That's awfully naive and layman-sounding, I know, but I am almost a layman when it comes to system-level programming.↩︎
That's assuming your build isn't interdependent in which any single failure corrupts everything. In that case, what can we do? I honestly see no way of injecting signal handling in
subprocess.Popen.↩︎