mirror of https://github.com/ansible/ansible.git
Add DaemonThreadPoolExecutor impl (#83880)
* Add DaemonThreadPoolExecutor impl * Provide a simple parallel execution method with the ability to abandon timed-out operations that won't block threadpool/process shutdown, and without a dependency on /dev/shm (as multiprocessing Thread/Process pools have). * Create module_utils/_internal to ensure that this is clearly not supported for public consumption.pull/83887/head
parent
1a4644ff15
commit
24e5b0d4fc
@ -0,0 +1,28 @@
|
|||||||
|
"""Proxy stdlib threading module that only supports non-joinable daemon threads."""
|
||||||
|
# NB: all new local module attrs are _ prefixed to ensure an identical public attribute surface area to the module we're proxying
|
||||||
|
|
||||||
|
from __future__ import annotations as _annotations
|
||||||
|
|
||||||
|
import threading as _threading
|
||||||
|
import typing as _t
|
||||||
|
|
||||||
|
|
||||||
|
class _DaemonThread(_threading.Thread):
|
||||||
|
"""
|
||||||
|
Daemon-only Thread subclass; prevents running threads of this type from blocking interpreter shutdown and process exit.
|
||||||
|
The join() method is a no-op.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *args, daemon: bool | None = None, **kwargs) -> None:
|
||||||
|
super().__init__(*args, daemon=daemon or True, **kwargs)
|
||||||
|
|
||||||
|
def join(self, timeout=None) -> None:
|
||||||
|
"""ThreadPoolExecutor's atexit handler joins all queue threads before allowing shutdown; prevent them from blocking."""
|
||||||
|
|
||||||
|
|
||||||
|
Thread = _DaemonThread # shadow the real Thread attr with our _DaemonThread
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str) -> _t.Any:
|
||||||
|
"""Delegate anything not defined locally to the real `threading` module."""
|
||||||
|
return getattr(_threading, name)
|
@ -0,0 +1,21 @@
|
|||||||
|
"""Utilities for concurrent code execution using futures."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import concurrent.futures
|
||||||
|
import types
|
||||||
|
|
||||||
|
from . import _daemon_threading
|
||||||
|
|
||||||
|
|
||||||
|
class DaemonThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor):
|
||||||
|
"""ThreadPoolExecutor subclass that creates non-joinable daemon threads for non-blocking pool and process shutdown with abandoned threads."""
|
||||||
|
|
||||||
|
atc = concurrent.futures.ThreadPoolExecutor._adjust_thread_count
|
||||||
|
|
||||||
|
# clone the base class `_adjust_thread_count` method with a copy of its globals dict
|
||||||
|
_adjust_thread_count = types.FunctionType(atc.__code__, atc.__globals__.copy(), name=atc.__name__, argdefs=atc.__defaults__, closure=atc.__closure__)
|
||||||
|
# patch the method closure's `threading` module import to use our daemon-only thread factory instead
|
||||||
|
_adjust_thread_count.__globals__.update(threading=_daemon_threading)
|
||||||
|
|
||||||
|
del atc # don't expose this as a class attribute
|
@ -0,0 +1,15 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from ansible.module_utils._internal._concurrent import _daemon_threading
|
||||||
|
|
||||||
|
|
||||||
|
def test_daemon_thread_getattr() -> None:
|
||||||
|
"""Ensure that the threading module proxy delegates properly to the real module."""
|
||||||
|
assert _daemon_threading.current_thread is threading.current_thread
|
||||||
|
|
||||||
|
|
||||||
|
def test_daemon_threading_thread_override() -> None:
|
||||||
|
"""Ensure that the proxy module's Thread attribute is different from the real module's."""
|
||||||
|
assert _daemon_threading.Thread is not threading.Thread
|
@ -0,0 +1,62 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import concurrent.futures as _cf
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ansible.module_utils._internal._concurrent import _futures
|
||||||
|
|
||||||
|
|
||||||
|
def test_daemon_thread_pool_nonblocking_cm_exit() -> None:
|
||||||
|
"""Ensure that the ThreadPoolExecutor context manager exit is not blocked by in-flight tasks."""
|
||||||
|
with _futures.DaemonThreadPoolExecutor(max_workers=1) as executor:
|
||||||
|
future = executor.submit(time.sleep, 5)
|
||||||
|
|
||||||
|
with pytest.raises(_cf.TimeoutError): # deprecated: description='aliased to stdlib TimeoutError in 3.11' python_version='3.10'
|
||||||
|
future.result(timeout=1)
|
||||||
|
|
||||||
|
assert future.running() # ensure the future is still going (ie, we didn't have to wait for it to return)
|
||||||
|
|
||||||
|
|
||||||
|
_task_success_msg = "work completed"
|
||||||
|
_process_success_msg = "exit success"
|
||||||
|
_timeout_sec = 3
|
||||||
|
_sleep_time_sec = _timeout_sec * 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_blocking_shutdown() -> None:
|
||||||
|
"""Run with the DaemonThreadPoolExecutor patch disabled to verify that shutdown is blocked by in-flight tasks."""
|
||||||
|
with pytest.raises(subprocess.TimeoutExpired):
|
||||||
|
subprocess.run(args=[sys.executable, __file__, 'block'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True, timeout=_timeout_sec)
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_blocking_shutdown() -> None:
|
||||||
|
"""Run with the DaemonThreadPoolExecutor patch enabled to verify that shutdown is not blocked by in-flight tasks."""
|
||||||
|
cp = subprocess.run(args=[sys.executable, __file__, ''], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True, timeout=_timeout_sec)
|
||||||
|
|
||||||
|
assert _task_success_msg in cp.stdout
|
||||||
|
assert _process_success_msg in cp.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def _run_blocking_exit_test(use_patched: bool) -> None: # pragma: nocover
|
||||||
|
"""Helper for external process integration test."""
|
||||||
|
tpe_type = _futures.DaemonThreadPoolExecutor if use_patched else _cf.ThreadPoolExecutor
|
||||||
|
|
||||||
|
with tpe_type(max_workers=2) as tp:
|
||||||
|
fs_non_blocking = tp.submit(lambda: print(_task_success_msg))
|
||||||
|
assert [tp.submit(time.sleep, _sleep_time_sec) for _idx in range(4)] # not a pointless statement
|
||||||
|
fs_non_blocking.result(timeout=1)
|
||||||
|
|
||||||
|
print(_process_success_msg)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None: # pragma: nocover
|
||||||
|
"""Used by test_(non)blocking_shutdown as a script-style run."""
|
||||||
|
_run_blocking_exit_test(sys.argv[1] != 'block')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__': # pragma: nocover
|
||||||
|
main()
|
Loading…
Reference in New Issue