gh-115634: Fix ProcessPoolExecutor deadlock with max_tasks_per_child#140900
Merged
Conversation
This comment was marked as outdated.
This comment was marked as outdated.
|
This PR is stale because it has been open for 30 days with no activity. |
…child The idle worker semaphore counts task completions, not idle workers, so it can hold a stale token released by a worker that later exited upon reaching its max_tasks_per_child limit. The worker replacement path consumed such tokens and skipped spawning a replacement, deadlocking the remaining queued tasks once no workers were left. Replace dead workers based on len(self._processes) without consulting the semaphore. The submit() path is unchanged, preserving on-demand spawning and idle worker reuse. Replace the documentation note added in pythonGH-140897 with a versionchanged entry now that the bug is fixed. Based on a fix proposed by Tabrez Mohammed.
c8dd63c to
a0d0402
Compare
Documentation build overview
|
|
Thanks @gpshead for the PR 🌮🎉.. I'm working now to backport this PR to: 3.13, 3.14, 3.15. |
|
GH-152926 is a backport of this pull request to the 3.15 branch. |
|
Sorry, @gpshead, I could not cleanly backport this to |
|
GH-152927 is a backport of this pull request to the 3.14 branch. |
gpshead
added a commit
that referenced
this pull request
Jul 3, 2026
…_child (GH-140900) (#152927) gh-115634: Fix ProcessPoolExecutor deadlock with max_tasks_per_child (GH-140900) The idle worker semaphore counts task completions, not idle workers, so it can hold a stale token released by a worker that later exited upon reaching its max_tasks_per_child limit. The worker replacement path consumed such tokens and skipped spawning a replacement, deadlocking the remaining queued tasks once no workers were left. Replace dead workers based on len(self._processes) without consulting the semaphore. The submit() path is unchanged, preserving on-demand spawning and idle worker reuse. Replace the documentation note added in GH-140897 with a versionchanged entry now that the bug is fixed. Based on a fix proposed by Tabrez Mohammed. (cherry picked from commit b706767) Co-authored-by: Gregory P. Smith <68491+gpshead@users.noreply.github.com>
gpshead
added a commit
that referenced
this pull request
Jul 3, 2026
…_child (GH-140900) (#152926) gh-115634: Fix ProcessPoolExecutor deadlock with max_tasks_per_child (GH-140900) The idle worker semaphore counts task completions, not idle workers, so it can hold a stale token released by a worker that later exited upon reaching its max_tasks_per_child limit. The worker replacement path consumed such tokens and skipped spawning a replacement, deadlocking the remaining queued tasks once no workers were left. Replace dead workers based on len(self._processes) without consulting the semaphore. The submit() path is unchanged, preserving on-demand spawning and idle worker reuse. Replace the documentation note added in GH-140897 with a versionchanged entry now that the bug is fixed. Based on a fix proposed by Tabrez Mohammed. (cherry picked from commit b706767) Co-authored-by: Gregory P. Smith <68491+gpshead@users.noreply.github.com>
|
GH-152928 is a backport of this pull request to the 3.13 branch. |
gpshead
added a commit
that referenced
this pull request
Jul 3, 2026
…_child (GH-140900) (#152928) The idle worker semaphore counts task completions, not idle workers, so it can hold a stale token released by a worker that later exited upon reaching its max_tasks_per_child limit. The worker replacement path consumed such tokens and skipped spawning a replacement, deadlocking the remaining queued tasks once no workers were left. Replace dead workers based on len(self._processes) without consulting the semaphore. The submit() path is unchanged, preserving on-demand spawning and idle worker reuse. Replace the documentation note added in GH-140897 with a versionchanged entry now that the bug is fixed. Based on a fix proposed by Tabrez Mohammed. (cherry picked from commit b706767)
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.
Summary
Fix a deadlock in
ProcessPoolExecutorwhen usingmax_tasks_per_child.The executor stopped scheduling queued tasks after a worker process exited
upon reaching its task limit.
Prints 0 and 1, then hangs forever.
The bug
The idle worker semaphore counts task completions, not idle workers: a
token is released on every non-final task completion, but only
submit()consumes tokens. When a backlog is queued, workers take their next task
directly from the call queue, so a token can outlive the idle period it
was released for -- and outlive the worker itself once the worker exits
at its
max_tasks_per_childlimit. The worker-replacement path thenacquired such a stale token, concluded an idle worker existed, and never
spawned a replacement. With no workers left, the queued tasks deadlocked.
Present since
max_tasks_per_childwas introduced in Python 3.11(GH-27373). Affects 3.11 through 3.15.0beta3.
The fix
Worker replacement after a process exit no longer consults the semaphore:
a new
_replace_dead_worker()spawns a replacement whenever the pool isbelow
max_workers, usinglen(self._processes)as the source of truth.The
submit()path is unchanged, preserving on-demand spawning and idleworker reuse (bpo-39207). This is the semantics suggested by @pitrou in
the GH-115642 review; the approach was proposed and production-tested by
@tabrezm.
Replacement is skipped when the executor is shutting down with no
pending work, so a worker reaching its task limit during shutdown does
not spawn a process that shutdown immediately reaps. The previously
disabled gh-90622 assertion in
_adjust_process_count()is re-enabled:with worker replacement split out, that method is reachable only from
submit()with a non-fork start method, so the assertion is valid now.The semaphore still drifts above the true idle count while a backlog
exists. After this change the drift is harmless: it can only briefly
suppress pool growth below
max_workers(each suppressed spawn drainsone token), never lose a worker. Whether the semaphore should be
redesigned or removed is left as a follow-up (see GH-115642 discussion).
The documentation warning added in GH-140897 is replaced with a
versionchanged:: nextentry, so each branch's docs will name therelease that fixed it; backports should likewise replace the notes
added by GH-143302 / GH-143303.
Related Issues
Acknowledgments
Claude Sonnet 4.5 helped do the original work on this PR last year. Claude Fable 5 helped tighten it up and validate everything.