From 64e0d559c19d1fc41db7c4521ff7c0df3bb160ba Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Wed, 23 Sep 2026 11:52:09 -0400 Subject: [PATCH 01/15] New get_cached_controller() API. --- CHANGES.md | 5 +++ README.md | 31 ++++++++++++++- benchmarks/bench_context_manager_overhead.py | 14 ++++++- conftest.py | 1 + tests/_get_cached_controller.py | 28 +++++++++++++ tests/test_threadpoolctl.py | 13 +++++++ threadpoolctl.py | 41 +++++++++++++++++++- 7 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 tests/_get_cached_controller.py diff --git a/CHANGES.md b/CHANGES.md index 0e643c66..2810d9dc 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,6 +5,11 @@ quite a bit of overhead. https://github.com/joblib/threadpoolctl/pull/250 +- Add a new API to get a cached `ThreadpoolController` instance + (`get_cached_controller()`), as an optimization for cases where limits are set + frequently. See the docstring for caveats. + https://github.com/joblib/threadpoolctl/issues/255 + - Added official support for Python 3.15, and declared support for Python 3.14 in package metadata. https://github.com/joblib/threadpoolctl/issues/251 diff --git a/README.md b/README.md index ebaec349..96c10679 100644 --- a/README.md +++ b/README.md @@ -178,8 +178,9 @@ in specific sections of your Python program: The threadpools can also be controlled via the object oriented API, which is especially useful to avoid searching through all the loaded shared libraries -each time. **Note that it will not act on libraries loaded after the instantiation -of the `ThreadpoolController`!** +each time. That is, you can reuse a `ThreadpoolController` to reduce performance +overhead. **However, note that it will not act on libraries loaded after the +instantiation of the `ThreadpoolController`!** ```python >>> from threadpoolctl import ThreadpoolController @@ -191,6 +192,32 @@ of the `ThreadpoolController`!** ... a_squared = a @ a ``` +As a compromise between speed and catching newly loaded controllable libraries +you can use `get_cached_controller()`. This will notice any newly loaded +libraries triggered by importing new libraries, but might miss new libraries +loaded by compiled extensions directly using mechanisms like `dlopen()` on Linux +(as opposed to linked), or via `ctypes.CDLL`. + +```python +from threadpoolctl import get_cached_controller +# This ThreadpoolController will not know about the BLAS library loaded by +# NumPy, since it hasn't yet been imported: +controller = get_cached_controller() + +# Calling the API again doesn't create a new instance, so it's fast: +assert get_cached_controller() is controller + +import numpy as np + +# Now that NumPy has been imported, a new ThreadpoolController is returned that +# will know about NumPy's linked BLAS: +assert get_cached_controller() is not controller + +with controller.limit(limits=1, user_api='blas'): + a = np.random.randn(1000, 1000) + a_squared = a @ a +``` + ### Restricting the Limits to the Scope of a Function `threadpool_limits` and `ThreadpoolController` can also be used as decorators to set diff --git a/benchmarks/bench_context_manager_overhead.py b/benchmarks/bench_context_manager_overhead.py index d3b69c15..00ead948 100644 --- a/benchmarks/bench_context_manager_overhead.py +++ b/benchmarks/bench_context_manager_overhead.py @@ -2,7 +2,7 @@ from argparse import ArgumentParser from pprint import pprint from statistics import mean, stdev -from threadpoolctl import threadpool_info, threadpool_limits +from threadpoolctl import get_cached_controller, threadpool_info, threadpool_limits parser = ArgumentParser(description="Measure threadpool_limits call overhead.") parser.add_argument( @@ -27,4 +27,14 @@ pass timings.append(time.time() - t) -print(f"Overhead per call: {mean(timings) * 1e3:.3f} +/-{stdev(timings) * 1e3:.3f} ms") +print(f"Overhead per `with threadpool_limits()` call: {mean(timings) * 1e3:.3f} +/-{stdev(timings) * 1e3:.3f} ms") + + +timings = [] +for _ in range(args.n_calls): + t = time.time() + with get_cached_controller().limit(limits=1): + pass + timings.append(time.time() - t) + +print(f"Overhead per `with get_cached_controller().limit()` call: {mean(timings) * 1e3:.3f} +/-{stdev(timings) * 1e3:.3f} ms") diff --git a/conftest.py b/conftest.py index 11855652..451cff36 100644 --- a/conftest.py +++ b/conftest.py @@ -3,4 +3,5 @@ "tests/_openmp_test_helper.py", "tests/_limit_blas.py", "tests/_dl_iterate_phdr_deadlock.py", + "tests/_get_cached_controller.py", ] diff --git a/tests/_get_cached_controller.py b/tests/_get_cached_controller.py new file mode 100644 index 00000000..f1710e7c --- /dev/null +++ b/tests/_get_cached_controller.py @@ -0,0 +1,28 @@ +"""Test script for ``_get_cached_controller``.""" + +from threadpoolctl import get_cached_controller, ThreadpoolController + + +def assert_or_exit(condition): + if not condition: + raise RuntimeError("condition not met") + + +controller = get_cached_controller() +assert isinstance(controller, ThreadpoolController) +# Same controller is returned on another call: +assert_or_exit(controller is get_cached_controller()) +# No libraries loaded: +assert_or_exit(len(controller.lib_controllers) == 0) + +import numpy as np + +# Now a new controller is returned: +controller2 = get_cached_controller() +assert_or_exit(controller is not controller2) +assert isinstance(controller2, ThreadpoolController) +# And it noticed the newly loaded controllable library: +assert_or_exit(len(controller2.lib_controllers) >= 1) + +# Special success exit code: +raise SystemExit(17) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index 076a33fc..92ce63de 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -1141,6 +1141,19 @@ def test_controller_parallelism_no_deadlocks(): assert process.returncode == 17 +def test_get_cached_controller(): + """ + ``get_cached_controller()`` returns a cached ``ThreadpoolController``, and + invalidates the cache if new modules are imported. + """ + process = subprocess.run( + [sys.executable, "-m", "tests._get_cached_controller"], timeout=10 + ) + + # Special code indicating success: + assert process.returncode == 17 + + @pytest.mark.skipif( not sys.platform.startswith("linux"), reason="ctypes.util is only avoided on Linux (#225) in Python 3.14", diff --git a/threadpoolctl.py b/threadpoolctl.py index 8e8268ed..382d40e5 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -11,13 +11,16 @@ # adapted from code by Intel developer @anton-malakhov available at # https://github.com/IntelPython/smp (Copyright (c) 2017, Intel Corporation) # and also published under the BSD 3-Clause license + +from __future__ import annotations + import os import re import sys import ctypes import itertools import textwrap -from threading import Thread +from threading import Lock, Thread from typing import Callable, Literal, final import warnings from abc import ABC, abstractmethod @@ -571,6 +574,11 @@ def switch_backend(self, backend): f"{self.available_backends} or the path to a valid shared library." ) + # Invalidate the global cached controller: + global _CACHED_CONTROLLER + with _CACHE_LOCK: + _CACHED_CONTROLLER = None + # Trigger a new search of loaded shared libraries since loading a new # backend caused a dlopen. self.parent._load_libraries() @@ -1739,5 +1747,36 @@ def _main(): print(json.dumps(threadpool_info(debugging_info=True), indent=2)) +# A cached instance of ThreadpoolController, created by +# get_cached_controller(): +_CACHED_CONTROLLER = None +# Cached number of modules in sys.modules, used by get_cached_controller() +_CACHED_SYS_MODULES_LEN = 0 +# The lock controlling access to the above: +_CACHE_LOCK = Lock() + + +def get_cached_controller() -> ThreadpoolController: + """Return a cached ``ThreadpoolController``, as a speed optimization. + + The cached version is regenerated if the number of imported of modules has + changed, since this indicates the possibility that a new controllable + library has been loaded. Invalidation also happens if Flexiblas is in use + and has changed its backend. + + It is possible that somehow a controllable library gets loaded without + importing a Python module, e.g. using ``dlopen()`` on Linux, a ctypes + ``CDLL``, and the like. This is rare, however. Outside of these + situations, using ``get_cached_controller()`` will be much faster than + creating a new ``ThreadpoolController`` instance. + """ + global _CACHED_CONTROLLER, _CACHED_SYS_MODULES_LEN + with _CACHE_LOCK: + if _CACHED_CONTROLLER is None or len(sys.modules) != _CACHED_SYS_MODULES_LEN: + _CACHED_CONTROLLER = ThreadpoolController() + _CACHED_SYS_MODULES_LEN = len(sys.modules) + return _CACHED_CONTROLLER + + if __name__ == "__main__": _main() From 65e2a6a369d1d849f060d78710777602462f7515 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Wed, 23 Sep 2026 12:03:57 -0400 Subject: [PATCH 02/15] Pacify black --- benchmarks/bench_context_manager_overhead.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/benchmarks/bench_context_manager_overhead.py b/benchmarks/bench_context_manager_overhead.py index 00ead948..a70af46a 100644 --- a/benchmarks/bench_context_manager_overhead.py +++ b/benchmarks/bench_context_manager_overhead.py @@ -27,7 +27,9 @@ pass timings.append(time.time() - t) -print(f"Overhead per `with threadpool_limits()` call: {mean(timings) * 1e3:.3f} +/-{stdev(timings) * 1e3:.3f} ms") +print( + f"Overhead per `with threadpool_limits()` call: {mean(timings) * 1e3:.3f} +/-{stdev(timings) * 1e3:.3f} ms" +) timings = [] @@ -37,4 +39,6 @@ pass timings.append(time.time() - t) -print(f"Overhead per `with get_cached_controller().limit()` call: {mean(timings) * 1e3:.3f} +/-{stdev(timings) * 1e3:.3f} ms") +print( + f"Overhead per `with get_cached_controller().limit()` call: {mean(timings) * 1e3:.3f} +/-{stdev(timings) * 1e3:.3f} ms" +) From 2348bfa8be292f5d8e89a9da13eb3ef6574417ec Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Wed, 23 Sep 2026 12:09:42 -0400 Subject: [PATCH 03/15] Handle edge cases better. --- tests/test_threadpoolctl.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index 92ce63de..1bad76dc 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -1146,6 +1146,11 @@ def test_get_cached_controller(): ``get_cached_controller()`` returns a cached ``ThreadpoolController``, and invalidates the cache if new modules are imported. """ + pytest.importorskip("numpy") + controller = ThreadpoolController().select(use_api="blas") + if not controller.lib_controllers: + pytest.skip("No BLAS in NumPy") + process = subprocess.run( [sys.executable, "-m", "tests._get_cached_controller"], timeout=10 ) From e20d20d889541a6ffb4e2dd277cab63e8d17519c Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Wed, 23 Sep 2026 12:10:06 -0400 Subject: [PATCH 04/15] Correct PR number --- CHANGES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 2810d9dc..f22b69c0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,7 +8,7 @@ - Add a new API to get a cached `ThreadpoolController` instance (`get_cached_controller()`), as an optimization for cases where limits are set frequently. See the docstring for caveats. - https://github.com/joblib/threadpoolctl/issues/255 + https://github.com/joblib/threadpoolctl/issues/257 - Added official support for Python 3.15, and declared support for Python 3.14 in package metadata. From efc4870858e69b9dca96bfc46fb41e0165e32927 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Wed, 23 Sep 2026 13:24:46 -0400 Subject: [PATCH 05/15] Fix typo --- tests/test_threadpoolctl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index 1bad76dc..a5adcbed 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -1147,7 +1147,7 @@ def test_get_cached_controller(): invalidates the cache if new modules are imported. """ pytest.importorskip("numpy") - controller = ThreadpoolController().select(use_api="blas") + controller = ThreadpoolController().select(user_api="blas") if not controller.lib_controllers: pytest.skip("No BLAS in NumPy") From 791f46e3e419f26a6f43feab4c1ca5bb505ea87f Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Wed, 23 Sep 2026 13:30:21 -0400 Subject: [PATCH 06/15] Test for FlexiBLAS cached controller invalidation. --- tests/test_threadpoolctl.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index a5adcbed..109226e9 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -11,7 +11,7 @@ from threading import Thread import threadpoolctl -from threadpoolctl import threadpool_limits, threadpool_info +from threadpoolctl import get_cached_controller, threadpool_limits, threadpool_info from threadpoolctl import LibController, ThreadpoolController from threadpoolctl import _ALL_PREFIXES, _ALL_USER_APIS from threadpoolctl import _determine_thread_limit_scope @@ -734,10 +734,13 @@ def test_flexiblas_switch(): # at first, only "OPENBLAS_CONDA" is loaded assert fb_controller.current_backend == "OPENBLAS_CONDA" assert fb_controller.loaded_backends == ["OPENBLAS_CONDA"] + cached_controller = get_cached_controller() fb_controller.switch_backend("NETLIB") assert fb_controller.current_backend == "NETLIB" assert fb_controller.loaded_backends == ["OPENBLAS_CONDA", "NETLIB"] + # Switching invalidated the cached controller: + assert get_cached_controller() is not cached_controller if sys.platform == "linux": mkl_path = f"{os.getenv('CONDA_PREFIX')}/lib/libmkl_rt.so" From de4a375d44f1fb7d4f18a5c517fde8bb88d47905 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Wed, 23 Sep 2026 14:01:54 -0400 Subject: [PATCH 07/15] Fix a race condition --- threadpoolctl.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index 382d40e5..17ab4450 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -1772,9 +1772,12 @@ def get_cached_controller() -> ThreadpoolController: """ global _CACHED_CONTROLLER, _CACHED_SYS_MODULES_LEN with _CACHE_LOCK: - if _CACHED_CONTROLLER is None or len(sys.modules) != _CACHED_SYS_MODULES_LEN: + # Do this once, so there's no race condition with imports in other + # threads: + num_modules = len(sys.modules) + if _CACHED_CONTROLLER is None or num_modules != _CACHED_SYS_MODULES_LEN: _CACHED_CONTROLLER = ThreadpoolController() - _CACHED_SYS_MODULES_LEN = len(sys.modules) + _CACHED_SYS_MODULES_LEN = num_modules return _CACHED_CONTROLLER From 9394e087f268db628fff3ee243f4da485b3cdb96 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Wed, 23 Sep 2026 14:20:39 -0400 Subject: [PATCH 08/15] Fix spurious Windows test failure --- threadpoolctl.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/threadpoolctl.py b/threadpoolctl.py index 17ab4450..22af5b22 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -43,6 +43,14 @@ # CPython before 3.14 does not provide dll inspection. dllist = None +# Ensure we don't get spurious cache invalidation the first time +# ThreadpoolController is created, as the Windows dll listing methods import +# from there: +if sys.platform == "win32": + from ctypes import wintypes + + del wintypes + __version__ = "3.8.0.dev0" __all__ = [ From d29ff6faaa86282d748164deff08b7eb64419567 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Fri, 25 Sep 2026 13:59:09 -0400 Subject: [PATCH 09/15] Just do normal assert --- tests/_get_cached_controller.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/tests/_get_cached_controller.py b/tests/_get_cached_controller.py index f1710e7c..50fdd563 100644 --- a/tests/_get_cached_controller.py +++ b/tests/_get_cached_controller.py @@ -3,26 +3,22 @@ from threadpoolctl import get_cached_controller, ThreadpoolController -def assert_or_exit(condition): - if not condition: - raise RuntimeError("condition not met") - - controller = get_cached_controller() assert isinstance(controller, ThreadpoolController) # Same controller is returned on another call: -assert_or_exit(controller is get_cached_controller()) +assert controller is get_cached_controller() # No libraries loaded: -assert_or_exit(len(controller.lib_controllers) == 0) +assert len(controller.lib_controllers) == 0 +# This should result in loading BLAS: import numpy as np # Now a new controller is returned: controller2 = get_cached_controller() -assert_or_exit(controller is not controller2) +assert controller is not controller2 assert isinstance(controller2, ThreadpoolController) # And it noticed the newly loaded controllable library: -assert_or_exit(len(controller2.lib_controllers) >= 1) +assert len(controller2.lib_controllers) >= 1 # Special success exit code: raise SystemExit(17) From f8266d37156a85aedcab33fd3b55b86451b19fb5 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Fri, 25 Sep 2026 14:03:30 -0400 Subject: [PATCH 10/15] Address review comments --- README.md | 16 ++++++++++++---- threadpoolctl.py | 1 + 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 96c10679..502be373 100644 --- a/README.md +++ b/README.md @@ -198,8 +198,20 @@ libraries triggered by importing new libraries, but might miss new libraries loaded by compiled extensions directly using mechanisms like `dlopen()` on Linux (as opposed to linked), or via `ctypes.CDLL`. +```python +import numpy + +with get_cached_controller().limit(limits=1, user_api='blas'): + a = np.random.randn(1000, 1000) + a_squared = a @ a +``` + +To get a sense of how `get_cached_controller()` cache invalidation happens, +consider the following example: + ```python from threadpoolctl import get_cached_controller + # This ThreadpoolController will not know about the BLAS library loaded by # NumPy, since it hasn't yet been imported: controller = get_cached_controller() @@ -212,10 +224,6 @@ import numpy as np # Now that NumPy has been imported, a new ThreadpoolController is returned that # will know about NumPy's linked BLAS: assert get_cached_controller() is not controller - -with controller.limit(limits=1, user_api='blas'): - a = np.random.randn(1000, 1000) - a_squared = a @ a ``` ### Restricting the Limits to the Scope of a Function diff --git a/threadpoolctl.py b/threadpoolctl.py index 22af5b22..489bdd44 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -58,6 +58,7 @@ "threadpool_info", "ThreadpoolController", "LibController", + "get_cached_controller", "register", ] From 3f982e2e5cbd513bf92346e15fe66323050e9824 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Fri, 25 Sep 2026 14:05:02 -0400 Subject: [PATCH 11/15] Fast path --- threadpoolctl.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/threadpoolctl.py b/threadpoolctl.py index 489bdd44..75fc7bc8 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -1780,6 +1780,9 @@ def get_cached_controller() -> ThreadpoolController: creating a new ``ThreadpoolController`` instance. """ global _CACHED_CONTROLLER, _CACHED_SYS_MODULES_LEN + # Fast path, with no lock: + if _CACHED_SYS_MODULES_LEN == len(sys.modules) and _CACHED_CONTROLLER is not None: + return _CACHED_CONTROLLER with _CACHE_LOCK: # Do this once, so there's no race condition with imports in other # threads: From 30198f5df8ebff4b689e202668e8c1cc19b857cf Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Fri, 25 Sep 2026 14:05:44 -0400 Subject: [PATCH 12/15] Reformat --- tests/_get_cached_controller.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/_get_cached_controller.py b/tests/_get_cached_controller.py index 50fdd563..d0f07a00 100644 --- a/tests/_get_cached_controller.py +++ b/tests/_get_cached_controller.py @@ -2,7 +2,6 @@ from threadpoolctl import get_cached_controller, ThreadpoolController - controller = get_cached_controller() assert isinstance(controller, ThreadpoolController) # Same controller is returned on another call: From 6c57a68d83210cc6783ffcaab93a12d357117cfb Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 13 Oct 2026 07:54:58 -0400 Subject: [PATCH 13/15] Reformat with ruff --- tests/utils.py | 4 +++- threadpoolctl.py | 3 +-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/utils.py b/tests/utils.py index 0c8d747a..b43e7263 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -113,7 +113,9 @@ def get_openblas_dll_path(): key=lambda path: ( 0 if "libscipy_openblas" in os.path.basename(path).lower() - else 1 if "libopenblas" in os.path.basename(path).lower() else 2 + else 1 + if "libopenblas" in os.path.basename(path).lower() + else 2 ), )[0] diff --git a/threadpoolctl.py b/threadpoolctl.py index 75fc7bc8..487747aa 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -34,8 +34,7 @@ dllist = None if sys.platform != "emscripten" and ( # Python 3.15 doesn't have the CFUNCTYPE anymore: - sys.platform == "linux" - and sys.version_info[:2] >= (3, 15) + sys.platform == "linux" and sys.version_info[:2] >= (3, 15) ): try: from ctypes.util import dllist From 68a954b6897d16dac9a61b9d038bf3486a888ccc Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 13 Oct 2026 08:05:47 -0400 Subject: [PATCH 14/15] Add cache invalidation API --- README.md | 9 ++++++--- tests/_get_cached_controller.py | 14 +++++++++++++- threadpoolctl.py | 9 +++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2d0d28bd..936c6214 100644 --- a/README.md +++ b/README.md @@ -201,9 +201,9 @@ loaded by compiled extensions directly using mechanisms like `dlopen()` on Linux ```python import numpy -with get_cached_controller().limit(limits=1, user_api='blas'): - a = np.random.randn(1000, 1000) - a_squared = a @ a +with get_cached_controller().limit(limits=1, user_api="blas"): + a = np.random.randn(1000, 1000) + a_squared = a @ a ``` To get a sense of how `get_cached_controller()` cache invalidation happens, @@ -226,6 +226,9 @@ import numpy as np assert get_cached_controller() is not controller ``` +You can clear the cached controller manually by running +``reset_cached_controller()``. + ### Restricting the Limits to the Scope of a Function `threadpool_limits` and `ThreadpoolController` can also be used as decorators to set diff --git a/tests/_get_cached_controller.py b/tests/_get_cached_controller.py index 8e4aa456..954eed3f 100644 --- a/tests/_get_cached_controller.py +++ b/tests/_get_cached_controller.py @@ -1,6 +1,10 @@ """Test script for ``_get_cached_controller``.""" -from threadpoolctl import ThreadpoolController, get_cached_controller +from threadpoolctl import ( + ThreadpoolController, + get_cached_controller, + reset_cached_controller, +) controller = get_cached_controller() assert isinstance(controller, ThreadpoolController) @@ -18,5 +22,13 @@ # And it noticed the newly loaded controllable library: assert len(controller2.lib_controllers) >= 1 +# reset_cached_controller() clears the cache: +assert get_cached_controller() is controller2 +reset_cached_controller() +controller3 = get_cached_controller() +assert controller3 is not controller2 +assert get_cached_controller() is controller3 + + # Special success exit code: raise SystemExit(17) diff --git a/threadpoolctl.py b/threadpoolctl.py index 070c0621..32182f62 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -58,6 +58,7 @@ "ThreadpoolController", "get_cached_controller", "register", + "reset_cached_controller", "threadpool_info", "threadpool_limits", ] @@ -1793,5 +1794,13 @@ def get_cached_controller() -> ThreadpoolController: return _CACHED_CONTROLLER +def reset_cached_controller() -> None: + """Clear the cached ``ThreadpoolController``, if any.""" + global _CACHED_CONTROLLER, _CACHED_SYS_MODULES_LEN + with _CACHE_LOCK: + _CACHED_CONTROLLER = None + _CACHED_SYS_MODULES_LEN = 0 + + if __name__ == "__main__": _main() From 85a14062820578f9f73dcd4110773ed7ebacc85e Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 19 Oct 2026 10:01:56 -0400 Subject: [PATCH 15/15] Update threadpoolctl.py Co-authored-by: Olivier Grisel --- threadpoolctl.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/threadpoolctl.py b/threadpoolctl.py index 32182f62..b18c1210 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -1779,6 +1779,9 @@ def get_cached_controller() -> ThreadpoolController: ``CDLL``, and the like. This is rare, however. Outside of these situations, using ``get_cached_controller()`` will be much faster than creating a new ``ThreadpoolController`` instance. + + The cached controller can be explicitly invalidated by calling the + `reset_cached_controller()` module level function. """ global _CACHED_CONTROLLER, _CACHED_SYS_MODULES_LEN # Fast path, with no lock: