From 94808374599a3fa37f923a7b04309b1eca8dec92 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 5 Nov 2026 13:59:26 -0500 Subject: [PATCH 01/24] Don't restore the limits if setting them was a no-op. --- CHANGES.md | 2 +- tests/test_threadpoolctl.py | 28 +++++++++++++++++++++++++++- threadpoolctl.py | 23 ++++++++++++++++------- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index eb539e0d..638f099c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,4 +1,4 @@ -3.9.0 (under development) +3.8.1 (under development) ========================= 3.8.0 (2026-11-05) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index 93c32292..b1965f18 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -8,7 +8,7 @@ import subprocess import sys import time -from threading import Lock, Thread, current_thread +from threading import Lock, Thread, current_thread, Event import pytest @@ -1075,6 +1075,32 @@ def test_restore_original_limits_keeps_openblas_controlling_thread(request): assert lib._SHARED_STATE.controlling_thread is None +def test_overlapping_context_manager_restores_correctly(request): + libs, lib, orig_limit = _require_resizable_non_openmp_openblas() + request.addfinalizer(_restore_openblas_thread_counts(libs)) + a_in, b_in, a_out = (Event() for _ in range(3)) + + def A(): + with threadpool_limits(2): + a_in.set() + b_in.wait() + a_out.set() + + def B(): + a_in.wait() + with threadpool_limits(1): # snapshot taken while A's limit is active + b_in.set() + a_out.wait() + + ts = [Thread(target=A), Thread(target=B)] + for t in ts: + t.start() + for t in ts: + t.join() + + assert lib.get_num_threads() == orig_limit + + # skip test if not run in a azure pipelines job since it relies on a specific flexiblas # installation. @pytest.mark.skipif( diff --git a/threadpoolctl.py b/threadpoolctl.py index 40377c03..f834f380 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -201,6 +201,9 @@ def get_and_set() -> None: OpenBLASController.clear_controlling_thread(force=True) +NOT_CONTROLLING = object() + + class LibController(ABC): """Abstract base class for the individual library controllers @@ -292,6 +295,9 @@ def set_num_threads(self, num_threads): When possible, implementations of this method should choose a thread limiting API that only applies to the current thread. + + Returns ``NOT_CONTROLLING`` if setting failed due to not being + controlling thread, possible in e.g. OpenBLAS-pthreads. """ @abstractmethod @@ -576,7 +582,7 @@ def set_num_threads(self, num_threads): return self.set_num_threads(num_threads) else: # Some other thread controls OpenBLAS: do nothing. - return + return NOT_CONTROLLING def _real_set_num_threads(self, num_threads): # The OpenBLAS limiting API is process-wide, and we want current thread @@ -1105,7 +1111,9 @@ def __init__(self, controller, *, limits=None, user_api=None): self._limits, self._user_api = _check_limiter_params( controller, limits, user_api ) - self._original_info = self._controller.info() + self._original_info = dict( + zip(self._controller.lib_controllers, self._controller.info()) + ) self._set_threadpool_limits() def __enter__(self): @@ -1125,9 +1133,7 @@ def wrap(cls, controller, *, limits=None, user_api=None): def restore_original_limits(self): """Set the limits back to their original values""" - for lib_controller, original_info in zip( - self._controller.lib_controllers, self._original_info - ): + for lib_controller, original_info in self._original_info.items(): lib_controller.set_num_threads(original_info["num_threads"]) # Alias of `restore_original_limits` for backward compatibility @@ -1144,7 +1150,7 @@ def get_original_num_threads(self): for user_api in self._user_api: limits = [ lib_info["num_threads"] - for lib_info in self._original_info + for lib_info in self._original_info.values() if lib_info["user_api"] == user_api ] limits = set(limits) @@ -1188,7 +1194,10 @@ def _set_threadpool_limits(self): continue if num_threads is not None: - lib_controller.set_num_threads(num_threads) + result = lib_controller.set_num_threads(num_threads) + if result is NOT_CONTROLLING: + # Can't restore to this: + self._original_info.pop(lib_controller, None) class _ThreadpoolLimiterDecorator(ContextDecorator): From 968373b5b05352019941e61cc7b417f8d85b09a1 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 5 Nov 2026 14:03:46 -0500 Subject: [PATCH 02/24] Changelog entry --- CHANGES.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 638f099c..54077c56 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,12 @@ 3.8.1 (under development) ========================= +- When setting the limit failed because the current thread doesn't control + OpenBLAS-pthreads, this information is returned, and `threadpool_limits` uses + this to prevent incorrectly restoring to the wrong limit. + https://github.com/joblib/threadpoolctl/pull/300 + + 3.8.0 (2026-11-05) ================== From 80a69c018ce5143247673b02583c8a5c265aacf8 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 5 Nov 2026 14:14:48 -0500 Subject: [PATCH 03/24] Ensure other LibControllers are restored correctly --- tests/test_threadpoolctl.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index b1965f18..5c67947e 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -1078,12 +1078,19 @@ def test_restore_original_limits_keeps_openblas_controlling_thread(request): def test_overlapping_context_manager_restores_correctly(request): libs, lib, orig_limit = _require_resizable_non_openmp_openblas() request.addfinalizer(_restore_openblas_thread_counts(libs)) + openmp = ThreadpoolController().select(user_api="openmp") + original_openmp_info = openmp.info() + a_in, b_in, a_out = (Event() for _ in range(3)) + a_limits_correct = [] + b_limits_correct = [] + def A(): with threadpool_limits(2): a_in.set() b_in.wait() + a_limits_correct.append(openmp.info() == original_openmp_info) a_out.set() def B(): @@ -1091,6 +1098,7 @@ def B(): with threadpool_limits(1): # snapshot taken while A's limit is active b_in.set() a_out.wait() + b_limits_correct.append(openmp.info() == original_openmp_info) ts = [Thread(target=A), Thread(target=B)] for t in ts: @@ -1098,7 +1106,10 @@ def B(): for t in ts: t.join() + assert a_limits_correct == [True] + assert b_limits_correct == [True] assert lib.get_num_threads() == orig_limit + assert openmp.info() == original_openmp_info # skip test if not run in a azure pipelines job since it relies on a specific flexiblas From b5c7139c5a5a81ed056e239dfd573592b07baf70 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 5 Nov 2026 14:15:52 -0500 Subject: [PATCH 04/24] Better explanation --- threadpoolctl.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index f834f380..5fb31f23 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -1196,7 +1196,9 @@ def _set_threadpool_limits(self): if num_threads is not None: result = lib_controller.set_num_threads(num_threads) if result is NOT_CONTROLLING: - # Can't restore to this: + # Didn't set, so shouldn't restore either; if this thread + # can become the controlling thread during restoration, it + # might restore the wrong value. self._original_info.pop(lib_controller, None) From b19b65345fdaa5a2ceeaac787860b7c992f8dc12 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 5 Nov 2026 14:24:50 -0500 Subject: [PATCH 05/24] Lint --- 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 5c67947e..c8ac2426 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -8,7 +8,7 @@ import subprocess import sys import time -from threading import Lock, Thread, current_thread, Event +from threading import Event, Lock, Thread, current_thread import pytest From a9153560fa50feebff9b3e575c13888d0af40f21 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 5 Nov 2026 14:47:19 -0500 Subject: [PATCH 06/24] Deal with platform edge cases --- tests/test_threadpoolctl.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index c8ac2426..661e983b 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -1075,7 +1075,19 @@ def test_restore_original_limits_keeps_openblas_controlling_thread(request): assert lib._SHARED_STATE.controlling_thread is None +@pytest.mark.xfail( + sys.platform == "windows", + reason="OpenMP on Windows has its own issues with overlapping setting", +) def test_overlapping_context_manager_restores_correctly(request): + flexiblas_controller = ThreadpoolController().select(internal_api="flexiblas") + + if flexiblas_controller: + pytest.skip( + "FlexiBLAS means there are two controllers for same OpenBLAS " + "(direct + FlexiBLAS) which causes test failures." + ) + libs, lib, orig_limit = _require_resizable_non_openmp_openblas() request.addfinalizer(_restore_openblas_thread_counts(libs)) openmp = ThreadpoolController().select(user_api="openmp") From aa787f6f36b37575237bc1e3c570500c4cb2d605 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 5 Nov 2026 14:54:35 -0500 Subject: [PATCH 07/24] Mark correctly --- 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 661e983b..b8467924 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -1075,7 +1075,7 @@ def test_restore_original_limits_keeps_openblas_controlling_thread(request): assert lib._SHARED_STATE.controlling_thread is None -@pytest.mark.xfail( +@pytest.xfail( sys.platform == "windows", reason="OpenMP on Windows has its own issues with overlapping setting", ) From bdfa41fbb183604faf8b38f952fa77d0e7b83b29 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 5 Nov 2026 14:55:36 -0500 Subject: [PATCH 08/24] Correct usage --- tests/test_threadpoolctl.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index b8467924..2dde8f91 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -1075,13 +1075,11 @@ def test_restore_original_limits_keeps_openblas_controlling_thread(request): assert lib._SHARED_STATE.controlling_thread is None -@pytest.xfail( - sys.platform == "windows", - reason="OpenMP on Windows has its own issues with overlapping setting", -) def test_overlapping_context_manager_restores_correctly(request): flexiblas_controller = ThreadpoolController().select(internal_api="flexiblas") + if sys.platform == "windows": + pytest.xfail("OpenMP on Windows has its own issues with overlapping setting") if flexiblas_controller: pytest.skip( "FlexiBLAS means there are two controllers for same OpenBLAS " From 359a5954ac1b103b26b0ce5768f3606e6901f3c3 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 5 Nov 2026 15:03:31 -0500 Subject: [PATCH 09/24] Test another usage pattern. --- CHANGES.md | 3 ++- tests/test_threadpoolctl.py | 51 +++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 54077c56..eb7f4c71 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,7 +3,8 @@ - When setting the limit failed because the current thread doesn't control OpenBLAS-pthreads, this information is returned, and `threadpool_limits` uses - this to prevent incorrectly restoring to the wrong limit. + this to prevent incorrectly restoring to the wrong limit. This is done both + for context manager usage and for direct calls to `restore_original_limits()`. https://github.com/joblib/threadpoolctl/pull/300 diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index 2dde8f91..f635f07b 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -1076,6 +1076,7 @@ def test_restore_original_limits_keeps_openblas_controlling_thread(request): def test_overlapping_context_manager_restores_correctly(request): + """A thread that failed to set should also not restore.""" flexiblas_controller = ThreadpoolController().select(internal_api="flexiblas") if sys.platform == "windows": @@ -1122,6 +1123,56 @@ def B(): assert openmp.info() == original_openmp_info +def test_overlapping_set_and_restore(request): + """A thread that failed to set should also not restore.""" + flexiblas_controller = ThreadpoolController().select(internal_api="flexiblas") + + if sys.platform == "windows": + pytest.xfail("OpenMP on Windows has its own issues with overlapping setting") + if flexiblas_controller: + pytest.skip( + "FlexiBLAS means there are two controllers for same OpenBLAS " + "(direct + FlexiBLAS) which causes test failures." + ) + + libs, lib, orig_limit = _require_resizable_non_openmp_openblas() + request.addfinalizer(_restore_openblas_thread_counts(libs)) + openmp = ThreadpoolController().select(user_api="openmp") + original_openmp_info = openmp.info() + + a_in, b_in, a_out = (Event() for _ in range(3)) + + a_limits_correct = [] + b_limits_correct = [] + + def A(): + limiter = threadpool_limits(2) + a_in.set() + b_in.wait() + limiter.restore_original_limits() + a_limits_correct.append(openmp.info() == original_openmp_info) + a_out.set() + + def B(): + a_in.wait() + limiter = threadpool_limits(1) # snapshot taken while A's limit is active + b_in.set() + a_out.wait() + limiter.restore_original_limits() + b_limits_correct.append(openmp.info() == original_openmp_info) + + ts = [Thread(target=A), Thread(target=B)] + for t in ts: + t.start() + for t in ts: + t.join() + + assert a_limits_correct == [True] + assert b_limits_correct == [True] + assert lib.get_num_threads() == orig_limit + assert openmp.info() == original_openmp_info + + # skip test if not run in a azure pipelines job since it relies on a specific flexiblas # installation. @pytest.mark.skipif( From 9d236aadf5fc8a3029152deb298694b9c169cdf2 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 5 Nov 2026 15:15:33 -0500 Subject: [PATCH 10/24] Better name and docs --- threadpoolctl.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index 5fb31f23..037966ff 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -200,8 +200,9 @@ def get_and_set() -> None: set_n_threads(previous) OpenBLASController.clear_controlling_thread(force=True) - -NOT_CONTROLLING = object() +# Returned from set_num_threads() to indicate that setting did not happen, +# typically because the current thread is not the controlling thread. +DID_NOT_SET = object() class LibController(ABC): @@ -296,8 +297,8 @@ def set_num_threads(self, num_threads): When possible, implementations of this method should choose a thread limiting API that only applies to the current thread. - Returns ``NOT_CONTROLLING`` if setting failed due to not being - controlling thread, possible in e.g. OpenBLAS-pthreads. + Returns ``DID_NOT_SET`` if setting did not happen; this is usually due + to not being the controlling thread for OpenBLAS-pthreads. """ @abstractmethod @@ -582,7 +583,7 @@ def set_num_threads(self, num_threads): return self.set_num_threads(num_threads) else: # Some other thread controls OpenBLAS: do nothing. - return NOT_CONTROLLING + return DID_NOT_SET def _real_set_num_threads(self, num_threads): # The OpenBLAS limiting API is process-wide, and we want current thread @@ -1195,7 +1196,7 @@ def _set_threadpool_limits(self): if num_threads is not None: result = lib_controller.set_num_threads(num_threads) - if result is NOT_CONTROLLING: + if result is DID_NOT_SET: # Didn't set, so shouldn't restore either; if this thread # can become the controlling thread during restoration, it # might restore the wrong value. From 919e2b460909be01da64c88ed08d93dc36b7ef40 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 5 Nov 2026 15:17:05 -0500 Subject: [PATCH 11/24] correct platform name --- 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 f635f07b..589ce36d 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -1079,7 +1079,7 @@ def test_overlapping_context_manager_restores_correctly(request): """A thread that failed to set should also not restore.""" flexiblas_controller = ThreadpoolController().select(internal_api="flexiblas") - if sys.platform == "windows": + if sys.platform == "win32": pytest.xfail("OpenMP on Windows has its own issues with overlapping setting") if flexiblas_controller: pytest.skip( From c309842b0514d0362adea06b6699e2c5255dd6b3 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 5 Nov 2026 15:17:13 -0500 Subject: [PATCH 12/24] Reformat --- threadpoolctl.py | 1 + 1 file changed, 1 insertion(+) diff --git a/threadpoolctl.py b/threadpoolctl.py index 037966ff..033efde0 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -200,6 +200,7 @@ def get_and_set() -> None: set_n_threads(previous) OpenBLASController.clear_controlling_thread(force=True) + # Returned from set_num_threads() to indicate that setting did not happen, # typically because the current thread is not the controlling thread. DID_NOT_SET = object() From f204aaa4a96bdff91acb10f621371db273e5838a Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 5 Nov 2026 15:30:37 -0500 Subject: [PATCH 13/24] Fix the _other_ wrong string --- 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 589ce36d..5b5f2ba4 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -1127,7 +1127,7 @@ def test_overlapping_set_and_restore(request): """A thread that failed to set should also not restore.""" flexiblas_controller = ThreadpoolController().select(internal_api="flexiblas") - if sys.platform == "windows": + if sys.platform == "win32": pytest.xfail("OpenMP on Windows has its own issues with overlapping setting") if flexiblas_controller: pytest.skip( From 36b898d65ac6573cca7fa3c0360cdfde4c9bd121 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Fri, 6 Nov 2026 11:42:54 -0500 Subject: [PATCH 14/24] Test for another race condition --- tests/test_threadpoolctl.py | 62 ++++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index 5b5f2ba4..312b88e0 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -1075,8 +1075,7 @@ def test_restore_original_limits_keeps_openblas_controlling_thread(request): assert lib._SHARED_STATE.controlling_thread is None -def test_overlapping_context_manager_restores_correctly(request): - """A thread that failed to set should also not restore.""" +def _race_condition_skip_problem_platforms(): flexiblas_controller = ThreadpoolController().select(internal_api="flexiblas") if sys.platform == "win32": @@ -1087,6 +1086,11 @@ def test_overlapping_context_manager_restores_correctly(request): "(direct + FlexiBLAS) which causes test failures." ) + +def test_overlapping_context_manager_restores_correctly(request): + """A thread that failed to set should also not restore.""" + _race_condition_skip_problem_platforms() + libs, lib, orig_limit = _require_resizable_non_openmp_openblas() request.addfinalizer(_restore_openblas_thread_counts(libs)) openmp = ThreadpoolController().select(user_api="openmp") @@ -1125,15 +1129,7 @@ def B(): def test_overlapping_set_and_restore(request): """A thread that failed to set should also not restore.""" - flexiblas_controller = ThreadpoolController().select(internal_api="flexiblas") - - if sys.platform == "win32": - pytest.xfail("OpenMP on Windows has its own issues with overlapping setting") - if flexiblas_controller: - pytest.skip( - "FlexiBLAS means there are two controllers for same OpenBLAS " - "(direct + FlexiBLAS) which causes test failures." - ) + _race_condition_skip_problem_platforms() libs, lib, orig_limit = _require_resizable_non_openmp_openblas() request.addfinalizer(_restore_openblas_thread_counts(libs)) @@ -1173,6 +1169,50 @@ def B(): assert openmp.info() == original_openmp_info +def test_get_current_info_race_condition(request): + """Test for race condition in multi-threaded OpenBLAS-pthreads usage. + + 1. Thread A sets limit L. + 2. Thread B gets limit L in `_ThreadpoolLimiter.__init__`. + 3. Thread A restores to original limit O and releases control. + 4. Thread B continues `__init__`, setting a limit and taking control. + 5. Thread B restores to limit L. + """ + _race_condition_skip_problem_platforms() + + libs, lib, orig_limit = _require_resizable_non_openmp_openblas() + request.addfinalizer(_restore_openblas_thread_counts(libs)) + + a_in, b_retrieved, a_out = (Event() for _ in range(3)) + + def A(): + with threadpool_limits(2): + a_in.set() + b_retrieved.wait() + a_out.set() + + class racy_threadpool_limits(threadpool_limits): + def _set_threadpool_limits(self): + # Tell A it can finish up: + b_retrieved.set() + # Wait until thread A is finished, so we can take control: + a_out.wait() + super()._set_threadpool_limits() + + def B(): + a_in.wait() + with racy_threadpool_limits(1): + pass + + ts = [Thread(target=A), Thread(target=B)] + for t in ts: + t.start() + for t in ts: + t.join() + + assert lib.get_num_threads() == orig_limit + + # skip test if not run in a azure pipelines job since it relies on a specific flexiblas # installation. @pytest.mark.skipif( From 2e188ac186e5c303d9292b20be2e9204e0e13e0a Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Fri, 6 Nov 2026 11:48:52 -0500 Subject: [PATCH 15/24] Fix for the race condition --- threadpoolctl.py | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index 033efde0..4ec41eb9 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -203,7 +203,17 @@ def get_and_set() -> None: # Returned from set_num_threads() to indicate that setting did not happen, # typically because the current thread is not the controlling thread. -DID_NOT_SET = object() +_DID_NOT_SET = object() + + +@dataclass +class _THIS_THREAD_TOOK_CONTROL: + """Indicates the current thread took control when calling ``set_num_threads()``. + + Includes number of threads set previously, right after control was released. + """ + + previous_num_threads: int class LibController(ABC): @@ -298,8 +308,8 @@ def set_num_threads(self, num_threads): When possible, implementations of this method should choose a thread limiting API that only applies to the current thread. - Returns ``DID_NOT_SET`` if setting did not happen; this is usually due - to not being the controlling thread for OpenBLAS-pthreads. + Implementations may return values tied to internal implementation + details. """ @abstractmethod @@ -571,6 +581,7 @@ def set_num_threads(self, num_threads): return self._real_set_num_threads(num_threads) elif controlling_thread is None or not controlling_thread.is_alive(): # No thread controls OpenBLAS, so try to take control: + previous_num_threads = None with self._SHARED_STATE.lock: # Another thread might have changed it, so ensure that's not # the case: @@ -579,12 +590,16 @@ def set_num_threads(self, num_threads): or not self._SHARED_STATE.controlling_thread.is_alive() ): self._SHARED_STATE.controlling_thread = this_thread - # Controlling thread should now be set, to either this thread or - # another, so try again: - return self.set_num_threads(num_threads) + previous_num_threads = self.get_num_threads() + if previous_num_threads is not None: + self._real_set_num_threads(num_threads) + return _THIS_THREAD_TOOK_CONTROL(previous_num_threads) + else: + # Another thread took control: + return _DID_NOT_SET else: # Some other thread controls OpenBLAS: do nothing. - return DID_NOT_SET + return _DID_NOT_SET def _real_set_num_threads(self, num_threads): # The OpenBLAS limiting API is process-wide, and we want current thread @@ -1197,11 +1212,15 @@ def _set_threadpool_limits(self): if num_threads is not None: result = lib_controller.set_num_threads(num_threads) - if result is DID_NOT_SET: + if result is _DID_NOT_SET: # Didn't set, so shouldn't restore either; if this thread # can become the controlling thread during restoration, it # might restore the wrong value. self._original_info.pop(lib_controller, None) + elif isinstance(result, _THIS_THREAD_TOOK_CONTROL): + self._original_info[lib_controller]["num_threads"] = ( + result.previous_num_threads + ) class _ThreadpoolLimiterDecorator(ContextDecorator): From 2a391268f56eba05f41af1031df6e534691d2cca Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 9 Nov 2026 10:33:23 -0500 Subject: [PATCH 16/24] Explain a bit better --- threadpoolctl.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/threadpoolctl.py b/threadpoolctl.py index 4ec41eb9..f0489ab1 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -679,6 +679,8 @@ def decrement(self): """Decrement ownership.""" self.count -= 1 if self.count <= 0: + # If we're not currently the controlling thread, this will do + # nothing, which is the desired behavior: OpenBLASController.clear_controlling_thread() From b9a13b00b806757632191f9bca7ce1985be490f9 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 9 Nov 2026 12:41:41 -0500 Subject: [PATCH 17/24] Better description --- CHANGES.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index eb7f4c71..c1d5c116 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,10 +1,8 @@ 3.8.1 (under development) ========================= -- When setting the limit failed because the current thread doesn't control - OpenBLAS-pthreads, this information is returned, and `threadpool_limits` uses - this to prevent incorrectly restoring to the wrong limit. This is done both - for context manager usage and for direct calls to `restore_original_limits()`. +- Fix a number of race conditions in OpenBLAS-pthreads controlling thread logic, + that could result in unexpected number of threads being set. https://github.com/joblib/threadpoolctl/pull/300 From 0de10cc4ebafd5f1879bbee929a826b15cf014c1 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 10 Nov 2026 08:35:15 -0500 Subject: [PATCH 18/24] Make additional info from OpenBLASController even more private --- threadpoolctl.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index f0489ab1..a7c45078 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -307,9 +307,6 @@ def set_num_threads(self, num_threads): When possible, implementations of this method should choose a thread limiting API that only applies to the current thread. - - Implementations may return values tied to internal implementation - details. """ @abstractmethod @@ -568,6 +565,9 @@ def clear_controlling_thread(cls, force: bool = False): cls._SHARED_STATE.controlling_thread = None def set_num_threads(self, num_threads): + self._set_num_threads_with_extra_info(num_threads) + + def _set_num_threads_with_extra_info(self, num_threads): if self.threading_layer == "openmp": # Per-thread limits, so no need to be constraining: return self._real_set_num_threads(num_threads) @@ -1213,7 +1213,11 @@ def _set_threadpool_limits(self): continue if num_threads is not None: - result = lib_controller.set_num_threads(num_threads) + if type(lib_controller) == OpenBLASController: + # Special-case API that returns additional information: + result = lib_controller._set_num_threads_with_extra_info(num_threads) + else: + result = lib_controller.set_num_threads(num_threads) if result is _DID_NOT_SET: # Didn't set, so shouldn't restore either; if this thread # can become the controlling thread during restoration, it From 2d5d66deb8c2636f358fb560cfa7ae31544b6f2b Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 10 Nov 2026 08:44:16 -0500 Subject: [PATCH 19/24] Reformat --- threadpoolctl.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index a7c45078..ef53d6f8 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -1215,7 +1215,9 @@ def _set_threadpool_limits(self): if num_threads is not None: if type(lib_controller) == OpenBLASController: # Special-case API that returns additional information: - result = lib_controller._set_num_threads_with_extra_info(num_threads) + result = lib_controller._set_num_threads_with_extra_info( + num_threads + ) else: result = lib_controller.set_num_threads(num_threads) if result is _DID_NOT_SET: From 0bbcfce09a04fc072fedacbcbe01f0202d217574 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 10 Nov 2026 08:53:19 -0500 Subject: [PATCH 20/24] Tweak --- CHANGES.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index c1d5c116..8f1bbc12 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,8 +1,8 @@ 3.8.1 (under development) ========================= -- Fix a number of race conditions in OpenBLAS-pthreads controlling thread logic, - that could result in unexpected number of threads being set. +- Fix a number of race conditions in OpenBLAS-pthreads/win32 controlling thread + logic, that could result in unexpected number of threads being set. https://github.com/joblib/threadpoolctl/pull/300 From e6c0c340f43990e75ae62d2cf62b28a45dbc4ceb Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 10 Nov 2026 09:02:28 -0500 Subject: [PATCH 21/24] Stick to ubuntu-24.04 for now (ubuntu-latest is now 26.04) --- .github/workflows/test.yml | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 29ea90f4..c23505c6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -125,7 +125,7 @@ jobs: # Linux environment with development versions of numpy and scipy - name: pylatest_pip_dev - os : ubuntu-latest + os : ubuntu-24.04 PACKAGER: "pip-dev" PYTHON_VERSION: "*" CC_OUTER_LOOP: "gcc" @@ -134,7 +134,7 @@ jobs: # Linux + Python 3.10 and homogeneous runtime nesting. - name: py310_conda_openblas_clang_clang - os: ubuntu-latest + os: ubuntu-24.04 PACKAGER: "conda" PYTHON_VERSION: "3.10" BLAS: "openblas" @@ -145,7 +145,7 @@ jobs: # Linux environment with MKL and Clang (known to be unsafe for # threadpoolctl) to only test the warning from multiple OpenMP. - name: pylatest_conda_mkl_clang_gcc - os: ubuntu-latest + os: ubuntu-24.04 PYTHON_VERSION: "*" PACKAGER: "conda" BLAS: "mkl" @@ -154,7 +154,7 @@ jobs: TESTS: "libomp_libiomp_warning" # Linux environment with MKL, safe for threadpoolctl. - name: pylatest_conda_mkl_gcc_gcc - os: ubuntu-latest + os: ubuntu-24.04 PYTHON_VERSION: "*" PACKAGER: "conda" BLAS: "mkl" @@ -166,7 +166,7 @@ jobs: # Linux + Python 3.11 with numpy / scipy installed with pip from PyPI # and heterogeneous OpenMP runtimes. - name: py311_pip_openblas_gcc_clang - os: ubuntu-latest + os: ubuntu-24.04 PACKAGER: "pip" PYTHON_VERSION: "3.11" CC_OUTER_LOOP: "gcc" @@ -174,7 +174,7 @@ jobs: # Linux + conda-forge Python RC, extra deps from PyPI. # OpenCV is omitted: no wheels for the current RC ABI yet. - name: pyrc_pip - os: ubuntu-latest + os: ubuntu-24.04 PACKAGER: "pip" # Using the "*" marker does not select the latest RC release # from the conda-forge `python_rc` channel hence the need to pin @@ -185,7 +185,7 @@ jobs: CC_INNER_LOOP: "gcc" # Linux + conda-forge free-threaded Python RC, extra deps from PyPI. - name: pyrc_ft_pip - os: ubuntu-latest + os: ubuntu-24.04 PACKAGER: "pip" # Using the "*" marker does not select the latest RC release # from the conda-forge `python_rc` channel hence the need to pin @@ -197,7 +197,7 @@ jobs: CC_INNER_LOOP: "gcc" # Linux environment with numpy from conda-forge channel and openblas-openmp - name: pylatest_conda_forge - os: ubuntu-latest + os: ubuntu-24.04 PACKAGER: "conda-forge" PYTHON_VERSION: "*" BLAS: "openblas" @@ -207,7 +207,7 @@ jobs: CC_INNER_LOOP: "gcc" # Linux environment with no numpy and heterogeneous OpenMP runtimes. - name: pylatest_conda_nonumpy_gcc_clang - os: ubuntu-latest + os: ubuntu-24.04 PACKAGER: "conda" PYTHON_VERSION: "*" NO_NUMPY: "true" @@ -216,7 +216,7 @@ jobs: # Linux environments with numpy linked to BLIS - name: pylatest_blis_gcc_clang_openmp - os: ubuntu-latest + os: ubuntu-24.04 PYTHON_VERSION: "*" INSTALL_BLAS: "blis" BLIS_NUM_THREAEDS: "4" @@ -225,7 +225,7 @@ jobs: BLIS_CC: "clang-18" BLIS_ENABLE_THREADING: "openmp" - name: pylatest_blis_clang_gcc_pthreads - os: ubuntu-latest + os: ubuntu-24.04 PYTHON_VERSION: "*" INSTALL_BLAS: "blis" BLIS_NUM_THREADS: "4" @@ -234,7 +234,7 @@ jobs: BLIS_CC: "gcc-12" BLIS_ENABLE_THREADING: "pthreads" - name: pylatest_blis_no_threading - os: ubuntu-latest + os: ubuntu-24.04 PYTHON_VERSION: "*" INSTALL_BLAS: "blis" BLIS_NUM_THREADS: "1" @@ -245,7 +245,7 @@ jobs: # Linux env with FlexiBLAS - name: pylatest_flexiblas - os: ubuntu-latest + os: ubuntu-24.04 PYTHON_VERSION: "*" INSTALL_BLAS: "flexiblas" PLATFORM_SPECIFIC_PACKAGES: "mkl" @@ -254,7 +254,7 @@ jobs: # Python 3.14, free-threaded - name: py314_freethreaded_pip_openblas - os: ubuntu-latest + os: ubuntu-24.04 PACKAGER: "conda-forge" BLAS: "openblas" PYTHON_VERSION: "3.14" From 11eaef5e35439dd0bdbfe7f35d4266c79d11a9a2 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 12 Nov 2026 07:16:44 -0500 Subject: [PATCH 22/24] Work for subclasses as well Co-authored-by: Olivier Grisel --- threadpoolctl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index ef53d6f8..931145cd 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -1213,7 +1213,7 @@ def _set_threadpool_limits(self): continue if num_threads is not None: - if type(lib_controller) == OpenBLASController: + if isinstance(lib_controller, OpenBLASController): # Special-case API that returns additional information: result = lib_controller._set_num_threads_with_extra_info( num_threads From f73c3cde564b9fcaefadbbe9596d6c97fac77dc9 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 12 Nov 2026 07:29:57 -0500 Subject: [PATCH 23/24] Switch to back to just that class --- threadpoolctl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index 931145cd..d59f795e 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -1213,7 +1213,7 @@ def _set_threadpool_limits(self): continue if num_threads is not None: - if isinstance(lib_controller, OpenBLASController): + if type(lib_controller) is OpenBLASController: # Special-case API that returns additional information: result = lib_controller._set_num_threads_with_extra_info( num_threads From 24c950cfe8fcd4ba9e364e8e08df5aef5f59f8ce Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 12 Nov 2026 08:37:51 -0500 Subject: [PATCH 24/24] More constrained test --- 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 312b88e0..372d09f8 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -1063,7 +1063,7 @@ def test_restore_original_limits_keeps_openblas_controlling_thread(request): libs, lib, orig = _require_resizable_non_openmp_openblas() request.addfinalizer(_restore_openblas_thread_counts(libs)) - limiter = threadpool_limits(limits=1) + limiter = ThreadpoolController().select(internal_api="openblas").limit(limits=1) assert lib.get_num_threads() == 1 assert lib._SHARED_STATE.controlling_thread is current_thread()