Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 14 additions & 14 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -166,15 +166,15 @@ 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"
CC_INNER_LOOP: "clang-18"
# 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
Expand All @@ -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
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand Down
7 changes: 6 additions & 1 deletion CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
3.9.0 (under development)
3.8.1 (under development)
=========================

- 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


3.8.0 (2026-11-05)
==================

Expand Down
142 changes: 140 additions & 2 deletions tests/test_threadpoolctl.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import subprocess
import sys
import time
from threading import Lock, Thread, current_thread
from threading import Event, Lock, Thread, current_thread

import pytest

Expand Down Expand Up @@ -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()

Expand All @@ -1075,6 +1075,144 @@ def test_restore_original_limits_keeps_openblas_controlling_thread(request):
assert lib._SHARED_STATE.controlling_thread is None


def _race_condition_skip_problem_platforms():
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."
)


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")
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():
a_in.wait()
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:
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


def test_overlapping_set_and_restore(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")
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


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(
Expand Down
Loading
Loading