Skip to content

Skip join() on live children in SubprocessList.cleanup() to unblock minion ioloop - #70685

Open
dwoz wants to merge 1 commit into
saltstack:3008.xfrom
dwoz:dwoz/fix/subprocess-list-cleanup-nonblocking
Open

dwoz wants to merge 1 commit into
saltstack:3008.xfrom
dwoz:dwoz/fix/subprocess-list-cleanup-nonblocking

Conversation

@dwoz

@dwoz dwoz commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

Summary

  • salt.utils.process.SubprocessList.cleanup() was calling proc.join(0.01) unconditionally on every tracked entry, including live processes. salt.minion wires cleanup_subprocesses() as a 200ms PeriodicCallback, so with N live children each cycle blocked the minion's ioloop for up to N*10ms inside multiprocessing.Process.join -> _popen.wait -> selectors.select.
  • Under resource-fanout load py-spy caught the minion's main thread repeatedly in that stack and the job-return drain rate was ~3 jobs/s. Skipping join() on live children raised it to ~12.5 jobs/s on the same workload (4x improvement).
  • The fix uses is_alive() (non-blocking waitpid(WNOHANG)) to decide whether to reap, and only join()s dead children (returns instantly, exit status is already cached). Thread entries fall through the same way because is_alive() on a dead thread also returns False.

Test plan

  • New functional test test_subprocess_list_cleanup_does_not_block_on_live_processes: adds 50 live children to a SubprocessList and asserts cleanup() returns well under the old N*10ms = 500ms bound.
  • New functional test test_subprocess_list_cleanup_reaps_dead_processes: guards that the non-blocking fast path still reaps an already-exited child.
  • Existing test_subprocess_list_fds still passes.
  • pre-commit clean.

…ocesses

salt.utils.process.SubprocessList.cleanup() was calling
``proc.join(0.01)`` unconditionally on every tracked entry, including
live processes. salt.minion wires cleanup_subprocesses() as a 200ms
PeriodicCallback (see ``add_periodic_callback("cleanup",
self.cleanup_subprocesses, interval=0.2)``), so with N live children
each cycle blocked the ioloop for up to N*10ms inside
``multiprocessing.Process.join -> _popen.wait -> wait([sentinel],
timeout) -> selectors.select``. Under resource-fanout load py-spy
caught the minion's main thread in exactly this stack repeatedly, and
the job-return drain rate was ~3 jobs/s. Skipping the join on live
children raised it to ~12.5 jobs/s on the same workload (4x).

Live processes don't need joining -- ``is_alive()`` is a non-blocking
``waitpid(WNOHANG)`` check. Skip live ones; only ``join()`` dead
ones, which returns instantly because the exit status is already
cached. Thread entries (``hasattr(proc, "exitcode") == False``) fall
through the same way because ``is_alive()`` on a dead thread also
returns False.

Regression guarded by two new functional tests in
tests/pytests/functional/utils/test_process.py:

  - test_subprocess_list_cleanup_does_not_block_on_live_processes:
    adds 50 live children to a SubprocessList and asserts cleanup()
    returns in well under the old N*10ms = 500ms bound.
  - test_subprocess_list_cleanup_reaps_dead_processes: guards that
    the non-blocking fast path still reaps an already-exited child.
@dwoz
dwoz requested a review from a team as a code owner November 5, 2026 01:47
@dwoz dwoz added the test:full Run the full test suite label Nov 5, 2026
@dwoz dwoz added this to the Argon v3008.7 milestone Nov 5, 2026

This branch was previously deployed

1 inactive deployment
ci — 281ba97a Deployed Nov 5, 2026 by dwoz via Build Onedir Packages / macOS (x86_64) #28449
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

test:full Run the full test suite

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant