Skip to content

Commit 7de6c4f

Browse files
gpsheadclaude
authored andcommitted
[3.14] gh-119592: gh-152967: Fix ProcessPoolExecutor stranding submitted work when a max_tasks_per_child worker exits (GH-152978)
gh-119592: Fix ProcessPoolExecutor stranding submitted work when a max_tasks_per_child worker exits Worker replacement went through the executor object: the manager thread read executor attributes that shutdown(wait=False) clears concurrently, and could not replace workers at all once the executor was garbage collected. A worker exiting at its max_tasks_per_child limit in those states left the remaining submitted work permanently unexecuted and hung interpreter exit; the racing case could crash the manager thread. Replace workers from the executor manager thread using its own state plus configuration read through the live executor weakref, which shutdown() never clears: - After shutdown(wait=False) with the executor still referenced, a replacement is spawned and the remaining work is executed as documented. - Once the executor has been garbage collected (gh-152967), or a replacement worker cannot be started and no workers remain, the remaining futures now fail with BrokenProcessPool instead of never resolving. - A new _force_shutting_down flag stops both spawn paths from starting workers that would escape terminate_workers()/kill_workers(). (cherry picked from commit 0c6422f) Co-authored-by: Gregory P. Smith <68491+gpshead@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Reviewed-multiple-times-by: Gregory P. Smith
1 parent cb40934 commit 7de6c4f

3 files changed

Lines changed: 262 additions & 46 deletions

File tree

Lib/concurrent/futures/process.py

