Skip to content

Commit 9d46ce9

Browse files
authored
Merge pull request #4455 from tybug/free-threading-global-random-lock
Add a lock to `deterministic_PRNG`
2 parents 3f18ab4 + d1bf8c7 commit 9d46ce9

7 files changed

Lines changed: 115 additions & 15 deletions

File tree

hypothesis-python/RELEASE.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
RELEASE_TYPE: patch
2+
3+
Improve threading compatibility of an internal helper for managing deterministic rng seeding.

hypothesis-python/src/hypothesis/core.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@
140140
SearchStrategy,
141141
check_strategy,
142142
)
143+
from hypothesis.utils.threading import ThreadLocal
143144
from hypothesis.vendor.pretty import RepresentationPrinter
144145
from hypothesis.version import __version__
145146

@@ -149,7 +150,11 @@
149150
running_under_pytest = False
150151
pytest_shows_exceptiongroups = True
151152
global_force_seed = None
152-
_hypothesis_global_random = None
153+
# `threadlocal` stores "engine-global" constants, which are global relative to a
154+
# ConjectureRunner instance (roughly speaking). Since only one conjecture runner
155+
# instance can be active per thread, making engine constants thread-local prevents
156+
# the ConjectureRunner instances of concurrent threads from treading on each other.
157+
threadlocal = ThreadLocal(_hypothesis_global_random=None)
153158

154159

155160
@dataclass
@@ -703,10 +708,9 @@ def get_random_for_wrapped_test(test, wrapped_test):
703708
elif global_force_seed is not None:
704709
return Random(global_force_seed)
705710
else:
706-
global _hypothesis_global_random
707-
if _hypothesis_global_random is None: # pragma: no cover
708-
_hypothesis_global_random = Random()
709-
seed = _hypothesis_global_random.getrandbits(128)
711+
if threadlocal._hypothesis_global_random is None: # pragma: no cover
712+
threadlocal._hypothesis_global_random = Random()
713+
seed = threadlocal._hypothesis_global_random.getrandbits(128)
710714
wrapped_test._hypothesis_internal_use_generated_seed = seed
711715
return Random(seed)
712716

