From fa8717ffb4b327c0b0e0dcc4dcffb59a4b378207 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 8 Sep 2026 10:45:08 -0400 Subject: [PATCH 01/11] Hold on to the GIL to prevent deadlocks. --- threadpoolctl.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index 72fda34f..21274d63 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -1409,7 +1409,22 @@ def _get_libc(cls): # libc symbols. We still name it libc for convenience. # If the main program does not contain the libc symbols, it's ok because # we check their presence later anyway. - libc = ctypes.CDLL(find_library("c"), mode=_RTLD_NOLOAD) + # + # This uses PyDLL to prevent deadlocks. If CDLL were used, you can + # get situation where the following happens: + # + # 1. Thread A via threadpoolctl calls dl_iterate_phdr, which + # acquires an internal dl lock. + # 2. Thread B, holding the GIL, calls some API that internally uses + # dl_iterate_phdr. For example, a backtrace() from NumPy can + # sometimes trigger that. This results in trying to acquire an + # internal dl lock, which is already held. + # 3. Thread A calls back into Python, requiring it to reacquire the GIL. + # 4. Deadlock! + # + # Using PyDLL prevents this situation by ensuring the order is + # always first GIL, then dl_iterate_phdr. + libc = ctypes.PyDLL(find_library("c"), mode=_RTLD_NOLOAD) cls._system_libraries["libc"] = libc return libc From 6bbb723fb75100a9b6ad77512b7cdf838b1a3c6d Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 8 Sep 2026 10:45:17 -0400 Subject: [PATCH 02/11] While we're at it, use Python's built-in dllist. --- threadpoolctl.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index 21274d63..cd8daca1 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -25,6 +25,12 @@ from functools import lru_cache from contextlib import ContextDecorator +if sys.version_info[:2] >= (3, 14): + from ctypes.util import dllist +else: + dllist = None + + __version__ = "3.7.0.dev0" __all__ = [ "threadpool_limits", @@ -1121,7 +1127,15 @@ def __len__(self): def _load_libraries(self): """Loop through loaded shared libraries and store the supported ones""" - if sys.platform == "darwin": + if dllist is not None and sys.platform != "emscripten": + # On Python 3.14+, this functionality is built-in. Usefully, it + # holds the GIL throughout for dl_iterate_phdr, which can prevent + # deadlocks with GIL and internal dl locks. + # + # Once Python 3.13 is no longer supported by threadpoolctl, the + # equivalent threadpoolctl implementations can be removed. + self._find_libraries_with_python() + elif sys.platform == "darwin": self._find_libraries_with_dyld() elif sys.platform == "win32": self._find_libraries_with_enum_process_module_ex() @@ -1130,6 +1144,18 @@ def _load_libraries(self): else: self._find_libraries_with_dl_iterate_phdr() + def _find_libraries_with_python(self): + """Loop through loaded libraries and return binders on supported ones + + Uses Python's built-in support for this functionality. + """ + assert dllist is not None + filepaths = dllist() + if filepaths and filepaths[0] in ("", sys.executable): + filepaths = filepaths[1:] + for filepath in filepaths: + self._make_controller_from_path(filepath) + def _find_libraries_with_dl_iterate_phdr(self): """Loop through loaded libraries and return binders on supported ones From 636a5349305e3f672c5b236797544551224e63a7 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 8 Sep 2026 12:27:31 -0400 Subject: [PATCH 03/11] Protect access to dl_iterate_phdr() with a lock. --- threadpoolctl.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index cd8daca1..3b11c85d 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -17,7 +17,7 @@ import ctypes import itertools import textwrap -from threading import Thread +from threading import Lock, Thread from typing import Callable, Literal, final import warnings from ctypes.util import find_library @@ -41,6 +41,11 @@ ] +# Prevent GIL + dl locks from causing deadlocks when they get acquired in +# different orders, by ensuring dl_iterate_phdr() is only called by one thread. +_DL_ITERATE_PHDR_LOCK = Lock() + + # One can get runtime errors or even segfaults due to multiple OpenMP libraries # loaded simultaneously which can happen easily in Python when importing and # using compiled extensions built with different compilers and therefore @@ -1150,7 +1155,8 @@ def _find_libraries_with_python(self): Uses Python's built-in support for this functionality. """ assert dllist is not None - filepaths = dllist() + with _DL_ITERATE_PHDR_LOCK: + filepaths = dllist() if filepaths and filepaths[0] in ("", sys.executable): filepaths = filepaths[1:] for filepath in filepaths: @@ -1197,7 +1203,8 @@ def match_library_callback(info, size, data): c_match_library_callback = c_func_signature(match_library_callback) data = ctypes.c_char_p(b"") - libc.dl_iterate_phdr(c_match_library_callback, data) + with _DL_ITERATE_PHDR_LOCK: + libc.dl_iterate_phdr(c_match_library_callback, data) # Now that a list of filepaths is available, load the respective # libraries: From f80da80aea91064791c51bb7b1ed1d055ad76442 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 8 Sep 2026 12:30:17 -0400 Subject: [PATCH 04/11] Changelog entry --- CHANGES.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index ce3c8a39..4942126f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -31,6 +31,12 @@ depend on how OpenBLAS was compiled with OpenMP. https://github.com/joblib/threadpoolctl/pull/228 +- Fixed a deadlock on Linux when using threadpoolctl from multiple threads. + https://github.com/joblib/threadpoolctl/pull/243 + +- Start using Python 3.14's built-in support for listening shared libraries. + https://github.com/joblib/threadpoolctl/pull/243 + 3.6.0 (2025-03-13) ================== From 539c2efeaf1308173e9107e5f17db56dd249fda8 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 10 Sep 2026 11:51:56 -0400 Subject: [PATCH 05/11] Test that demonstrates deadlocks, on Conda-Forge envs at least. --- tests/test_threadpoolctl.py | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index adf83d62..03817390 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -1,5 +1,6 @@ from __future__ import annotations +import ctypes import json import os import pytest @@ -7,6 +8,7 @@ import subprocess import sys from shutil import which +from threading import Thread from threadpoolctl import threadpool_limits, threadpool_info from threadpoolctl import LibController, ThreadpoolController @@ -952,3 +954,39 @@ def test_conda_blas_detection_after_import(module): if each["internal_api"] != "flexiblas" ] assert set(blas_names_from_threadpoolctl).issubset(blas_names_from_conda) + + +def test_controller_parallelism_no_deadlocks(): + """ + Creating a controller in parallel to itself does not cause deadlocks. + + Non-regression test for https://github.com/joblib/threadpoolctl/issues/239 + + Lacking the fixes from this PR, this deadlocks on Conda environments, at + least, but possibly not on PyPI with Python from a Linux distro. + """ + if sys.platform != "linux" or not hasattr(ctypes.PyDLL(None), "backtrace"): + pytest.skip("Requires glibc on Linux") + + # Internally, backtrace() calls dl_iterate_phdr which can result in + # deadlocks with threadpoolctl's usage of dl_iterate_phdr. + backtrace_gil = ctypes.PyDLL(None).backtrace + backtrace_gil.argtypes = [ctypes.c_void_p, ctypes.c_int] + backtrace_nogil = ctypes.CDLL(None).backtrace + backtrace_nogil.argtypes = [ctypes.c_void_p, ctypes.c_int] + + def create_controllers(): + buf = (ctypes.c_void_p * 20)() + for _ in range(100): + limiter = threadpool_limits() + backtrace_gil(buf, 20) + backtrace_nogil(buf, 20) + + threads = [] + for _ in range(os.cpu_count() * 4): + t = Thread(target=create_controllers) + threads.append(t) + t.start() + + for t in threads: + t.join() From 7fda3b8fd2d2cc74f856ee5581ddb8581da33c33 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 10 Sep 2026 12:48:51 -0400 Subject: [PATCH 06/11] Instead of trying to prevent deadlocks with lock management, switch to a mechanism that avoids the locks. --- threadpoolctl.py | 65 ++++++++++++++++++++++++------------------------ 1 file changed, 32 insertions(+), 33 deletions(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index 78637f46..bd098f16 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -17,7 +17,7 @@ import ctypes import itertools import textwrap -from threading import Lock, Thread +from threading import Thread from typing import Callable, Literal, final import warnings from ctypes.util import find_library @@ -41,11 +41,6 @@ ] -# Prevent GIL + dl locks from causing deadlocks when they get acquired in -# different orders, by ensuring dl_iterate_phdr() is only called by one thread. -_DL_ITERATE_PHDR_LOCK = Lock() - - # One can get runtime errors or even segfaults due to multiple OpenMP libraries # loaded simultaneously which can happen easily in Python when importing and # using compiled extensions built with different compilers and therefore @@ -1133,13 +1128,17 @@ def __len__(self): def _load_libraries(self): """Loop through loaded shared libraries and store the supported ones""" - if dllist is not None and sys.platform != "emscripten": - # On Python 3.14+, this functionality is built-in. Usefully, it - # holds the GIL throughout for dl_iterate_phdr, which can prevent - # deadlocks with GIL and internal dl locks. - # - # Once Python 3.13 is no longer supported by threadpoolctl, the - # equivalent threadpoolctl implementations can be removed. + if sys.platform == "linux" and os.path.exists("/proc/self/maps"): + # On glibc, dl_iterate_phdr has an internal lock, and that plus + # calling back into Python and the need to (re)acquire the GIL + # results in deadlocks. To avoid that, use a Linux-specific + # mechanism that doesn't have these issues; since it's Linux, musl + # works fine too. + self._find_libraries_with_linux() + elif dllist is not None and sys.platform != "emscripten": + # On Python 3.14+, this functionality is built-in. Once Python 3.13 + # is no longer supported by threadpoolctl, most of the equivalent + # threadpoolctl implementations can be removed. self._find_libraries_with_python() elif sys.platform == "darwin": self._find_libraries_with_dyld() @@ -1148,16 +1147,32 @@ def _load_libraries(self): elif "pyodide" in sys.modules: self._find_libraries_pyodide() else: + # Non-Linux Unix platforms. self._find_libraries_with_dl_iterate_phdr() + def _find_libraries_with_linux(self): + """Loop through loaded libraries and return binders on supported ones + + Uses a Linux-specific mechanism. + """ + with open("/proc/self/maps") as f: + maps = f.read() + filepaths = set() + for line in maps.splitlines(): + start_index = line.find("/") + if start_index == -1 or ".so" not in line: + continue + filepaths.add(line[start_index:]) + for filepath in filepaths: + self._make_controller_from_path(filepath) + def _find_libraries_with_python(self): """Loop through loaded libraries and return binders on supported ones Uses Python's built-in support for this functionality. """ assert dllist is not None - with _DL_ITERATE_PHDR_LOCK: - filepaths = dllist() + filepaths = dllist() if filepaths and filepaths[0] in ("", sys.executable): filepaths = filepaths[1:] for filepath in filepaths: @@ -1204,8 +1219,7 @@ def match_library_callback(info, size, data): c_match_library_callback = c_func_signature(match_library_callback) data = ctypes.c_char_p(b"") - with _DL_ITERATE_PHDR_LOCK: - libc.dl_iterate_phdr(c_match_library_callback, data) + libc.dl_iterate_phdr(c_match_library_callback, data) # Now that a list of filepaths is available, load the respective # libraries: @@ -1443,22 +1457,7 @@ def _get_libc(cls): # libc symbols. We still name it libc for convenience. # If the main program does not contain the libc symbols, it's ok because # we check their presence later anyway. - # - # This uses PyDLL to prevent deadlocks. If CDLL were used, you can - # get situation where the following happens: - # - # 1. Thread A via threadpoolctl calls dl_iterate_phdr, which - # acquires an internal dl lock. - # 2. Thread B, holding the GIL, calls some API that internally uses - # dl_iterate_phdr. For example, a backtrace() from NumPy can - # sometimes trigger that. This results in trying to acquire an - # internal dl lock, which is already held. - # 3. Thread A calls back into Python, requiring it to reacquire the GIL. - # 4. Deadlock! - # - # Using PyDLL prevents this situation by ensuring the order is - # always first GIL, then dl_iterate_phdr. - libc = ctypes.PyDLL(find_library("c"), mode=_RTLD_NOLOAD) + libc = ctypes.CDLL(find_library("c"), mode=_RTLD_NOLOAD) cls._system_libraries["libc"] = libc return libc From 2960ab37b0222fea465e3757265e8c0aba39b4b8 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 10 Sep 2026 12:50:49 -0400 Subject: [PATCH 07/11] Improve test docs --- tests/test_threadpoolctl.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index 03817390..652f8ae0 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -962,14 +962,14 @@ def test_controller_parallelism_no_deadlocks(): Non-regression test for https://github.com/joblib/threadpoolctl/issues/239 - Lacking the fixes from this PR, this deadlocks on Conda environments, at + Lacking the fixes from PR #243, this deadlocks on Conda environments, at least, but possibly not on PyPI with Python from a Linux distro. """ if sys.platform != "linux" or not hasattr(ctypes.PyDLL(None), "backtrace"): - pytest.skip("Requires glibc on Linux") + pytest.skip("Testing glibc on Linux") # Internally, backtrace() calls dl_iterate_phdr which can result in - # deadlocks with threadpoolctl's usage of dl_iterate_phdr. + # deadlocks if threadpoolctl is also using dl_iterate_phdr. backtrace_gil = ctypes.PyDLL(None).backtrace backtrace_gil.argtypes = [ctypes.c_void_p, ctypes.c_int] backtrace_nogil = ctypes.CDLL(None).backtrace From d0bd959aa5b26b5eff89c2be56e815ac491c0230 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 10 Sep 2026 12:54:18 -0400 Subject: [PATCH 08/11] Update changelog --- CHANGES.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index af192cf6..b0b5e3bf 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -37,9 +37,11 @@ - Fixed a deadlock on Linux when using threadpoolctl from multiple threads. https://github.com/joblib/threadpoolctl/pull/243 -- Start using Python 3.14's built-in support for listening shared libraries. +- Start using Python 3.14's built-in support for listing shared libraries. https://github.com/joblib/threadpoolctl/pull/243 +- On Linux, start using /proc/self/maps for listing shared libraries. + https://github.com/joblib/threadpoolctl/pull/243 3.6.0 (2025-03-13) ================== From c7e3945a8deae7b671ce072e3085eac52f21e077 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 10 Sep 2026 12:55:25 -0400 Subject: [PATCH 09/11] Clarification --- threadpoolctl.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index bd098f16..a0dc6386 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -1139,6 +1139,9 @@ def _load_libraries(self): # On Python 3.14+, this functionality is built-in. Once Python 3.13 # is no longer supported by threadpoolctl, most of the equivalent # threadpoolctl implementations can be removed. + # + # We don't use this on Linux since it uses dl_iterate_phdr + # internally and so might still have deadlock issues. self._find_libraries_with_python() elif sys.platform == "darwin": self._find_libraries_with_dyld() @@ -1153,7 +1156,8 @@ def _load_libraries(self): def _find_libraries_with_linux(self): """Loop through loaded libraries and return binders on supported ones - Uses a Linux-specific mechanism. + Uses a Linux-specific mechanism: + https://man7.org/linux/man-pages/man5/proc_pid_maps.5.html """ with open("/proc/self/maps") as f: maps = f.read() From 479e156642be08a5fca9278a025e7e37a4e5d31b Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 10 Sep 2026 12:59:39 -0400 Subject: [PATCH 10/11] More robust handling of paths --- threadpoolctl.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index a0dc6386..d675f184 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -1166,7 +1166,10 @@ def _find_libraries_with_linux(self): start_index = line.find("/") if start_index == -1 or ".so" not in line: continue - filepaths.add(line[start_index:]) + filepath = line[start_index:] + if os.path.exists(filepath): + filepaths.add(filepath) + for filepath in filepaths: self._make_controller_from_path(filepath) From 6dcd6fd4cd431de9c9d7a3f00e1a81ea2c822301 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 10 Sep 2026 14:06:54 -0400 Subject: [PATCH 11/11] More robust way to import dllist, e.g. for wasm --- threadpoolctl.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index d675f184..3faf26e1 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -25,9 +25,9 @@ from functools import lru_cache from contextlib import ContextDecorator -if sys.version_info[:2] >= (3, 14): +try: from ctypes.util import dllist -else: +except ImportError: dllist = None