Process groups and killing trees

subprocess · Expert · 13 min

Shells, build tools and test runners spawn grandchildren that survive if you only kill the direct child.

Killing a process does not kill its children. If you start sh -c "make && ./server" and kill the shell on timeout, make or server keep running as orphans, and if they hold the stdout pipe open, communicate() keeps waiting for them.

On POSIX, put the child in its own process group and signal the whole group:

  • start_new_session=True runs setsid() in the child: a new session and process group, detached from your terminal.
  • process_group=0 (3.11+) only creates a new process group, keeping the session.

Then os.killpg(p.pid, signal.SIGTERM) (the group id equals the leader's pid) reaches every process in the group, including grandchildren, unless they deliberately moved to another group.

Side effects: a child in a new session no longer receives Ctrl+C from your terminal, so forward signals yourself if you need that behaviour.

On Windows, use creationflags=subprocess.CREATE_NEW_PROCESS_GROUP and send CTRL_BREAK_EVENT, or rely on Job Objects (via third-party libraries) to kill trees.

The third-party psutil library can also walk children(recursive=True) and kill each, a portable fallback.

Example

import os, signal, subprocess, time

# The shell starts a grandchild (sleep) that outlives a plain kill.
cmd = "sleep 30 & echo grandchild $!; wait"
p = subprocess.Popen(["sh", "-c", cmd], stdout=subprocess.PIPE, text=True,
                     start_new_session=True)
gpid = int(p.stdout.readline().split()[1])
print(os.getpgid(gpid) == p.pid)     # True: same group as the shell

os.killpg(p.pid, signal.SIGTERM)     # signal the whole group
p.wait()
t = time.perf_counter()
print(repr(p.stdout.read()))         # '' -> EOF: no process holds the pipe
print(f"{time.perf_counter() - t:.1f}s")  # 0.0s (p.kill() alone: blocks ~30s)
p.stdout.close()

Remember

  • Killing a process leaves its children running
  • start_new_session=True or process_group=0 puts the child in its own group
  • os.killpg(pid, sig) signals the whole tree

Watch out for

  • Using shell=True with timeout and expecting the timeout to stop everything the shell started.

Check yourself

  1. Why doesn't run('sh -c "server"', shell=True, timeout=5) always stop the server?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/library/subprocess.html#subprocess.Popen