hypothesis-python/src/hypothesis/internal/entropy.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,9 +195,11 @@ def deterministic_PRNG(seed: int = 0) -> Generator[None, None, None]:
195195
bad idea in principle, and breaks all kinds of independence assumptions
196196
in practice.
197197
"""
198-
if hypothesis.core._hypothesis_global_random is None: # pragma: no cover
199-
hypothesis.core._hypothesis_global_random = random.Random()
200-
register_random(hypothesis.core._hypothesis_global_random)
198+
if (
199+
hypothesis.core.threadlocal._hypothesis_global_random is None
200+
): # pragma: no cover
201+
hypothesis.core.threadlocal._hypothesis_global_random = random.Random()
202+
register_random(hypothesis.core.threadlocal._hypothesis_global_random)
201203

202204
seed_all, restore_all = get_seeder_and_restorer(seed)
203205
seed_all()
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# This file is part of Hypothesis, which may be found at
2+
# https://github.com/HypothesisWorks/hypothesis/
3+
#
4+
# Copyright the Hypothesis Authors.
5+
# Individual contributors are listed in AUTHORS.rst and the git log.
6+
#
7+
# This Source Code Form is subject to the terms of the Mozilla Public License,
8+
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
9+
# obtain one at https://mozilla.org/MPL/2.0/.
10+
11+
import threading
12+
from typing import Any
13+
14+
15+
class ThreadLocal:
16+
"""
17+
Manages thread-local state. ThreadLocal forwards getattr and setattr to a
18+
threading.local() instance. The passed kwargs defines the available attributes
19+
on the threadlocal and their default values.
20+
21+
The only supported names to geattr and setattr are the keys of the passed kwargs.
22+
"""
23+
24+
def __init__(self, **kwargs: Any) -> None:
25+
self.__initialized = False
26+
self.__kwargs = kwargs
27+
self.__threadlocal = threading.local()
28+
self.__initialized = True
29+
30+
def __getattr__(self, name: str) -> Any:
31+
if name not in self.__kwargs:
32+
raise AttributeError(f"No attribute {name}")
33+
if not hasattr(self.__threadlocal, name):
34+
setattr(self.__threadlocal, name, self.__kwargs[name])
35+
return getattr(self.__threadlocal, name)
36+
37+
def __setattr__(self, name: str, value: Any) -> None:
38+
# disable attribute-forwarding while initializing
39+
if "_ThreadLocal__initialized" not in self.__dict__ or not self.__initialized:
40+
super().__setattr__(name, value)
41+
else:
42+
if name not in self.__kwargs:
43+
raise AttributeError(f"No attribute {name}")
44+
setattr(self.__threadlocal, name, value)

hypothesis-python/tests/cover/test_random_module.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,11 +133,11 @@ def test(r):
133133

134134
test()
135135
state_a = random.getstate()
136-
state_a2 = core._hypothesis_global_random.getstate()
136+
state_a2 = core.threadlocal._hypothesis_global_random.getstate()
137137

138138
test()
139139
state_b = random.getstate()
140-
state_b2 = core._hypothesis_global_random.getstate()
140+
state_b2 = core.threadlocal._hypothesis_global_random.getstate()
141141

142142
assert state_a == state_b
143143
assert state_a2 != state_b2
@@ -147,11 +147,11 @@ def test_find_does_not_pollute_state():
147147
with deterministic_PRNG():
148148
find(st.random_module(), lambda r: True)
149149
state_a = random.getstate()
150-
state_a2 = core._hypothesis_global_random.getstate()
150+
state_a2 = core.threadlocal._hypothesis_global_random.getstate()
151151

152152
find(st.random_module(), lambda r: True)
153153
state_b = random.getstate()
154-
state_b2 = core._hypothesis_global_random.getstate()
154+
state_b2 = core.threadlocal._hypothesis_global_random.getstate()
155155

156156
assert state_a == state_b
157157
assert state_a2 != state_b2
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# This file is part of Hypothesis, which may be found at
2+
# https://github.com/HypothesisWorks/hypothesis/
3+
#
4+
# Copyright the Hypothesis Authors.
5+
# Individual contributors are listed in AUTHORS.rst and the git log.
6+
#
7+
# This Source Code Form is subject to the terms of the Mozilla Public License,
8+
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
9+
# obtain one at https://mozilla.org/MPL/2.0/.
10+
11+
import pytest
12+
13+
from hypothesis.utils.threading import ThreadLocal
14+
15+
16+
def test_threadlocal_setattr_and_getattr():
17+
threadlocal = ThreadLocal(a=1, b=2)
18+
assert threadlocal.a == 1
19+
assert threadlocal.b == 2
20+
# check that we didn't add attributes to the ThreadLocal instance itself
21+
# instead of its threading.local() variable
22+
assert set(threadlocal.__dict__) == {
23+
"_ThreadLocal__initialized",
24+
"_ThreadLocal__kwargs",
25+
"_ThreadLocal__threadlocal",
26+
}
27+
28+
threadlocal.a = 3
29+
assert threadlocal.a == 3
30+
assert threadlocal.b == 2
31+
assert set(threadlocal.__dict__) == {
32+
"_ThreadLocal__initialized",
33+
"_ThreadLocal__kwargs",
34+
"_ThreadLocal__threadlocal",
35+
}
36+
37+
38+
def test_nonexistent_getattr_raises():
39+
threadlocal = ThreadLocal(a=1)
40+
with pytest.raises(AttributeError):
41+
_c = threadlocal.c
42+
43+
44+
def test_nonexistent_setattr_raises():
45+
threadlocal = ThreadLocal(a=1)
46+
with pytest.raises(AttributeError):
47+
threadlocal.c = 2

hypothesis-python/tests/nocover/test_randomization.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,11 @@ def f1(n):
4040
def f2(n):
4141
choices2.append(n)
4242

43-
core._hypothesis_global_random = Random(0)
44-
state = core._hypothesis_global_random.getstate()
43+
core.threadlocal._hypothesis_global_random = Random(0)
44+
state = core.threadlocal._hypothesis_global_random.getstate()
4545
f1()
4646

47-
core._hypothesis_global_random.setstate(state)
47+
core.threadlocal._hypothesis_global_random.setstate(state)
4848
f2()
4949

5050
assert choices1 == choices2

0 commit comments

Comments
 (0)