gh-146181: Spawn asyncio Unix subprocesses in a worker thread#154111
Open
sreehariannam wants to merge 2 commits into
Open
gh-146181: Spawn asyncio Unix subprocesses in a worker thread#154111sreehariannam wants to merge 2 commits into
sreehariannam wants to merge 2 commits into
Conversation
sreehariannam
requested review from
1st1,
asvetlov,
kumaraditya303 and
willingc
as code owners
July 19, 2026 11:56
sreehariannam
force-pushed
the
gh-146181-async-subprocess-spawn
branch
from
July 21, 2026 10:19
6c26687 to
e2d6328
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes gh-146181.
Problem
asyncio.create_subprocess_exec()/create_subprocess_shell()callsubprocess.Popen()synchronously on the event loop thread.Popen()doesn't return until the child has calledexecve(), so a slow-to-start child (large statically-linked binary, contended disk during page-in, etc.) can block the entire event loop for multiple seconds, as reported in production.Fix (Unix only — see note below)
Following the direction @gvanrossum and @kumaraditya303 converged on in the issue: only the
subprocess.Popen()call itself moves to a worker thread, vialoop.run_in_executor(). Everything else (task creation, watcher registration) stays on the loop thread, sinceloop.create_task()and friends aren't thread-safe.Concretely:
Lib/asyncio/unix_events.py: extracted thePopen()+ AIX/cygwin stdin-socketpair logic out of_UnixSubprocessTransport._start()into a module-level_spawn_subprocess()function, so it can run insiderun_in_executor()._make_subprocess_transport()now awaits that in the executor, then constructs the transport with the already-created process.Lib/asyncio/base_subprocess.py:BaseSubprocessTransport.__init__gained an optional_proc=Noneparameter — if a process is already provided, it skips callingself._start(). Purely additive; unset by default, so this is a no-op for any existing caller (including Windows, whose_make_subprocess_transportdoesn't pass it).Cancellation (the part flagged as tricky)
Worker threads can't be interrupted, so if the task awaiting subprocess creation is cancelled while
Popen()is still running in the executor, the call keeps running in the background regardless. To avoid leaking an orphaned, untracked child process (or its pipe fds) in that case:I found this the hard way: my first version only did
proc.kill()/proc.wait()and leaked the three pipe fdsPopen()opens forstdin=PIPE, stdout=PIPE, stderr=PIPE, since no transport is ever constructed in this path to own that cleanup — caught it via aResourceWarningthat showed up on the existingtest_cancel_post_inittest once I ran it a few times.@kumaraditya303 — does this cleanup behavior match what you had in mind, or would you rather cancellation left the process running for some caller-visible cleanup semantics? Wanted to raise this concretely against real code rather than in the abstract, since the general direction (thread dispatch for
Popen()) already had your and Guido's go-ahead in the issue.Windows
windows_events.pyhas the identical structural issue (_WindowsSubprocessTransport._start()callingPopen()synchronously from an async_make_subprocess_transport()), but I can only build/test on Linux (WSL) and have no way to exercise the IOCP/proactor code path. Deliberately leaving Windows out of this PR rather than changing code I can't verify — happy to do it as a follow-up if someone can test on Windows, or if this Unix-side approach gets a thumbs up first.Testing
test_asyncio.test_subprocesssuite (85 tests after this change) passes, including bothSubprocessThreadedWatcherTestsandSubprocessPidfdWatcherTestsvariants.test_asynciosuite (2784 tests) passes.test_subprocess_exec_does_not_block_loop: patches_spawn_subprocessto block on athreading.Event, and asserts a concurrent coroutine keeps ticking while the "spawn" is stuck.test_cancel_make_subprocess_transport_kills_process: cancels subprocess creation mid-spawn and asserts the resulting process was killed/reaped and its pipe fds closed (not just thatCancelledErrorpropagates, which the existingtest_cancel_post_initalready covered).unix_events.py/base_subprocess.pyand confirming they fail without it.