Repository navigation
Description
Environment:
Debian 5.10.113-1, python 3.7 (can repro on python 3.10, too)
Repro:
This was found in a LLVM test, easiest way to repro (not the most minimal, though):
git clone git@github.com:llvm/llvm-project.git
while true ; do llvm/utils/lit/lit.py -j1 --order=lexical --max-failures=1 llvm/utils/lit/tests/Inputs/max-failures ; done
This sets up a 1-worker pool (-j1). The debug output looks like this:
-- Testing: 3 tests, 1 workers --
created semlock with handle 140702642597888
created semlock with handle 140702642593792
created semlock with handle 140702628683776
created semlock with handle 140702628679680
created semlock with handle 140702628675584
created semlock with handle 140702628671488
added worker
closing pool
FAIL: max-failures :: fail1.txt (1 of 3)
terminating pool
finalizing pool
helping task handler/workers to finish
removing tasks from inqueue until task handler finished
result handler found thread._state=TERMINATE
ensuring that outqueue is not full
joining worker handler
worker handler exiting
result handler exiting: len(cache)=%s, thread._state=%s 2 TERMINATE
terminating workers
task handler got sentinel
joining task handler
task handler sending sentinel to result handler
...and now it hangs
After attaching with gdb:
(gdb) py-bt
Traceback (most recent call first):
File "/usr/lib/python3.10/multiprocessing/synchronize.py", line 95, in __enter__
return self._semlock.__enter__()
File "/usr/lib/python3.10/multiprocessing/queues.py", line 376, in put
with self._wlock:
File "/usr/lib/python3.10/multiprocessing/pool.py", line 562, in _handle_tasks
outqueue.put(None)
File "/usr/lib/python3.10/threading.py", line 953, in run
self._target(*self._args, **self._kwargs)
File "/usr/lib/python3.10/threading.py", line 1016, in _bootstrap_inner
self.run()
File "/usr/lib/python3.10/threading.py", line 973, in _bootstrap
self._bootstrap_inner()
Upon looking further: looks like multiprocessing.Pool's terminate attempts to terminate (as in Popen.terminate) the worker processes too early. If worker was in outqueue's critical section (that _wlock was taken) when it's shut down, that may explain the issue.
I tried calling terminate on the pool processes only after joining the task_handler and result_handler, i.e. :
711,716d710
< # Terminate workers which haven't already finished.
< if pool and hasattr(pool[0], 'terminate'):
< util.debug('terminating workers')
< for p in pool:
< if p.exitcode is None:
< p.terminate()
724a719,725
>
> # Terminate workers which haven't already finished.
> if pool and hasattr(pool[0], 'terminate'):
> util.debug('terminating workers')
> for p in pool:
> if p.exitcode is None:
> p.terminate()
That appears to address the issue - the script runs now indefinitely.
The code in Pool.terminate seems to have been around for a while, so maybe I am missing something? If not, happy to submit a patch!
Metadata
Metadata
Assignees
Labels
Projects
- StatusShow more project fieldsNo status
Activity
lorenzph commentedon Nov 7, 2024
I have experienced the same issue and the following snippet triggers this deadlock quite reliably on my machine (Python 3.12.7 on Linux):
Switching
imaptomapmitigates the issue as in this particular casemapdoesn't seem to raise the exception until the workers are finished (hence avoiding the issue that the lock is held by one when the pool is torn down).ondrejch commentedon Oct 31, 2026
We hit what looks like a second mechanism behind this hang, in a batch job on Linux x86-64 workers (Python 3.12.15, also reproduced on 3.12.3). The reordering proposed here is not sufficient alone.
Mechanism.
Connection._send_bytessends a message larger than 16384 bytes as two writes, a 4-byte header and then the payload (connection.py, 3.12: lines 417-421).SimpleQueue.putholdsoutqueue._wlockacross both. Ifterminate()SIGTERMs a worker between the two writes, the worker dies holding_wlockand leaves a truncated message in the result pipe. The parent keeps the write end open, so there is no EOF:pool.py:626get()->connection.py:250 recv->437 _recv_bytes->395 _recv(in the field case,read(fd, 19770)with 0 bytes unread);pool.py:562outqueue.put(None)->queues.py:398with self._wlock;terminate()waits in_terminate_poolatpool.py:720(task_handler.join()), or at724(result_handler.join()) if the task handler had already exited.The frames are identical on 3.12.3 and 3.12.15. The split send and
SimpleQueue.putare unchanged in 3.13, 3.14, 3.15 and main;if n > 16384is atconnection.pyline 417 on 3.12, 422 on 3.13 and 439 on 3.14, 3.15 and main. Thosepool.pylines stay at 562, 626, 703, 720 and 724 through 3.15, and_terminate_poolis unchanged through 3.15. On main the sentinelputis at 728 and the joins at 745 and 749, and_terminate_poolalso releases the task-queue buffer semaphores aftertask_handler._state = TERMINATE; that does not release_wlock. The same truncation is the subject of the open gh-159689 and the open, unmerged PR #159697 (checked 2026-10-31). That PR teaches theProcessPoolExecutorresult read to watch worker sentinels.Poolis unchanged there, and arecv()without sentinels still blocks.Reordering alone. Separately, a hang with no kill at all has the caller of
terminate()atpool.py:703(outqueue.put(None)->queues.py:398,with self._wlock). One trial of a longer, instrumented version of the script below (8 workers, 64 tasks, and a 300 ms pause after the last input item) was snapshotted on unpatched 3.12.3 in that state: the task handler atpool.py:562->queues.py:398, the workers alive, 3687 of the 4096 bytes of the result pipe unread, and the_wlocksemaphore value 0. The diff in this issue does not move line 703. It lies above the workerterminate()loop (lines 712-716) and the joins (720, 724), so a live holder of_wlockstops_terminate_poolthere whether or not the loop is moved. I did not apply the diff and rerun; this follows from the unpatched snapshot and the line order.Reproducer (stdlib only, Linux). It widens the window with two tricks: the result pipe is shrunk to 4 KiB with
F_SETPIPE_SZ, and unpickling a result in the parent sleeps 20 ms. Neither patchesmultiprocessing.On one host (3.12.3) this exits 3 with the stacks above in the first 30 trials in 3 of 3 invocations (trials 6, 5 and 8). A longer, instrumented version of the same script hung 3 times in 22 trials across four invocations (the last invocation stopped after 2 trials without a hang); the task and result handlers were stuck as above in one, the result handler only in two. These numbers are from one machine.
This looks like the same root cause as gh-79448 (the
_wlockheld by a killed worker) and gh-78178 (the result handler stuck in the drain). A fix probably has to stop the workers cooperatively, or make the two-part send safe against a kill, rather than only reorder the joins.