Loading...
Loading...
Subprocess and concurrency discipline for Python: how to run external programs safely, and how to pick the right concurrency model instead of the habitual one. Use when spawning processes with subprocess or asyncio subprocesses, writing threading or ThreadPoolExecutor code, using multiprocessing or ProcessPoolExecutor, sizing worker pools, bounding fan-out, or diagnosing deadlocks, hangs, orphaned children, and zombie processes. Covers argv vs shell, timeouts that kill the whole process tree, pipe deadlocks, start methods (spawn/forkserver/fork), pickling limits, GIL and free-threading, and cancellation. For event-loop and coroutine rules (TaskGroup, blocking calls, cancellation), use python-async-best-practices instead.
npx skill4agent add agentmemoir/skills python-subprocess-and-concurrencyfork| Topic | Owner |
|---|---|
| this skill ( |
| this skill ( |
| this skill ( |
| |
| The offload decision itself: GIL vs threads vs process pool, default executor sizing, thread non-interruptibility | |
| General Python style, typing, error handling, dataclasses | |
| Test determinism and synchronization | |
async-bound-concurrencydeterminism-sync-not-sleepasync-no-blocking-event-loopmin(32, cpu + 4)process_cpu_count()thread-for-io-not-cputhread-size-and-boundthread-cannot-be-killedsubprocessexcepthookThreadPoolExecutor| The work | Model | Why |
|---|---|---|
| Nothing that can overlap | plain loop | Concurrency is pure cost: workers and failure modes for zero gain |
| Many blocking I/O calls on async-capable libraries | | See |
| Blocking I/O with libraries that have no async equivalent | | Simplest tool that overlaps the wait |
| Pure-Python CPU work | | The GIL serializes bytecode; processes do not share it |
| CPU work inside a C extension that releases the GIL | threads | The GIL is not the limit for that call |
CPU work, free-threaded build ( | threads | No GIL to serialize them (PEP 703/779); measure, and confirm C extensions did not re-enable it |
| Thousands of small CPU tasks with little shared state | | Each interpreter has its own GIL: multicore parallelism without process startup |
| An external program, script, or CLI | | It is a separate process whether you like it or not |
os.process_cpu_count()os.cpu_count()NoneThreadPoolExecutor(max_workers=None)min(32, (os.process_cpu_count() or 1) + 4)ProcessPoolExecutor(max_workers=None)os.process_cpu_count()InterpreterPoolExecutorThreadPoolExecutorsp-sp-argv-not-shellshell=Truesp-check-and-capturecheck=TruereturncodecheckFalsesp-timeout-alwaysrun(timeout=)sp-kill-the-process-treestart_new_session=Trueos.killpgsp-communicate-not-waitcommunicate().wait().read()sp-popen-only-when-neededrun()with Popensp-close-stdinstdin=DEVNULLsp-explicit-env-and-cwdcwdenvsp-explicit-text-encodingencoding="utf-8"sp-no-preexec-fnpreexec_fnstart_new_sessionprocess_groupsp-bound-child-fanoutsp-drop-legacy-apisos.systemos.popenos.spawn*getstatusoutputsp-async-childrencreate_subprocess_execasyncio.timeoutthread-thread-cannot-be-killedthread-own-and-jointhread-size-and-boundmax_workersasync-bound-concurrencythread-never-wait-on-a-futurethread-for-io-not-cputhread-share-by-messagethread-no-pool-for-long-running-workatexitthread-explicit-shutdownshutdown()cancel_futuresproc-proc-choose-start-methodforkproc-main-guardif __name__ == "__main__":freeze_support()proc-top-level-and-picklableproc-inherit-dont-shipinitializerproc-message-passPipeproc-expect-a-broken-poolBrokenExecutorproc-stop-cooperativelyproc-bound-and-chunkchunksizemapimport subprocess
def git_rev(path: str) -> str:
result = subprocess.run(
["/usr/bin/git", "-C", path, "rev-parse", "HEAD"],
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=30,
check=True,
)
return result.stdout.strip()run(timeout=)import os
import signal
import subprocess
def run_tree(argv: list[str], *, timeout: float, grace: float = 5.0) -> int:
# start_new_session=True puts the child in its own session, so its process
# group id equals its pid: one killpg stops the child and every descendant.
proc = subprocess.Popen(argv, start_new_session=True, stdin=subprocess.DEVNULL)
try:
return proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGTERM) # ask the whole group to stop
try:
proc.wait(timeout=grace) # give it a moment to exit cleanly
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL) # then stop asking
proc.wait() # always reap
raisefrom concurrent.futures import ThreadPoolExecutor
import subprocess
def run_all(argv_list: list[list[str]], *, max_parallel: int = 8) -> list[str]:
def one(argv: list[str]) -> str:
done = subprocess.run(
argv, capture_output=True, text=True, encoding="utf-8",
timeout=60, check=True,
)
return done.stdout
# max_workers bounds how many children exist at once. Same principle as
# async-bound-concurrency, applied to processes instead of coroutines.
with ThreadPoolExecutor(max_workers=max_parallel) as executor:
return list(executor.map(one, argv_list))from concurrent.futures import ThreadPoolExecutor
def handle(item: str) -> None: ...
with ThreadPoolExecutor(max_workers=4) as executor: # shutdown(wait=True) on exit
futures = [executor.submit(handle, item) for item in items]
for future in futures:
future.result() # never let a worker's exception disappearimport multiprocessing
from concurrent.futures import ProcessPoolExecutor
_conn = None # assigned once per worker by init_worker()
def init_worker(dsn: str) -> None:
global _conn
_conn = connect(dsn) # opened once per worker process, not once per task
def score(row_id: int) -> float:
return _conn.query(row_id) # must be importable at module top level to be picklable
if __name__ == "__main__": # required by the spawn and forkserver start methods
ctx = multiprocessing.get_context("spawn") # name it; never inherit the default blindly
with ProcessPoolExecutor(max_workers=8, mp_context=ctx,
initializer=init_worker, initargs=(dsn,)) as executor:
results = list(executor.map(score, row_ids, chunksize=200))faulthandler.dump_traceback_later(60, exit=True)python -X faulthandlerpy-spy dump --pid <pid>py-spy top --pid <pid>--localsSYS_PTRACEthreading.enumerate()multiprocessing.active_children()sp-kill-the-process-treethread-own-and-joinproc-main-guardreferences/debugging-hangs.mdsubprocessshell=Truecheck=Truereturncode.read().stdin.write().wait()PIPEcommunicate()encodingstdin=DEVNULLinput=cwdenvpreexec_fnos.systemos.popenos.spawn*subprocess.getstatusoutputos.process_cpu_count()os.cpu_count()forkif __name__ == "__main__":BrokenExecutorBrokenProcessPoolterminate()references/subprocess.mdsp-references/threads.mdthread-references/processes.mdproc-references/debugging-hangs.mdscripts/verify-recipes.pynpm test