Lines changed: 122 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,20 @@ def _process_worker(call_queue, result_queue, initializer, initargs, max_tasks=N
269269
return
270270

271271

272+
def _spawn_worker(mp_context, call_queue, result_queue, initializer,
273+
initargs, max_tasks_per_child, processes):
274+
"""Start one worker process and record it in *processes* by pid."""
275+
p = mp_context.Process(
276+
target=_process_worker,
277+
args=(call_queue,
278+
result_queue,
279+
initializer,
280+
initargs,
281+
max_tasks_per_child))
282+
p.start()
283+
processes[p.pid] = p
284+
285+
272286
class _ExecutorManagerThread(threading.Thread):
273287
"""Manages the communication between this process and the worker processes.
274288
@@ -321,6 +335,15 @@ def weakref_cb(_,
321335
# exiting safely
322336
self.max_tasks_per_child = executor._max_tasks_per_child
323337

338+
# gh-119592: Needed to size worker replacement, and immutable, so
339+
# keep a copy rather than reading it back through the executor
340+
# weakref. The rest of the spawn configuration is deliberately NOT
341+
# copied here: holding user-provided objects (initializer,
342+
# initargs, mp_context) in this always-reachable running thread
343+
# could keep the executor itself reachable through them, breaking
344+
# garbage-collection-triggered shutdown.
345+
self.max_workers = executor._max_workers
346+
324347
# A dict mapping work ids to _WorkItems e.g.
325348
# {5: <_WorkItem...>, 6: <_WorkItem...>, ...}
326349
self.pending_work_items = executor._pending_work_items
@@ -357,12 +380,14 @@ def run(self):
357380
# while waiting on new results.
358381
del result_item
359382

360-
if executor := self.executor_reference():
361-
if process_exited:
362-
with self.shutdown_lock:
363-
executor._replace_dead_worker()
364-
else:
365-
executor._idle_worker_semaphore.release()
383+
if process_exited:
384+
with self.shutdown_lock:
385+
broken = self._replace_dead_worker()
386+
if broken is not None:
387+
self.terminate_broken(*broken)
388+
return
389+
elif executor := self.executor_reference():
390+
executor._idle_worker_semaphore.release()
366391
del executor
367392

368393
if self.is_shutting_down():
@@ -379,6 +404,71 @@ def run(self):
379404
self.join_executor_internals()
380405
return
381406

407+
def _replace_dead_worker(self):
408+
"""Spawn a replacement for a worker that exited at its
409+
max_tasks_per_child limit. Called under self.shutdown_lock.
410+
411+
Returns None while the pool can still make progress, otherwise a
412+
(cause, message) tuple describing why the remaining work items can
413+
never run, so that run() can fail their futures.
414+
"""
415+
assert self.shutdown_lock.locked()
416+
cause = None
417+
message = None
418+
executor = self.executor_reference()
419+
if executor is None:
420+
# gh-152967: The executor was garbage collected; nothing can
421+
# spawn a replacement worker for it anymore.
422+
message = ("The ProcessPoolExecutor was garbage collected with "
423+
"work pending after its last worker process exited "
424+
"upon reaching max_tasks_per_child; the pending work "
425+
"can never be run.")
426+
elif executor._force_shutting_down:
427+
# terminate_workers()/kill_workers() is tearing the pool down;
428+
# a replacement worker would escape the kill and run work
429+
# items that were enqueued before it.
430+
message = ("A worker process exited while the pool was being "
431+
"forcefully shut down; work that was still enqueued "
432+
"will not be run.")
433+
elif self.pending_work_items or not self.is_shutting_down():
434+
# gh-115634: Do not consult the executor's
435+
# _idle_worker_semaphore here: it counts task completions, not
436+
# idle workers, so it can hold a stale token released by the
437+
# now-dead worker. Trusting such a token would leave the pool
438+
# a worker short, deadlocking once all workers reach their
439+
# task limit. Spawning from this (manager) thread is safe
440+
# despite gh-90622 because max_tasks_per_child is rejected for
441+
# the "fork" start method.
442+
if len(self.processes) < self.max_workers:
443+
# gh-119592: Spawn using state owned by this thread and
444+
# configuration read through the live weakref (which
445+
# shutdown() never clears), not the executor state that
446+
# shutdown(wait=False) clears concurrently.
447+
try:
448+
_spawn_worker(executor._mp_context, self.call_queue,
449+
self.result_queue, executor._initializer,
450+
executor._initargs,
451+
self.max_tasks_per_child, self.processes)
452+
except Exception as exc:
453+
# While other workers remain the pool has merely lost
454+
# capacity and they keep draining the queue; with none
455+
# left the failure is reported below.
456+
cause = format_exception(exc)
457+
message = ("A replacement worker process could not be "
458+
"started, leaving the pool without workers "
459+
"to run the remaining work.")
460+
del executor
461+
462+
if not self.processes and (self.pending_work_items
463+
or cause is not None):
464+
# No worker processes remain and no replacement can be
465+
# spawned: any remaining work items can never run. A spawn
466+
# failure breaks the pool even with nothing pending; leaving
467+
# a zero-worker pool alive would hang a later submit() on a
468+
# stale _idle_worker_semaphore token instead of raising.
469+
return (cause, message)
470+
return None
471+
382472
def add_call_item_to_queue(self):
383473
# Fills call_queue with _WorkItems from pending_work_items.
384474
# This function never blocks.
@@ -455,10 +545,11 @@ def is_shutting_down(self):
455545
return (_global_shutdown or executor is None
456546
or executor._shutdown_thread)
457547

458-
def _terminate_broken(self, cause):
548+
def _terminate_broken(self, cause, bpe_message=None):
459549
# Terminate the executor because it is in a broken state. The cause
460550
# argument can be used to display more information on the error that
461-
# lead the executor into becoming broken.
551+
# lead the executor into becoming broken. bpe_message overrides the
552+
# default message on the BrokenProcessPool set on pending futures.
462553

463554
# Mark the process pool broken so that submits fail right now.
464555
executor = self.executor_reference()
@@ -476,11 +567,12 @@ def _terminate_broken(self, cause):
476567
if cause is not None:
477568
cause_tb = f"\n'''\n{''.join(cause)}'''"
478569

570+
if bpe_message is None:
571+
bpe_message = ("A process in the process pool was terminated "
572+
"abruptly while the future was running or pending.")
479573
# Mark pending tasks as failed.
480574
for work_id, work_item in self.pending_work_items.items():
481-
bpe = BrokenProcessPool("A process in the process pool was "
482-
"terminated abruptly while the future was "
483-
"running or pending.")
575+
bpe = BrokenProcessPool(bpe_message)
484576
if cause_tb is not None:
485577
bpe.__cause__ = _RemoteTraceback(cause_tb)
486578
try:
@@ -505,9 +597,9 @@ def _terminate_broken(self, cause):
505597
# clean up resources
506598
self._join_executor_internals(broken=True)
507599

508-
def terminate_broken(self, cause):
600+
def terminate_broken(self, cause, bpe_message=None):
509601
with self.shutdown_lock:
510-
self._terminate_broken(cause)
602+
self._terminate_broken(cause, bpe_message)
511603

512604
def flag_executor_shutting_down(self):
513605
# Flag the executor as shutting down and cancel remaining tasks if
@@ -720,6 +812,7 @@ def __init__(self, max_workers=None, mp_context=None,
720812
self._queue_count = 0
721813
self._pending_work_items = {}
722814
self._cancel_pending_futures = False
815+
self._force_shutting_down = False
723816

724817
# _ThreadWakeup is a communication channel used to interrupt the wait
725818
# of the main loop of executor_manager_thread from another thread (e.g.
@@ -759,34 +852,15 @@ def _start_executor_manager_thread(self):
759852
_threads_wakeups[self._executor_manager_thread] = \
760853
self._executor_manager_thread_wakeup
761854

762-
def _replace_dead_worker(self):
855+
def _adjust_process_count(self):
763856
# gh-132969: avoid error when state is reset and executor is still running,
764857
# which will happen when shutdown(wait=False) is called.
765858
if self._processes is None:
766859
return
767860

768-
# A replacement is pointless when shutting down with nothing left
769-
# to run. Both attributes are read under _shutdown_lock, which
770-
# shutdown() holds while setting _shutdown_thread.
771-
assert self._shutdown_lock.locked()
772-
if self._shutdown_thread and not self._pending_work_items:
773-
return
774-
775-
# gh-115634: A worker exited after reaching max_tasks_per_child and
776-
# has been removed from self._processes. Do not consult
777-
# _idle_worker_semaphore here: it counts task completions, not idle
778-
# workers, so it can hold a stale token released by the now-dead
779-
# worker. Trusting such a token would leave the pool a worker short,
780-
# deadlocking once all workers reach their task limit. Spawning is
781-
# safe from this (manager) thread despite gh-90622 because
782-
# max_tasks_per_child is rejected for the "fork" start method.
783-
if len(self._processes) < self._max_workers:
784-
self._spawn_process()
785-
786-
def _adjust_process_count(self):
787-
# gh-132969: avoid error when state is reset and executor is still running,
788-
# which will happen when shutdown(wait=False) is called.
789-
if self._processes is None:
861+
# gh-152967: A forceful shutdown is in progress; a worker spawned
862+
# here could escape its process snapshot and keep running work.
863+
if self._force_shutting_down:
790864
return
791865

792866
# if there's an idle process, we don't need to spawn a new one.
@@ -812,15 +886,10 @@ def _launch_processes(self):
812886
self._spawn_process()
813887

814888
def _spawn_process(self):
815-
p = self._mp_context.Process(
816-
target=_process_worker,
817-
args=(self._call_queue,
818-
self._result_queue,
819-
self._initializer,
820-
self._initargs,
821-
self._max_tasks_per_child))
822-
p.start()
823-
self._processes[p.pid] = p
889+
_spawn_worker(self._mp_context, self._call_queue,
890+
self._result_queue, self._initializer,
891+
self._initargs, self._max_tasks_per_child,
892+
self._processes)
824893

825894
def submit(self, fn, /, *args, **kwargs):
826895
with self._shutdown_lock:
@@ -917,6 +986,14 @@ def _force_shutdown(self, operation):
917986
if operation not in _SHUTDOWN_CALLBACK_OPERATION:
918987
raise ValueError(f"Unsupported operation: {operation!r}")
919988

989+
# gh-152967: Stop the manager thread from spawning replacement
990+
# workers before we copy the processes to signal: a worker spawned
991+
# after the copy would survive the loop below and run enqueued
992+
# work items. Taking the lock orders this against the manager's
993+
# worker replacement, which runs under the same lock.
994+
with self._shutdown_lock:
995+
self._force_shutting_down = True
996+
920997
processes = {}
921998
if self._processes:
922999
processes = self._processes.copy()

0 commit comments

Comments
 (0)