Skip to content

Commit 5ff7685

Browse files
committed
Custom lock/rlock classes owning the protected data
Compared to the well-known `threading.Lock`, the locks added here wrap the data they protect. It is still possible to modify the data, since Python does not have actual private attributes, but it's no longer possible to do so by accident or by omission: ```python # With the classical approach... _SOME_SHARED_DATA_LOCK = threading.Lock() SOME_SHARED_DATA = set() # ... nothing stops me from changing the set without taking the lock: SOME_SHARED_DATA.add('foo') # On the other hand, with our custom locks, the set does not exist as # a value I could modify directly... SOME_SHARED_DATA: Lock[set[str]] = Lock(set()) # ... and it's "lent" to me only if the wrapper acquires the lock on my # behalf: with SOME_SHARED_DATA as the_wrapped_set: the_wrapped_set.add('foo') ```
1 parent 1cacdb1 commit 5ff7685

2 files changed

Lines changed: 107 additions & 0 deletions

File tree

tests/unit/test_threading.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from tmt.utils._threading import Lock
2+
3+
4+
def test_sanity() -> None:
5+
some_data: list[str] = []
6+
7+
lock: Lock[list[str]] = Lock(some_data)
8+
9+
assert lock._Lock__lock.locked() is False # type: ignore[attr-defined]
10+
assert lock._Lock__value is some_data # type: ignore[attr-defined]
11+
12+
with lock as data:
13+
assert lock._Lock__lock.locked() is True # type: ignore[attr-defined]
14+
assert data is some_data
15+
16+
assert lock._Lock__lock.locked() is False # type: ignore[attr-defined]
17+
assert lock._Lock__value is some_data # type: ignore[attr-defined]

tmt/utils/_threading.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""
2+
Threading and synchronization helpers.
3+
"""
4+
5+
import threading
6+
from typing import Generic, TypeVar
7+
8+
T = TypeVar('T')
9+
LockT = TypeVar('LockT', threading.Lock, threading.RLock)
10+
11+
12+
class _Lock(Generic[LockT, T]):
13+
__lock: LockT
14+
__value: T
15+
16+
def __init__(self, lock: LockT, value: T) -> None:
17+
self.__lock = lock
18+
self.__value = value
19+
20+
def __enter__(self) -> T:
21+
self.__lock.acquire()
22+
23+
return self.__value
24+
25+
def __exit__(self, *args: object) -> None:
26+
self.__lock.release()
27+
28+
29+
class Lock(_Lock[threading.Lock, T]):
30+
"""
31+
A lock protecting its payload from unsynchronized access.
32+
33+
In functionality it is similar to :py:class:`threading.Lock`, but
34+
bundles together the protected value and lock protecting it.
35+
To get the value, code is forced to acquire the lock:
36+
37+
.. code-block:: python
38+
39+
# A list, representing data shared between multiple threads,
40+
# is not assigned to any global name. Instead, it is wrapped by
41+
# the lock, and "borrowed" to caller using the context manager
42+
# approach:
43+
SHARED_DATA: Lock[list[str]] = Lock([])
44+
45+
...
46+
47+
# `data` below is the list given to `Lock()` above:
48+
with SHARED_DATA as data:
49+
data += [...]
50+
51+
Compared to :py:class:`RLock`, ``Lock`` is not reentrant, i.e. it
52+
can be acquired by the same thread only once, another attempt to
53+
acquire the lock while already holding it will end up with a
54+
deadlock. See :py:class:`threading.RLock` for more details on its
55+
reentrancy.
56+
"""
57+
58+
def __init__(self, value: T) -> None:
59+
super().__init__(threading.Lock(), value)
60+
61+
62+
class RLock(_Lock[threading.RLock, T]):
63+
"""
64+
A reentrant variant of :py:class:`Lock`.
65+
66+
In functionality it is similar to :py:class:`threading.RLock`, but
67+
bundles together the protected value and lock protecting it.
68+
To get the value, code is forced to acquire the lock:
69+
70+
.. code-block:: python
71+
72+
# A list, representing data shared between multiple threads,
73+
# is not assigned to any global name. Instead, it is wrapped by
74+
# the lock, and "borrowed" to caller using the context manager
75+
# approach:
76+
SHARED_DATA: Lock[list[str]] = RLock([])
77+
78+
...
79+
80+
# `data` below is the list given to `Lock()` above:
81+
with SHARED_DATA as data:
82+
data += [...]
83+
84+
Compared to :py:class:`Lock`, ``RLock`` is reentrant, i.e. it can
85+
be reacquired by the same thread. See :py:class:`threading.RLock`
86+
for more details on its reentrancy.
87+
"""
88+
89+
def __init__(self, value: T) -> None:
90+
super().__init__(threading.RLock(), value)

0 commit comments

Comments
 (0)