From e4ee49ff9240a010dcf07cecb6fe50c635a691d9 Mon Sep 17 00:00:00 2001 From: Eric Snow Date: Mon, 5 May 2025 15:49:00 -0600 Subject: [PATCH 01/16] Use _interpreters.call(). --- Lib/concurrent/futures/interpreter.py | 124 ++++++++---------- Lib/test/test_concurrent_futures/test_init.py | 4 + .../test_interpreter_pool.py | 74 +++++++---- 3 files changed, 112 insertions(+), 90 deletions(-) diff --git a/Lib/concurrent/futures/interpreter.py b/Lib/concurrent/futures/interpreter.py index a2c4fbfd3fb831..537fe34171f99e 100644 --- a/Lib/concurrent/futures/interpreter.py +++ b/Lib/concurrent/futures/interpreter.py @@ -45,12 +45,8 @@ def resolve_task(fn, args, kwargs): # XXX Circle back to this later. raise TypeError('scripts not supported') else: - # Functions defined in the __main__ module can't be pickled, - # so they can't be used here. In the future, we could possibly - # borrow from multiprocessing to work around this. task = (fn, args, kwargs) - data = pickle.dumps(task) - return data + return task if initializer is not None: try: @@ -65,35 +61,6 @@ def create_context(): return cls(initdata, shared) return create_context, resolve_task - @classmethod - @contextlib.contextmanager - def _capture_exc(cls, resultsid): - try: - yield - except BaseException as exc: - # Send the captured exception out on the results queue, - # but still leave it unhandled for the interpreter to handle. - _interpqueues.put(resultsid, (None, exc)) - raise # re-raise - - @classmethod - def _send_script_result(cls, resultsid): - _interpqueues.put(resultsid, (None, None)) - - @classmethod - def _call(cls, func, args, kwargs, resultsid): - with cls._capture_exc(resultsid): - res = func(*args or (), **kwargs or {}) - # Send the result back. - with cls._capture_exc(resultsid): - _interpqueues.put(resultsid, (res, None)) - - @classmethod - def _call_pickled(cls, pickled, resultsid): - with cls._capture_exc(resultsid): - fn, args, kwargs = pickle.loads(pickled) - cls._call(fn, args, kwargs, resultsid) - def __init__(self, initdata, shared=None): self.initdata = initdata self.shared = dict(shared) if shared else None @@ -104,11 +71,56 @@ def __del__(self): if self.interpid is not None: self.finalize() - def _exec(self, script): - assert self.interpid is not None - excinfo = _interpreters.exec(self.interpid, script, restrict=True) + def _call(self, fn, args, kwargs): + def do_call(resultsid, func, *args, **kwargs): + try: + return func(*args, **kwargs) + except BaseException as exc: + # Avoid relying on globals. + import _interpreters + import _interpqueues + # Send the captured exception out on the results queue, + # but still leave it unhandled for the interpreter to handle. + try: + _interpqueues.put(resultsid, exc) + except _interpreters.NotShareableError: + # The exception is not shareable. + import sys + import traceback + print('exception is not shareable:', file=sys.stderr) + traceback.print_exception(exc) + _interpqueues.put(resultsid, None) + raise # re-raise + + args = (self.resultsid, fn, *args) + res, excinfo = _interpreters.call(self.interpid, do_call, args, kwargs) if excinfo is not None: raise ExecutionFailed(excinfo) + return res + + def _get_exception(self): + # Wait for the exception data to show up. + while True: + try: + excdata = _interpqueues.get(self.resultsid) + except _interpqueues.QueueNotFoundError: + raise # re-raise + except _interpqueues.QueueError as exc: + if exc.__cause__ is not None or exc.__context__ is not None: + raise # re-raise + if str(exc).endswith(' is empty'): + continue + else: + raise # re-raise + except ModuleNotFoundError: + # interpreters.queues doesn't exist, which means + # QueueEmpty doesn't. Act as though it does. + continue + else: + break + exc, unboundop = excdata + assert unboundop is None, unboundop + return exc def initialize(self): assert self.interpid is None, self.interpid @@ -119,8 +131,6 @@ def initialize(self): maxsize = 0 self.resultsid = _interpqueues.create(maxsize) - self._exec(f'from {__name__} import WorkerContext') - if self.shared: _interpreters.set___main___attrs( self.interpid, self.shared, restrict=True) @@ -148,37 +158,15 @@ def finalize(self): pass def run(self, task): - data = task - script = f'WorkerContext._call_pickled({data!r}, {self.resultsid})' - + fn, args, kwargs = task try: - self._exec(script) - except ExecutionFailed as exc: - exc_wrapper = exc - else: - exc_wrapper = None - - # Return the result, or raise the exception. - while True: - try: - obj = _interpqueues.get(self.resultsid) - except _interpqueues.QueueNotFoundError: + return self._call(fn, args, kwargs) + except ExecutionFailed as wrapper: + exc = self._get_exception() + if exc is None: + # The exception must have been not shareable. raise # re-raise - except _interpqueues.QueueError: - continue - except ModuleNotFoundError: - # interpreters.queues doesn't exist, which means - # QueueEmpty doesn't. Act as though it does. - continue - else: - break - (res, exc), unboundop = obj - assert unboundop is None, unboundop - if exc is not None: - assert res is None, res - assert exc_wrapper is not None - raise exc from exc_wrapper - return res + raise exc from wrapper class BrokenInterpreterPool(_thread.BrokenThreadPool): diff --git a/Lib/test/test_concurrent_futures/test_init.py b/Lib/test/test_concurrent_futures/test_init.py index df640929309318..6b8484c0d5f197 100644 --- a/Lib/test/test_concurrent_futures/test_init.py +++ b/Lib/test/test_concurrent_futures/test_init.py @@ -20,6 +20,10 @@ def init(x): global INITIALIZER_STATUS INITIALIZER_STATUS = x + # InterpreterPoolInitializerTest.test_initializer fails + # if we don't have a LOAD_GLOBAL. (It could be any global.) + # We will address this separately. + INITIALIZER_STATUS def get_init_status(): return INITIALIZER_STATUS diff --git a/Lib/test/test_concurrent_futures/test_interpreter_pool.py b/Lib/test/test_concurrent_futures/test_interpreter_pool.py index f6c62ae4b2021b..c5578c272eb67e 100644 --- a/Lib/test/test_concurrent_futures/test_interpreter_pool.py +++ b/Lib/test/test_concurrent_futures/test_interpreter_pool.py @@ -2,7 +2,7 @@ import contextlib import io import os -import pickle +import select import time import unittest from concurrent.futures.interpreter import ( @@ -22,10 +22,14 @@ def noop(): def write_msg(fd, msg): + import os os.write(fd, msg + b'\0') -def read_msg(fd): +def read_msg(fd, timeout=10.0): + r, _, _ = select.select([fd], [], [], timeout) + if fd not in r: + raise TimeoutError('nothing to read') msg = b'' while ch := os.read(fd, 1): if ch == b'\0': @@ -121,10 +125,19 @@ def init2(): nonlocal count count += 1 - with self.assertRaises(pickle.PicklingError): - self.executor_type(initializer=init1) - with self.assertRaises(pickle.PicklingError): - self.executor_type(initializer=init2) + with contextlib.redirect_stderr(io.StringIO()) as stderr: + with self.executor_type(initializer=init1) as executor: + fut = executor.submit(lambda: None) + self.assertIn('NotShareableError', stderr.getvalue()) + with self.assertRaises(BrokenInterpreterPool): + fut.result() + + with contextlib.redirect_stderr(io.StringIO()) as stderr: + with self.executor_type(initializer=init2) as executor: + fut = executor.submit(lambda: None) + self.assertIn('NotShareableError', stderr.getvalue()) + with self.assertRaises(BrokenInterpreterPool): + fut.result() def test_init_instance_method(self): class Spam: @@ -132,8 +145,12 @@ def initializer(self): raise NotImplementedError spam = Spam() - with self.assertRaises(pickle.PicklingError): - self.executor_type(initializer=spam.initializer) + with contextlib.redirect_stderr(io.StringIO()) as stderr: + with self.executor_type(initializer=spam.initializer) as executor: + fut = executor.submit(lambda: None) + self.assertIn('NotShareableError', stderr.getvalue()) + with self.assertRaises(BrokenInterpreterPool): + fut.result() def test_init_shared(self): msg = b'eggs' @@ -178,8 +195,6 @@ def test_init_exception_in_func(self): stderr = stderr.getvalue() self.assertIn('ExecutionFailed: Exception: spam', stderr) self.assertIn('Uncaught in the interpreter:', stderr) - self.assertIn('The above exception was the direct cause of the following exception:', - stderr) @unittest.expectedFailure def test_submit_script(self): @@ -208,10 +223,14 @@ def task2(): return spam executor = self.executor_type() - with self.assertRaises(pickle.PicklingError): - executor.submit(task1) - with self.assertRaises(pickle.PicklingError): - executor.submit(task2) + + fut = executor.submit(task1) + with self.assertRaises(_interpreters.NotShareableError): + fut.result() + + fut = executor.submit(task2) + with self.assertRaises(_interpreters.NotShareableError): + fut.result() def test_submit_local_instance(self): class Spam: @@ -219,8 +238,9 @@ def __init__(self): self.value = True executor = self.executor_type() - with self.assertRaises(pickle.PicklingError): - executor.submit(Spam) + fut = executor.submit(Spam) + with self.assertRaises(_interpreters.NotShareableError): + fut.result() def test_submit_instance_method(self): class Spam: @@ -229,8 +249,9 @@ def run(self): spam = Spam() executor = self.executor_type() - with self.assertRaises(pickle.PicklingError): - executor.submit(spam.run) + fut = executor.submit(spam.run) + with self.assertRaises(_interpreters.NotShareableError): + fut.result() def test_submit_func_globals(self): executor = self.executor_type() @@ -242,6 +263,7 @@ def test_submit_func_globals(self): @unittest.expectedFailure def test_submit_exception_in_script(self): + # Scripts are not supported currently. fut = self.executor.submit('raise Exception("spam")') with self.assertRaises(Exception) as captured: fut.result() @@ -289,13 +311,21 @@ def test_idle_thread_reuse(self): executor.shutdown(wait=True) def test_pickle_errors_propagate(self): - # GH-125864: Pickle errors happen before the script tries to execute, so the - # queue used to wait infinitely. - + # GH-125864: Pickle errors happen before the script tries to execute, + # so the queue used to wait infinitely. fut = self.executor.submit(PickleShenanigans(0)) - with self.assertRaisesRegex(RuntimeError, "gotcha"): + expected = _interpreters.NotShareableError + with self.assertRaisesRegex(expected, 'unpickled'): fut.result() + def test_no_stale_references(self): + # Weak references don't cross between interpreters. + raise unittest.SkipTest('not applicable') + + def test_free_reference(self): + # Weak references don't cross between interpreters. + raise unittest.SkipTest('not applicable') + class AsyncioTest(InterpretersMixin, testasyncio_utils.TestCase): From ccc135c72ec10c620e9afe3c4d64c4043d10e5a0 Mon Sep 17 00:00:00 2001 From: Eric Snow Date: Tue, 27 May 2025 11:53:55 -0600 Subject: [PATCH 02/16] Do not use select.select() on Windows. --- .../test_interpreter_pool.py | 52 ++++++++++++++++--- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/Lib/test/test_concurrent_futures/test_interpreter_pool.py b/Lib/test/test_concurrent_futures/test_interpreter_pool.py index c5578c272eb67e..d4be9ba77287a5 100644 --- a/Lib/test/test_concurrent_futures/test_interpreter_pool.py +++ b/Lib/test/test_concurrent_futures/test_interpreter_pool.py @@ -2,7 +2,7 @@ import contextlib import io import os -import select +import sys import time import unittest from concurrent.futures.interpreter import ( @@ -17,6 +17,46 @@ from .util import BaseTestCase, InterpreterPoolMixin, setup_module +WINDOWS = sys.platform.startswith('win') + + +@contextlib.contextmanager +def nonblocking(fd): + blocking = os.get_blocking(fd) + if blocking: + os.set_blocking(fd, False) + try: + yield + finally: + if blocking: + os.set_blocking(fd, blocking) + + +def read_file_with_timeout(fd, nbytes, timeout): + with nonblocking(fd): + end = time.time() + timeout + try: + return os.read(fd, nbytes) + except BlockingIOError: + pass + while time.time() < end: + try: + return os.read(fd, nbytes) + except BlockingIOError: + continue + else: + raise TimeoutError('nothing to read') + + +if not WINDOWS: + import select + def read_file_with_timeout(fd, nbytes, timeout): + r, _, _ = select.select([fd], [], [], timeout) + if fd not in r: + raise TimeoutError('nothing to read') + return os.read(fd, nbytes) + + def noop(): pass @@ -27,14 +67,12 @@ def write_msg(fd, msg): def read_msg(fd, timeout=10.0): - r, _, _ = select.select([fd], [], [], timeout) - if fd not in r: - raise TimeoutError('nothing to read') msg = b'' - while ch := os.read(fd, 1): - if ch == b'\0': - return msg + ch = read_file_with_timeout(fd, 1, timeout) + while ch != b'\0': msg += ch + ch = os.read(fd, 1) + return msg def get_current_name(): From da936a6be1e53e541b94901c2bcd732828205d43 Mon Sep 17 00:00:00 2001 From: Eric Snow Date: Tue, 3 Jun 2025 18:13:05 -0600 Subject: [PATCH 03/16] Keep a cache of loaded module namespaces. --- .../test_interpreter_pool.py | 34 +++++++++ Python/crossinterp.c | 74 +++++++++++++++---- 2 files changed, 95 insertions(+), 13 deletions(-) diff --git a/Lib/test/test_concurrent_futures/test_interpreter_pool.py b/Lib/test/test_concurrent_futures/test_interpreter_pool.py index d4be9ba77287a5..54ee96501eee7a 100644 --- a/Lib/test/test_concurrent_futures/test_interpreter_pool.py +++ b/Lib/test/test_concurrent_futures/test_interpreter_pool.py @@ -10,6 +10,8 @@ ) import _interpreters from test import support +from test.support import os_helper +from test.support import script_helper import test.test_asyncio.utils as testasyncio_utils from test.support.interpreters import queues @@ -155,6 +157,38 @@ def test_init_func(self): self.assertEqual(before, b'\0') self.assertEqual(after, msg) + def test_init_with___main___global(self): + # See https://github.com/python/cpython/pull/133957#issuecomment-2927415311. + text = """if True: + from concurrent.futures import InterpreterPoolExecutor + + INITIALIZER_STATUS = 'uninitialized' + + def init(x): + global INITIALIZER_STATUS + INITIALIZER_STATUS = x + INITIALIZER_STATUS + + def get_init_status(): + return INITIALIZER_STATUS + + if __name__ == "__main__": + exe = InterpreterPoolExecutor(initializer=init, + initargs=('initialized',)) + fut = exe.submit(get_init_status) + print(fut.result()) # 'initialized' + exe.shutdown(wait=True) + print(INITIALIZER_STATUS) # 'uninitialized' + """ + with os_helper.temp_dir() as tempdir: + filename = script_helper.make_script(tempdir, 'my-script', text) + res = script_helper.assert_python_ok(filename) + stdout = res.out.decode('utf-8').strip() + self.assertEqual(stdout.splitlines(), [ + 'initialized', + 'uninitialized', + ]) + def test_init_closure(self): count = 0 def init1(): diff --git a/Python/crossinterp.c b/Python/crossinterp.c index 5e73ab28f2b663..bfb14f2cf88b6b 100644 --- a/Python/crossinterp.c +++ b/Python/crossinterp.c @@ -540,6 +540,50 @@ sync_module_clear(struct sync_module *data) } +static PyObject * +get_cached_module_ns(PyThreadState *tstate, + const char *modname, const char *filename) +{ + // Load the module from the original file. + assert(filename != NULL); + PyObject *loaded = NULL; + + const char *run_modname = modname; + if (strcmp(modname, "__main__") == 0) { + // We don't want to trigger "if __name__ == '__main__':". + run_modname = ""; + } + + // First try the per-interpreter cache. + PyObject *interpns = PyInterpreterState_GetDict(tstate->interp); + assert(interpns != NULL); + PyObject *key = PyUnicode_FromFormat("CACHED_MODULE_NS_%s", modname); + if (key == NULL) { + return NULL; + } + if (PyDict_GetItemRef(interpns, key, &loaded) < 0) { + goto finally; + } + if (loaded != NULL) { + goto finally; + } + + // It wasn't already loaded from file. + loaded = runpy_run_path(filename, run_modname); + if (loaded == NULL) { + goto finally; + } + if (PyDict_SetItem(interpns, key, loaded) < 0) { + Py_CLEAR(loaded); + goto finally; + } + +finally: + Py_DECREF(key); + return loaded; +} + + struct _unpickle_context { PyThreadState *tstate; // We only special-case the __main__ module, @@ -574,37 +618,40 @@ _unpickle_context_set_module(struct _unpickle_context *ctx, struct sync_module_result res = {0}; struct sync_module_result *cached = NULL; const char *filename = NULL; - const char *run_modname = modname; if (strcmp(modname, "__main__") == 0) { cached = &ctx->main.cached; filename = ctx->main.filename; - // We don't want to trigger "if __name__ == '__main__':". - run_modname = ""; } else { res.failed = PyExc_NotImplementedError; - goto finally; + goto error; } res.module = import_get_module(ctx->tstate, modname); if (res.module == NULL) { - res.failed = _PyErr_GetRaisedException(ctx->tstate); - assert(res.failed != NULL); - goto finally; + goto error; } + // Load the module ns from the original file and cache it. + // Note that functions will use the cached ns for __globals__, + // not res.module. if (filename == NULL) { - Py_CLEAR(res.module); res.failed = PyExc_NotImplementedError; - goto finally; + goto error; } - res.loaded = runpy_run_path(filename, run_modname); + res.loaded = get_cached_module_ns(ctx->tstate, modname, filename); if (res.loaded == NULL) { - Py_CLEAR(res.module); + goto error; + } + goto finally; + +error: + Py_CLEAR(res.module); + if (res.failed == NULL) { res.failed = _PyErr_GetRaisedException(ctx->tstate); assert(res.failed != NULL); - goto finally; } + assert(!_PyErr_Occurred(ctx->tstate)); finally: if (cached != NULL) { @@ -629,7 +676,8 @@ _handle_unpickle_missing_attr(struct _unpickle_context *ctx, PyObject *exc) } // Get the module. - struct sync_module_result mod = _unpickle_context_get_module(ctx, info.modname); + struct sync_module_result mod = + _unpickle_context_get_module(ctx, info.modname); if (mod.failed != NULL) { // It must have failed previously. return -1; From f1b932a5a67cec927a6ecf53aeba446ae4d2a296 Mon Sep 17 00:00:00 2001 From: Eric Snow Date: Wed, 4 Jun 2025 15:04:56 -0600 Subject: [PATCH 04/16] Drop some outdated notes. --- Doc/library/concurrent.futures.rst | 8 -------- 1 file changed, 8 deletions(-) diff --git a/Doc/library/concurrent.futures.rst b/Doc/library/concurrent.futures.rst index 3c8d9ab111e09e..3a0ee398a6b119 100644 --- a/Doc/library/concurrent.futures.rst +++ b/Doc/library/concurrent.futures.rst @@ -304,10 +304,6 @@ the bytes over a shared :mod:`socket ` or and *initargs* using :mod:`pickle` when sending them to the worker's interpreter. - .. note:: - Functions defined in the ``__main__`` module cannot be pickled - and thus cannot be used. - .. note:: The executor may replace uncaught exceptions from *initializer* with :class:`~concurrent.futures.interpreter.ExecutionFailed`. @@ -326,10 +322,6 @@ except the worker serializes the callable and arguments using :mod:`pickle` when sending them to its interpreter. The worker likewise serializes the return value when sending it back. -.. note:: - Functions defined in the ``__main__`` module cannot be pickled - and thus cannot be used. - When a worker's current task raises an uncaught exception, the worker always tries to preserve the exception as-is. If that is successful then it also sets the ``__cause__`` to a corresponding From 39c67681d4bf53b17f598066d741240bed4280d2 Mon Sep 17 00:00:00 2001 From: Eric Snow Date: Wed, 4 Jun 2025 15:05:08 -0600 Subject: [PATCH 05/16] Fix a typo. --- Doc/library/concurrent.futures.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/library/concurrent.futures.rst b/Doc/library/concurrent.futures.rst index 3a0ee398a6b119..c8c8a45623ef6b 100644 --- a/Doc/library/concurrent.futures.rst +++ b/Doc/library/concurrent.futures.rst @@ -265,7 +265,7 @@ Each worker's interpreter is isolated from all the other interpreters. "Isolated" means each interpreter has its own runtime state and operates completely independently. For example, if you redirect :data:`sys.stdout` in one interpreter, it will not be automatically -redirected any other interpreter. If you import a module in one +redirected to any other interpreter. If you import a module in one interpreter, it is not automatically imported in any other. You would need to import the module separately in interpreter where you need it. In fact, each module imported in an interpreter is From fd94a4265edbcef0c1619c5fa12feeebf9cd17dd Mon Sep 17 00:00:00 2001 From: Eric Snow Date: Thu, 5 Jun 2025 13:10:28 -0600 Subject: [PATCH 06/16] Drop the "shared" parameter. --- Doc/library/concurrent.futures.rst | 9 +--- Lib/concurrent/futures/interpreter.py | 21 +++------ .../test_interpreter_pool.py | 47 ++++++++++--------- 3 files changed, 34 insertions(+), 43 deletions(-) diff --git a/Doc/library/concurrent.futures.rst b/Doc/library/concurrent.futures.rst index c8c8a45623ef6b..dd92765038c4f7 100644 --- a/Doc/library/concurrent.futures.rst +++ b/Doc/library/concurrent.futures.rst @@ -287,7 +287,7 @@ efficient alternative is to serialize with :mod:`pickle` and then send the bytes over a shared :mod:`socket ` or :func:`pipe `. -.. class:: InterpreterPoolExecutor(max_workers=None, thread_name_prefix='', initializer=None, initargs=(), shared=None) +.. class:: InterpreterPoolExecutor(max_workers=None, thread_name_prefix='', initializer=None, initargs=()) A :class:`ThreadPoolExecutor` subclass that executes calls asynchronously using a pool of at most *max_workers* threads. Each thread runs @@ -308,13 +308,6 @@ the bytes over a shared :mod:`socket ` or The executor may replace uncaught exceptions from *initializer* with :class:`~concurrent.futures.interpreter.ExecutionFailed`. - The optional *shared* argument is a :class:`dict` of objects that all - interpreters in the pool share. The *shared* items are added to each - interpreter's ``__main__`` module. Not all objects are shareable. - Shareable objects include the builtin singletons, :class:`str` - and :class:`bytes`, and :class:`memoryview`. See :pep:`734` - for more info. - Other caveats from parent :class:`ThreadPoolExecutor` apply here. :meth:`~Executor.submit` and :meth:`~Executor.map` work like normal, diff --git a/Lib/concurrent/futures/interpreter.py b/Lib/concurrent/futures/interpreter.py index 537fe34171f99e..8dcc36f96afe41 100644 --- a/Lib/concurrent/futures/interpreter.py +++ b/Lib/concurrent/futures/interpreter.py @@ -39,7 +39,7 @@ def __str__(self): class WorkerContext(_thread.WorkerContext): @classmethod - def prepare(cls, initializer, initargs, shared): + def prepare(cls, initializer, initargs): def resolve_task(fn, args, kwargs): if isinstance(fn, str): # XXX Circle back to this later. @@ -58,12 +58,11 @@ def resolve_task(fn, args, kwargs): else: initdata = None def create_context(): - return cls(initdata, shared) + return cls(initdata) return create_context, resolve_task - def __init__(self, initdata, shared=None): + def __init__(self, initdata): self.initdata = initdata - self.shared = dict(shared) if shared else None self.interpid = None self.resultsid = None @@ -131,10 +130,6 @@ def initialize(self): maxsize = 0 self.resultsid = _interpqueues.create(maxsize) - if self.shared: - _interpreters.set___main___attrs( - self.interpid, self.shared, restrict=True) - if self.initdata: self.run(self.initdata) except BaseException: @@ -180,11 +175,11 @@ class InterpreterPoolExecutor(_thread.ThreadPoolExecutor): BROKEN = BrokenInterpreterPool @classmethod - def prepare_context(cls, initializer, initargs, shared): - return WorkerContext.prepare(initializer, initargs, shared) + def prepare_context(cls, initializer, initargs): + return WorkerContext.prepare(initializer, initargs) def __init__(self, max_workers=None, thread_name_prefix='', - initializer=None, initargs=(), shared=None): + initializer=None, initargs=()): """Initializes a new InterpreterPoolExecutor instance. Args: @@ -194,8 +189,6 @@ def __init__(self, max_workers=None, thread_name_prefix='', initializer: A callable or script used to initialize each worker interpreter. initargs: A tuple of arguments to pass to the initializer. - shared: A mapping of shareabled objects to be inserted into - each worker interpreter. """ super().__init__(max_workers, thread_name_prefix, - initializer, initargs, shared=shared) + initializer, initargs) diff --git a/Lib/test/test_concurrent_futures/test_interpreter_pool.py b/Lib/test/test_concurrent_futures/test_interpreter_pool.py index 54ee96501eee7a..8a222f8d122b7a 100644 --- a/Lib/test/test_concurrent_futures/test_interpreter_pool.py +++ b/Lib/test/test_concurrent_futures/test_interpreter_pool.py @@ -224,24 +224,6 @@ def initializer(self): with self.assertRaises(BrokenInterpreterPool): fut.result() - def test_init_shared(self): - msg = b'eggs' - r, w = self.pipe() - script = f"""if True: - import os - if __name__ != '__main__': - import __main__ - spam = __main__.spam - os.write({w}, spam + b'\\0') - """ - - executor = self.executor_type(shared={'spam': msg}) - fut = executor.submit(exec, script) - fut.result() - after = read_msg(r) - - self.assertEqual(after, msg) - @unittest.expectedFailure def test_init_exception_in_script(self): executor = self.executor_type(initializer='raise Exception("spam")') @@ -363,16 +345,39 @@ def test_submit_exception_in_func(self): def test_saturation(self): blocker = queues.create() - executor = self.executor_type(4, shared=dict(blocker=blocker)) + executor = self.executor_type(4) for i in range(15 * executor._max_workers): - executor.submit(exec, 'import __main__; __main__.blocker.get()') - #executor.submit('blocker.get()') + executor.submit(blocker.get) self.assertEqual(len(executor._threads), executor._max_workers) for i in range(15 * executor._max_workers): blocker.put_nowait(None) executor.shutdown(wait=True) + def test_blocking(self): + ready = queues.create() + blocker = queues.create() + + def run(ready, blocker): + ready.put(None) + blocker.get() # blocking + + numtasks = 10 + futures = [] + executor = self.executor_type() + try: + for i in range(numtasks): + fut = executor.submit(run, ready, blocker) + futures.append(fut) + # Wait for them all to be ready. + for i in range(numtasks): + ready.get() # blocking + # Unblock the workers. + for i in range(numtasks): + blocker.put_nowait(None) + finally: + executor.shutdown(wait=True) + @support.requires_gil_enabled("gh-117344: test is flaky without the GIL") def test_idle_thread_reuse(self): executor = self.executor_type() From cfc856646465f1ff2abb772ca61a3aa9654ac19f Mon Sep 17 00:00:00 2001 From: Eric Snow Date: Mon, 16 Jun 2025 17:02:13 -0600 Subject: [PATCH 07/16] Fix a test. --- Lib/test/test_concurrent_futures/test_interpreter_pool.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_concurrent_futures/test_interpreter_pool.py b/Lib/test/test_concurrent_futures/test_interpreter_pool.py index 075944e763d841..85674eb44b9c40 100644 --- a/Lib/test/test_concurrent_futures/test_interpreter_pool.py +++ b/Lib/test/test_concurrent_futures/test_interpreter_pool.py @@ -8,6 +8,7 @@ from concurrent.futures.interpreter import ( ExecutionFailed, BrokenInterpreterPool, ) +from concurrent import interpreters from concurrent.interpreters import _queues as queues import _interpreters from test import support @@ -391,9 +392,10 @@ def test_pickle_errors_propagate(self): # GH-125864: Pickle errors happen before the script tries to execute, # so the queue used to wait infinitely. fut = self.executor.submit(PickleShenanigans(0)) - expected = _interpreters.NotShareableError - with self.assertRaisesRegex(expected, 'unpickled'): + expected = interpreters.NotShareableError + with self.assertRaisesRegex(expected, 'args not shareable') as cm: fut.result() + self.assertRegex(str(cm.exception.__cause__), 'unpickled') def test_no_stale_references(self): # Weak references don't cross between interpreters. From 35076d9a80bbb04bd24cbbc1562800b5b854e955 Mon Sep 17 00:00:00 2001 From: Eric Snow Date: Mon, 16 Jun 2025 17:27:28 -0600 Subject: [PATCH 08/16] Use interpreters instead of _interpreters. --- Lib/concurrent/futures/interpreter.py | 140 +++++------------- .../test_interpreter_pool.py | 8 +- 2 files changed, 37 insertions(+), 111 deletions(-) diff --git a/Lib/concurrent/futures/interpreter.py b/Lib/concurrent/futures/interpreter.py index 8dcc36f96afe41..cbb60ce80c1813 100644 --- a/Lib/concurrent/futures/interpreter.py +++ b/Lib/concurrent/futures/interpreter.py @@ -1,39 +1,26 @@ """Implements InterpreterPoolExecutor.""" -import contextlib -import pickle +from concurrent import interpreters +import sys import textwrap from . import thread as _thread -import _interpreters -import _interpqueues +import traceback -class ExecutionFailed(_interpreters.InterpreterError): - """An unhandled exception happened during execution.""" - - def __init__(self, excinfo): - msg = excinfo.formatted - if not msg: - if excinfo.type and excinfo.msg: - msg = f'{excinfo.type.__name__}: {excinfo.msg}' - else: - msg = excinfo.type.__name__ or excinfo.msg - super().__init__(msg) - self.excinfo = excinfo - - def __str__(self): +def do_call(results, func, args, kwargs): + try: + return func(*args, **kwargs) + except BaseException as exc: + # Send the captured exception out on the results queue, + # but still leave it unhandled for the interpreter to handle. try: - formatted = self.excinfo.errdisplay - except Exception: - return super().__str__() - else: - return textwrap.dedent(f""" -{super().__str__()} - -Uncaught in the interpreter: - -{formatted} - """.strip()) + results.put(exc) + except interpreters.NotShareableError: + # The exception is not shareable. + print('exception is not shareable:', file=sys.stderr) + traceback.print_exception(exc) + results.put(None) + raise # re-raise class WorkerContext(_thread.WorkerContext): @@ -63,72 +50,19 @@ def create_context(): def __init__(self, initdata): self.initdata = initdata - self.interpid = None - self.resultsid = None + self.interp = None + self.results = None def __del__(self): - if self.interpid is not None: + if self.interp is not None: self.finalize() - def _call(self, fn, args, kwargs): - def do_call(resultsid, func, *args, **kwargs): - try: - return func(*args, **kwargs) - except BaseException as exc: - # Avoid relying on globals. - import _interpreters - import _interpqueues - # Send the captured exception out on the results queue, - # but still leave it unhandled for the interpreter to handle. - try: - _interpqueues.put(resultsid, exc) - except _interpreters.NotShareableError: - # The exception is not shareable. - import sys - import traceback - print('exception is not shareable:', file=sys.stderr) - traceback.print_exception(exc) - _interpqueues.put(resultsid, None) - raise # re-raise - - args = (self.resultsid, fn, *args) - res, excinfo = _interpreters.call(self.interpid, do_call, args, kwargs) - if excinfo is not None: - raise ExecutionFailed(excinfo) - return res - - def _get_exception(self): - # Wait for the exception data to show up. - while True: - try: - excdata = _interpqueues.get(self.resultsid) - except _interpqueues.QueueNotFoundError: - raise # re-raise - except _interpqueues.QueueError as exc: - if exc.__cause__ is not None or exc.__context__ is not None: - raise # re-raise - if str(exc).endswith(' is empty'): - continue - else: - raise # re-raise - except ModuleNotFoundError: - # interpreters.queues doesn't exist, which means - # QueueEmpty doesn't. Act as though it does. - continue - else: - break - exc, unboundop = excdata - assert unboundop is None, unboundop - return exc - def initialize(self): - assert self.interpid is None, self.interpid - self.interpid = _interpreters.create(reqrefs=True) + assert self.interp is None, self.interp + self.interp = interpreters.create() try: - _interpreters.incref(self.interpid) - maxsize = 0 - self.resultsid = _interpqueues.create(maxsize) + self.results = interpreters.create_queue(maxsize) if self.initdata: self.run(self.initdata) @@ -137,27 +71,21 @@ def initialize(self): raise # re-raise def finalize(self): - interpid = self.interpid - resultsid = self.resultsid - self.resultsid = None - self.interpid = None - if resultsid is not None: - try: - _interpqueues.destroy(resultsid) - except _interpqueues.QueueNotFoundError: - pass - if interpid is not None: - try: - _interpreters.decref(interpid) - except _interpreters.InterpreterNotFoundError: - pass + interp = self.interp + results = self.results + self.results = None + self.interp = None + if results is not None: + del results + if interp is not None: + interp.close() def run(self, task): - fn, args, kwargs = task try: - return self._call(fn, args, kwargs) - except ExecutionFailed as wrapper: - exc = self._get_exception() + return self.interp.call(do_call, self.results, *task) + except interpreters.ExecutionFailed as wrapper: + # Wait for the exception data to show up. + exc = self.results.get() if exc is None: # The exception must have been not shareable. raise # re-raise diff --git a/Lib/test/test_concurrent_futures/test_interpreter_pool.py b/Lib/test/test_concurrent_futures/test_interpreter_pool.py index 85674eb44b9c40..8e19701b8d8bab 100644 --- a/Lib/test/test_concurrent_futures/test_interpreter_pool.py +++ b/Lib/test/test_concurrent_futures/test_interpreter_pool.py @@ -5,9 +5,7 @@ import sys import time import unittest -from concurrent.futures.interpreter import ( - ExecutionFailed, BrokenInterpreterPool, -) +from concurrent.futures.interpreter import BrokenInterpreterPool from concurrent import interpreters from concurrent.interpreters import _queues as queues import _interpreters @@ -325,7 +323,7 @@ def test_submit_exception_in_script(self): self.assertIs(type(captured.exception), Exception) self.assertEqual(str(captured.exception), 'spam') cause = captured.exception.__cause__ - self.assertIs(type(cause), ExecutionFailed) + self.assertIs(type(cause), interpreters.ExecutionFailed) for attr in ('__name__', '__qualname__', '__module__'): self.assertEqual(getattr(cause.excinfo.type, attr), getattr(Exception, attr)) @@ -338,7 +336,7 @@ def test_submit_exception_in_func(self): self.assertIs(type(captured.exception), Exception) self.assertEqual(str(captured.exception), 'spam') cause = captured.exception.__cause__ - self.assertIs(type(cause), ExecutionFailed) + self.assertIs(type(cause), interpreters.ExecutionFailed) for attr in ('__name__', '__qualname__', '__module__'): self.assertEqual(getattr(cause.excinfo.type, attr), getattr(Exception, attr)) From 9efe8056fc58ad02cf511840e57e9a88983c1824 Mon Sep 17 00:00:00 2001 From: Eric Snow Date: Tue, 17 Jun 2025 13:35:55 -0600 Subject: [PATCH 09/16] Revert "Keep a cache of loaded module namespaces." This reverts commit da936a6be1e53e541b94901c2bcd732828205d43. --- Python/crossinterp.c | 74 ++++++++------------------------------------ 1 file changed, 13 insertions(+), 61 deletions(-) diff --git a/Python/crossinterp.c b/Python/crossinterp.c index 43c87115c0bc83..0bd267e07d5f2b 100644 --- a/Python/crossinterp.c +++ b/Python/crossinterp.c @@ -540,50 +540,6 @@ sync_module_clear(struct sync_module *data) } -static PyObject * -get_cached_module_ns(PyThreadState *tstate, - const char *modname, const char *filename) -{ - // Load the module from the original file. - assert(filename != NULL); - PyObject *loaded = NULL; - - const char *run_modname = modname; - if (strcmp(modname, "__main__") == 0) { - // We don't want to trigger "if __name__ == '__main__':". - run_modname = ""; - } - - // First try the per-interpreter cache. - PyObject *interpns = PyInterpreterState_GetDict(tstate->interp); - assert(interpns != NULL); - PyObject *key = PyUnicode_FromFormat("CACHED_MODULE_NS_%s", modname); - if (key == NULL) { - return NULL; - } - if (PyDict_GetItemRef(interpns, key, &loaded) < 0) { - goto finally; - } - if (loaded != NULL) { - goto finally; - } - - // It wasn't already loaded from file. - loaded = runpy_run_path(filename, run_modname); - if (loaded == NULL) { - goto finally; - } - if (PyDict_SetItem(interpns, key, loaded) < 0) { - Py_CLEAR(loaded); - goto finally; - } - -finally: - Py_DECREF(key); - return loaded; -} - - struct _unpickle_context { PyThreadState *tstate; // We only special-case the __main__ module, @@ -618,40 +574,37 @@ _unpickle_context_set_module(struct _unpickle_context *ctx, struct sync_module_result res = {0}; struct sync_module_result *cached = NULL; const char *filename = NULL; + const char *run_modname = modname; if (strcmp(modname, "__main__") == 0) { cached = &ctx->main.cached; filename = ctx->main.filename; + // We don't want to trigger "if __name__ == '__main__':". + run_modname = ""; } else { res.failed = PyExc_NotImplementedError; - goto error; + goto finally; } res.module = import_get_module(ctx->tstate, modname); if (res.module == NULL) { - goto error; + res.failed = _PyErr_GetRaisedException(ctx->tstate); + assert(res.failed != NULL); + goto finally; } - // Load the module ns from the original file and cache it. - // Note that functions will use the cached ns for __globals__, - // not res.module. if (filename == NULL) { + Py_CLEAR(res.module); res.failed = PyExc_NotImplementedError; - goto error; + goto finally; } - res.loaded = get_cached_module_ns(ctx->tstate, modname, filename); + res.loaded = runpy_run_path(filename, run_modname); if (res.loaded == NULL) { - goto error; - } - goto finally; - -error: - Py_CLEAR(res.module); - if (res.failed == NULL) { + Py_CLEAR(res.module); res.failed = _PyErr_GetRaisedException(ctx->tstate); assert(res.failed != NULL); + goto finally; } - assert(!_PyErr_Occurred(ctx->tstate)); finally: if (cached != NULL) { @@ -676,8 +629,7 @@ _handle_unpickle_missing_attr(struct _unpickle_context *ctx, PyObject *exc) } // Get the module. - struct sync_module_result mod = - _unpickle_context_get_module(ctx, info.modname); + struct sync_module_result mod = _unpickle_context_get_module(ctx, info.modname); if (mod.failed != NULL) { // It must have failed previously. return -1; From 9a8dcdd8abfe5f88b9de2a922543e7f70645c3be Mon Sep 17 00:00:00 2001 From: Eric Snow Date: Tue, 17 Jun 2025 13:46:07 -0600 Subject: [PATCH 10/16] Fix an error case. --- Python/crossinterp.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Python/crossinterp.c b/Python/crossinterp.c index aa958fe2f5cbd7..c44bcd559000b3 100644 --- a/Python/crossinterp.c +++ b/Python/crossinterp.c @@ -674,7 +674,9 @@ _PyPickle_Loads(struct _unpickle_context *ctx, PyObject *pickled) finally: if (exc != NULL) { - sync_module_capture_exc(tstate, &ctx->main); + if (_PyErr_Occurred(tstate)) { + sync_module_capture_exc(tstate, &ctx->main); + } // We restore the original exception. // It might make sense to chain it (__context__). _PyErr_SetRaisedException(tstate, exc); From 605c802686c91d68812df7473a554d187b172d35 Mon Sep 17 00:00:00 2001 From: Eric Snow Date: Tue, 17 Jun 2025 14:09:56 -0600 Subject: [PATCH 11/16] [debugging via CI] Temporarily raise with the queue IDs. --- .../test_interpreter_pool.py | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/Lib/test/test_concurrent_futures/test_interpreter_pool.py b/Lib/test/test_concurrent_futures/test_interpreter_pool.py index 8e19701b8d8bab..88ed972b603d06 100644 --- a/Lib/test/test_concurrent_futures/test_interpreter_pool.py +++ b/Lib/test/test_concurrent_futures/test_interpreter_pool.py @@ -358,6 +358,7 @@ def test_blocking(self): blocker = queues.create() def run(ready, blocker): + raise Exception((ready.id, blocker.id)) ready.put(None) blocker.get() # blocking @@ -368,12 +369,24 @@ def run(ready, blocker): for i in range(numtasks): fut = executor.submit(run, ready, blocker) futures.append(fut) - # Wait for them all to be ready. - for i in range(numtasks): - ready.get() # blocking - # Unblock the workers. - for i in range(numtasks): - blocker.put_nowait(None) +# assert len(executor._threads) == numtasks, len(executor._threads) + ctx = None + for i, fut in enumerate(futures, 1): + try: + fut.result(timeout=10) + except Exception as exc: + exc.__cause__ = ctx + ctx = exc + if i == numtasks: + raise Exception((ready.id, blocker.id)) +# try: +# # Wait for them all to be ready. +# for i in range(numtasks): +# ready.get() # blocking +# finally: +# # Unblock the workers. +# for i in range(numtasks): +# blocker.put_nowait(None) finally: executor.shutdown(wait=True) From 755770cdea65859ac640c67cf5c1f899dc39894c Mon Sep 17 00:00:00 2001 From: Eric Snow Date: Tue, 17 Jun 2025 15:07:16 -0600 Subject: [PATCH 12/16] [debugging via CI] Temporarily raise with the task ID. --- .../test_interpreter_pool.py | 45 +++++++++++-------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/Lib/test/test_concurrent_futures/test_interpreter_pool.py b/Lib/test/test_concurrent_futures/test_interpreter_pool.py index 88ed972b603d06..241d375d0be0a3 100644 --- a/Lib/test/test_concurrent_futures/test_interpreter_pool.py +++ b/Lib/test/test_concurrent_futures/test_interpreter_pool.py @@ -357,36 +357,45 @@ def test_blocking(self): ready = queues.create() blocker = queues.create() - def run(ready, blocker): - raise Exception((ready.id, blocker.id)) - ready.put(None) - blocker.get() # blocking + def run(taskid, ready, blocker): + ready.put_nowait(taskid) + raise Exception(taskid) + blocker.get(timeout=10) # blocking numtasks = 10 futures = [] executor = self.executor_type() try: for i in range(numtasks): - fut = executor.submit(run, ready, blocker) + fut = executor.submit(run, i, ready, blocker) futures.append(fut) # assert len(executor._threads) == numtasks, len(executor._threads) - ctx = None + exceptions1 = [] for i, fut in enumerate(futures, 1): try: fut.result(timeout=10) except Exception as exc: - exc.__cause__ = ctx - ctx = exc - if i == numtasks: - raise Exception((ready.id, blocker.id)) -# try: -# # Wait for them all to be ready. -# for i in range(numtasks): -# ready.get() # blocking -# finally: -# # Unblock the workers. -# for i in range(numtasks): -# blocker.put_nowait(None) + exceptions1.append(exc) + exceptions2 = [] + try: + # Wait for them all to be ready. + for i in range(numtasks): + try: + ready.get(timeout=10) # blocking + except interpreters.QueueEmpty as exc: + exceptions2.append(exc) + finally: + # Unblock the workers. + for i in range(numtasks): + blocker.put_nowait(None) + group1 = ExceptionGroup('futures', exceptions1) if exceptions1 else None + group2 = ExceptionGroup('ready', exceptions2) if exceptions2 else None + if group2: + group2.__cause__ = group1 + raise group2 + elif group1: + raise group1 + raise group finally: executor.shutdown(wait=True) From cce4c751939607b3bbd0b550061f6036d5eedd50 Mon Sep 17 00:00:00 2001 From: Eric Snow Date: Tue, 17 Jun 2025 17:47:10 -0600 Subject: [PATCH 13/16] [debugging via CI] Temporarily use timeouts. --- .../test_interpreter_pool.py | 81 ++++++++++++++----- 1 file changed, 61 insertions(+), 20 deletions(-) diff --git a/Lib/test/test_concurrent_futures/test_interpreter_pool.py b/Lib/test/test_concurrent_futures/test_interpreter_pool.py index 241d375d0be0a3..d245d57bb84065 100644 --- a/Lib/test/test_concurrent_futures/test_interpreter_pool.py +++ b/Lib/test/test_concurrent_futures/test_interpreter_pool.py @@ -4,6 +4,7 @@ import os import sys import time +from threading import Thread import unittest from concurrent.futures.interpreter import BrokenInterpreterPool from concurrent import interpreters @@ -359,45 +360,85 @@ def test_blocking(self): def run(taskid, ready, blocker): ready.put_nowait(taskid) - raise Exception(taskid) blocker.get(timeout=10) # blocking +# blocker.get() # blocking numtasks = 10 futures = [] executor = self.executor_type() try: + # Request the jobs. for i in range(numtasks): fut = executor.submit(run, i, ready, blocker) futures.append(fut) # assert len(executor._threads) == numtasks, len(executor._threads) - exceptions1 = [] - for i, fut in enumerate(futures, 1): - try: - fut.result(timeout=10) - except Exception as exc: - exceptions1.append(exc) - exceptions2 = [] + try: # Wait for them all to be ready. - for i in range(numtasks): + pending = numtasks + def wait_for_ready(): + nonlocal pending try: ready.get(timeout=10) # blocking - except interpreters.QueueEmpty as exc: - exceptions2.append(exc) + except interpreters.QueueEmpty: + pass + else: + pending -= 1 + threads = [Thread(target=wait_for_ready) + for _ in range(pending)] + for t in threads: + t.start() + for t in threads: + t.join() + if pending: + if pending < numtasks: + # At least one was ready, so wait longer. + for _ in range(pending): + ready.get() # blocking + else: + # Something is probably wrong. Bail out. + group = [] + for fut in futures: + try: + fut.result(timeout=0) + except TimeoutError: + # Still running. + try: + ready.get_nowait() + except interpreters.QueueEmpty as exc: + # It's hung. + group.append(exc) + else: + pending -= 1 + except Exception as exc: + group.append(exc) + if group: + raise ExceptionGroup('futures', group) + assert not pending, pending +# for _ in range(numtasks): +# ready.get() # blocking finally: # Unblock the workers. for i in range(numtasks): blocker.put_nowait(None) - group1 = ExceptionGroup('futures', exceptions1) if exceptions1 else None - group2 = ExceptionGroup('ready', exceptions2) if exceptions2 else None - if group2: - group2.__cause__ = group1 - raise group2 - elif group1: - raise group1 - raise group + + # Make sure they finished. + group = [] + def wait_for_done(fut): + try: + fut.result(timeout=10) + except Exception as exc: + group.append(exc) + threads = [Thread(target=wait_for_done, args=(fut,)) + for fut in futures] + for t in threads: + t.start() + for t in threads: + t.join() + if group: + raise ExceptionGroup('futures', group) finally: - executor.shutdown(wait=True) + executor.shutdown(wait=False) @support.requires_gil_enabled("gh-117344: test is flaky without the GIL") def test_idle_thread_reuse(self): From 29e83cc97b2c678b79f47169bb127bc37da3e1eb Mon Sep 17 00:00:00 2001 From: Eric Snow Date: Tue, 17 Jun 2025 18:11:08 -0600 Subject: [PATCH 14/16] Do not use a timeout for the blocker. --- Lib/test/test_concurrent_futures/test_interpreter_pool.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_concurrent_futures/test_interpreter_pool.py b/Lib/test/test_concurrent_futures/test_interpreter_pool.py index d245d57bb84065..71ba07f923c1b2 100644 --- a/Lib/test/test_concurrent_futures/test_interpreter_pool.py +++ b/Lib/test/test_concurrent_futures/test_interpreter_pool.py @@ -360,8 +360,8 @@ def test_blocking(self): def run(taskid, ready, blocker): ready.put_nowait(taskid) - blocker.get(timeout=10) # blocking -# blocker.get() # blocking +# blocker.get(timeout=20) # blocking + blocker.get() # blocking numtasks = 10 futures = [] From b4f4ae6808a8f9ad2dbe5873763460140463e576 Mon Sep 17 00:00:00 2001 From: Eric Snow Date: Wed, 18 Jun 2025 10:52:20 -0600 Subject: [PATCH 15/16] [debugging via CI] Temporarily add some logging. --- Lib/test/test_concurrent_futures/test_interpreter_pool.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Lib/test/test_concurrent_futures/test_interpreter_pool.py b/Lib/test/test_concurrent_futures/test_interpreter_pool.py index 71ba07f923c1b2..77ed0734a81987 100644 --- a/Lib/test/test_concurrent_futures/test_interpreter_pool.py +++ b/Lib/test/test_concurrent_futures/test_interpreter_pool.py @@ -359,9 +359,12 @@ def test_blocking(self): blocker = queues.create() def run(taskid, ready, blocker): + print(f'{taskid}: starting', flush=True) ready.put_nowait(taskid) + print(f'{taskid}: ready', flush=True) # blocker.get(timeout=20) # blocking blocker.get() # blocking + print(f'{taskid}: done', flush=True) numtasks = 10 futures = [] From 2e514fc5c6047f93d02a3b874a3e213ed646142b Mon Sep 17 00:00:00 2001 From: Eric Snow Date: Wed, 18 Jun 2025 17:25:14 -0600 Subject: [PATCH 16/16] Do not assume one-worker-per-task. --- .../test_interpreter_pool.py | 124 ++++++++---------- 1 file changed, 56 insertions(+), 68 deletions(-) diff --git a/Lib/test/test_concurrent_futures/test_interpreter_pool.py b/Lib/test/test_concurrent_futures/test_interpreter_pool.py index 77ed0734a81987..844dfdd6fc901c 100644 --- a/Lib/test/test_concurrent_futures/test_interpreter_pool.py +++ b/Lib/test/test_concurrent_futures/test_interpreter_pool.py @@ -4,7 +4,6 @@ import os import sys import time -from threading import Thread import unittest from concurrent.futures.interpreter import BrokenInterpreterPool from concurrent import interpreters @@ -355,93 +354,82 @@ def test_saturation(self): executor.shutdown(wait=True) def test_blocking(self): + # There is no guarantee that a worker will be created for every + # submitted task. That's because there's a race between: + # + # * a new worker thread, created when task A was just submitted, + # becoming non-idle when it picks up task A + # * after task B is added to the queue, a new worker thread + # is started only if there are no idle workers + # (the check in ThreadPoolExecutor._adjust_thread_count()) + # + # That means we must not block waiting for *all* tasks to report + # "ready" before we unblock the known-ready workers. ready = queues.create() blocker = queues.create() def run(taskid, ready, blocker): - print(f'{taskid}: starting', flush=True) + # There can't be any globals here. ready.put_nowait(taskid) - print(f'{taskid}: ready', flush=True) -# blocker.get(timeout=20) # blocking blocker.get() # blocking - print(f'{taskid}: done', flush=True) numtasks = 10 futures = [] - executor = self.executor_type() - try: + with self.executor_type() as executor: # Request the jobs. for i in range(numtasks): fut = executor.submit(run, i, ready, blocker) futures.append(fut) -# assert len(executor._threads) == numtasks, len(executor._threads) - - try: - # Wait for them all to be ready. - pending = numtasks - def wait_for_ready(): - nonlocal pending + pending = numtasks + while pending > 0: + # Wait for any to be ready. + done = 0 + for _ in range(pending): try: - ready.get(timeout=10) # blocking + ready.get(timeout=1) # blocking except interpreters.QueueEmpty: pass else: - pending -= 1 - threads = [Thread(target=wait_for_ready) - for _ in range(pending)] - for t in threads: - t.start() - for t in threads: - t.join() - if pending: - if pending < numtasks: - # At least one was ready, so wait longer. - for _ in range(pending): - ready.get() # blocking - else: - # Something is probably wrong. Bail out. - group = [] - for fut in futures: - try: - fut.result(timeout=0) - except TimeoutError: - # Still running. - try: - ready.get_nowait() - except interpreters.QueueEmpty as exc: - # It's hung. - group.append(exc) - else: - pending -= 1 - except Exception as exc: - group.append(exc) - if group: - raise ExceptionGroup('futures', group) - assert not pending, pending -# for _ in range(numtasks): -# ready.get() # blocking - finally: + done += 1 + pending -= done # Unblock the workers. - for i in range(numtasks): + for _ in range(done): blocker.put_nowait(None) - # Make sure they finished. - group = [] - def wait_for_done(fut): - try: - fut.result(timeout=10) - except Exception as exc: - group.append(exc) - threads = [Thread(target=wait_for_done, args=(fut,)) - for fut in futures] - for t in threads: - t.start() - for t in threads: - t.join() - if group: - raise ExceptionGroup('futures', group) - finally: - executor.shutdown(wait=False) + def test_blocking_with_limited_workers(self): + # This is essentially the same as test_blocking, + # but we explicitly force a limited number of workers, + # instead of it happening implicitly sometimes due to a race. + ready = queues.create() + blocker = queues.create() + + def run(taskid, ready, blocker): + # There can't be any globals here. + ready.put_nowait(taskid) + blocker.get() # blocking + + numtasks = 10 + futures = [] + with self.executor_type(4) as executor: + # Request the jobs. + for i in range(numtasks): + fut = executor.submit(run, i, ready, blocker) + futures.append(fut) + pending = numtasks + while pending > 0: + # Wait for any to be ready. + done = 0 + for _ in range(pending): + try: + ready.get(timeout=1) # blocking + except interpreters.QueueEmpty: + pass + else: + done += 1 + pending -= done + # Unblock the workers. + for _ in range(done): + blocker.put_nowait(None) @support.requires_gil_enabled("gh-117344: test is flaky without the GIL") def test_idle_thread_reuse(self):