Skip to content

Fix intermittent hang of parallel_map when called from several threads - #566

Merged
gmloose merged 1 commit into
masterfrom
fix-fork-deadlock-parallel-map
Nov 9, 2026
Merged

gmloose merged 1 commit into
masterfrom
fix-fork-deadlock-parallel-map

Conversation

@gmloose

@gmloose gmloose commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator

Problem

CI runs hang intermittently in test/tmulti_proc.py, at test_overlapping_calls. One #564 run hung there for 27 minutes, until it was cancelled, and the run for #560 has been stuck in the same test step for hours. Jobs then run until GitHub's 6-hour limit.

The cause is a race in concurrent.futures.process when forking from a multi-threaded process:

  1. The module keeps a registry (_threads_wakeups) of the "wakeup" objects of all running process pools. A forked worker inherits it, including the entries of other pools.
  2. When a worker exits, threading._shutdown() runs _python_exit, which calls wakeup() on every entry, and wakeup() takes the object's lock.
  3. If another thread held one of those locks at the moment of the fork, the worker inherits a lock that is never released. The worker then hangs on exit, and parallel_map hangs in executor.shutdown() while waiting for it.

test_overlapping_calls calls parallel_map from two threads at once, so occasionally one pool forks while the other thread is inside wakeup(). The stacks of a reproduced hang (with py-spy) show exactly this: the parent waits in executor.shutdown(), and the worker is blocked in _ThreadWakeup.wakeup called from _python_exit.

The lock in _ThreadWakeup only exists in recent Python versions. It's present in 3.12.15, 3.13 and 3.14, but not in 3.12.3 or in 3.10/3.11. CI uses 3.12.16, and the hang couldn't be reproduced with 3.12.3.

Fix

  • multi_proc.py: the worker initializer clears the inherited registry. Those entries belong to the parent's pools, which are of no use in a worker. CPython already does the same for thread pools in forked processes (concurrent/futures/thread.py: os.register_at_fork(after_in_child=_threads_queues.clear)), but not for process pools. Real PyBDSF runs call parallel_map from one thread, so they are affected only when PyBDSF is used from several threads at once.
  • tmulti_proc.py: a new test_fork_while_other_pool_is_busy triggers the race deterministically. It holds another pool's wakeup lock while parallel_map forks its workers. The test runs in a subprocess with a 60-second timeout, so a regression fails the test instead of hanging the job. It's skipped on Python versions without the lock.
  • ci.yml: timeout-minutes: 30 for the job (normal runs take 2–4 minutes).

Testing

Before After
CI test sequence 150 times on 4 cores, Python 3.12.15 hung in run 50 no hang
Same, Python 3.14.5 – no hang
New regression test, Python 3.12.15 / 3.13.3 / 3.14.5 hangs (fails after 60 s) passes
tmulti_proc.py, Python 3.11, 3.12, 3.13, 3.14 – all 12 tests pass

Unrelated and not changed here: on Python 3.10, test_interrupt_stops_workers fails intermittently ("workers still running"), also on master (in 1 of 3 runs in both cases). The wheel builds (cibuildwheel) run this test on cp310.

🤖 Generated with Claude Code

Forked workers of a ProcessPoolExecutor inherit the registry of "wakeup"
objects of all process pools in the parent (concurrent.futures.process.
_threads_wakeups), and wake them up when they exit. In recent Python
versions (e.g. 3.12.15, 3.13, 3.14) these objects have a lock; if another
thread held such a lock while a worker was forked, the worker hung when
exiting, and parallel_map hung in executor.shutdown(). This made
test_overlapping_calls in tmulti_proc.py hang intermittently in CI.

- Clear the inherited registry in the worker initializer (CPython does the
  same for thread pools in forked processes).
- Add a regression test that triggers the race deterministically.
- Add a 30-minute timeout to the CI job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gmloose

gmloose commented Nov 7, 2026

Copy link
Copy Markdown
Collaborator Author

@codex

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-07T00:46:13.826217Z 2cb283e Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Breezy!

Reviewed commit: 2cb283e0e2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@darafferty darafferty left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I completely understand the problem, but I think I might have encountered some hanging myself on recent runs. At any rate, good to have a test for it!

@gmloose

gmloose commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator Author

Not sure I completely understand the problem, but I think I might have encountered some hanging myself on recent runs. At any rate, good to have a test for it!

It's subtle, indeed, like with all bugs related to race conditions. Apparently, this is a bug in the Python library concurrent.futures, and as such a consequence of the recent changes made to bdsf/multi_proc.py in #503.

@gmloose
gmloose merged commit a1e54b5 into master Nov 9, 2026
1 check passed
gmloose added a commit that referenced this pull request Nov 9, 2026
#566)

Forked workers of a ProcessPoolExecutor inherit the registry of "wakeup"
objects of all process pools in the parent (concurrent.futures.process.
_threads_wakeups), and wake them up when they exit. In recent Python
versions (e.g. 3.12.15, 3.13, 3.14) these objects have a lock; if another
thread held such a lock while a worker was forked, the worker hung when
exiting, and parallel_map hung in executor.shutdown(). This made
test_overlapping_calls in tmulti_proc.py hang intermittently in CI.

- Clear the inherited registry in the worker initializer (CPython does the
  same for thread pools in forked processes).
- Add a regression test that triggers the race deterministically.
- Add a 30-minute timeout to the CI job.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@gmloose
gmloose deleted the fix-fork-deadlock-parallel-map branch November 9, 2026 14:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants