From 7ac42e210a2a7ca1403a1d47d56aa4d218173317 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 29 Oct 2026 14:02:24 -0400 Subject: [PATCH 1/2] Dead threads lose control --- tests/test_threadpoolctl.py | 32 +++++++++++++++++++++++++++++++- threadpoolctl.py | 9 +++++++-- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index 080cc244..c8b1fb9b 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -7,7 +7,7 @@ import shutil import subprocess import sys -from threading import Thread, current_thread +from threading import Lock, Thread, current_thread import pytest @@ -801,11 +801,41 @@ def clear_and_set(): run_in_thread(openblas_lib.set_num_threads, 1) assert openblas_lib.get_num_threads() == 1 + # Dead threads lose control: + openblas_lib.set_num_threads(orig_num_threads) + assert openblas_lib.get_num_threads() == orig_num_threads + + # Release control: + openblas_lib.clear_controlling_thread() + # clear_controlling_thread(force=True) allows any thread to clear: + was_set, finish = Lock(), Lock() + was_set.acquire() + finish.acquire() + + def set_and_exit_after_lock_released(): + openblas_lib.set_num_threads(1) + was_set.release() + finish.acquire() + + t = Thread(target=set_and_exit_after_lock_released) + t.start() + was_set.acquire() + assert openblas_lib.get_num_threads() == 1 + + # This thread can't set since it's not the controller: + openblas_lib.set_num_threads(orig_num_threads) + assert openblas_lib.get_num_threads() == 1 + + # But when forced clear happens, we can set: openblas_lib.clear_controlling_thread(force=True) openblas_lib.set_num_threads(orig_num_threads) assert openblas_lib.get_num_threads() == orig_num_threads + # Clean up + finish.release() + t.join() + def _openblas_controlling_thread(): openblas_controller = ThreadpoolController().select(internal_api="openblas") diff --git a/threadpoolctl.py b/threadpoolctl.py index 47bc8e15..541ba692 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -555,12 +555,17 @@ def set_num_threads(self, num_threads): this_thread = current_thread() match self._SHARED_STATE.controlling_thread: - case None: + case controlling_thread if ( + controlling_thread is None or not controlling_thread.is_alive() + ): # No thread controls OpenBLAS, so try to take control: with self._SHARED_STATE.lock: # Another thread might have changed it, so ensure that's # not the case: - if self._SHARED_STATE.controlling_thread is None: + if ( + self._SHARED_STATE.controlling_thread is None + 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: From 5b212b6117a62f4b6ea4abf9ff287a6a6fddcb19 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 29 Oct 2026 18:11:44 -0400 Subject: [PATCH 2/2] add link to followup PR --- CHANGES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.md b/CHANGES.md index 7f013581..593766c6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -13,6 +13,7 @@ thread controlls OpenBLAS in these cases, and decorator and context manager limit APIs will automatically call that API when they are done. https://github.com/joblib/threadpoolctl/pull/275 + https://github.com/joblib/threadpoolctl/pull/289 - Add a new API to get a cached `ThreadpoolController` instance (`get_cached_controller()`), as an optimization for cases where limits are set