Skip to content

Commit d69e884

Browse files
mpageadorilson
authored andcommitted
pythongh-114271: Make _thread.ThreadHandle thread-safe in free-threaded builds (pythonGH-115190)
Make `_thread.ThreadHandle` thread-safe in free-threaded builds We protect the mutable state of `ThreadHandle` using a `_PyOnceFlag`. Concurrent operations (i.e. `join` or `detach`) on `ThreadHandle` block until it is their turn to execute or an earlier operation succeeds. Once an operation has been applied successfully all future operations complete immediately. The `join()` method is now idempotent. It may be called multiple times but the underlying OS thread will only be joined once. After `join()` succeeds, any future calls to `join()` will succeed immediately. The internal thread handle `detach()` method has been removed.
1 parent 5eb9239 commit d69e884

File tree

5 files changed

+225
-101
lines changed

5 files changed

+225
-101
lines changed

Include/internal/pycore_lock.h

+13
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,10 @@ typedef struct {
136136
uint8_t v;
137137
} PyEvent;
138138

139+
// Check if the event is set without blocking. Returns 1 if the event is set or
140+
// 0 otherwise.
141+
PyAPI_FUNC(int) _PyEvent_IsSet(PyEvent *evt);
142+
139143
// Set the event and notify any waiting threads.
140144
// Export for '_testinternalcapi' shared extension
141145
PyAPI_FUNC(void) _PyEvent_Notify(PyEvent *evt);
@@ -149,6 +153,15 @@ PyAPI_FUNC(void) PyEvent_Wait(PyEvent *evt);
149153
// and 0 if the timeout expired or thread was interrupted.
150154
PyAPI_FUNC(int) PyEvent_WaitTimed(PyEvent *evt, PyTime_t timeout_ns);
151155

156+
// A one-time event notification with reference counting.
157+
typedef struct _PyEventRc {
158+
PyEvent event;
159+
Py_ssize_t refcount;
160+
} _PyEventRc;
161+
162+
_PyEventRc *_PyEventRc_New(void);
163+
void _PyEventRc_Incref(_PyEventRc *erc);
164+
void _PyEventRc_Decref(_PyEventRc *erc);
152165

153166
// _PyRawMutex implements a word-sized mutex that that does not depend on the
154167
// parking lot API, and therefore can be used in the parking lot

Lib/test/test_thread.py

+47-44
Original file line numberDiff line numberDiff line change
@@ -189,8 +189,8 @@ def task():
189189
with threading_helper.wait_threads_exit():
190190
handle = thread.start_joinable_thread(task)
191191
handle.join()
192-
with self.assertRaisesRegex(ValueError, "not joinable"):
193-
handle.join()
192+
# Subsequent join() calls should succeed
193+
handle.join()
194194

195195
def test_joinable_not_joined(self):
196196
handle_destroyed = thread.allocate_lock()
@@ -233,58 +233,61 @@ def task():
233233
with self.assertRaisesRegex(RuntimeError, "Cannot join current thread"):
234234
raise errors[0]
235235

236-
def test_detach_from_self(self):
237-
errors = []
238-
handles = []
239-
start_joinable_thread_returned = thread.allocate_lock()
240-
start_joinable_thread_returned.acquire()
241-
thread_detached = thread.allocate_lock()
242-
thread_detached.acquire()
236+
def test_join_then_self_join(self):
237+
# make sure we can't deadlock in the following scenario with
238+
# threads t0 and t1 (see comment in `ThreadHandle_join()` for more
239+
# details):
240+
#
241+
# - t0 joins t1
242+
# - t1 self joins
243+
def make_lock():
244+
lock = thread.allocate_lock()
245+
lock.acquire()
246+
return lock
247+
248+
error = None
249+
self_joiner_handle = None
250+
self_joiner_started = make_lock()
251+
self_joiner_barrier = make_lock()
252+
def self_joiner():
253+
nonlocal error
254+
255+
self_joiner_started.release()
256+
self_joiner_barrier.acquire()
243257

244-
def task():
245-
start_joinable_thread_returned.acquire()
246258
try:
247-
handles[0].detach()
259+
self_joiner_handle.join()
248260
except Exception as e:
249-
errors.append(e)
250-
finally:
251-
thread_detached.release()
261+
error = e
262+
263+
joiner_started = make_lock()
264+
def joiner():
265+
joiner_started.release()
266+
self_joiner_handle.join()
252267

253268
with threading_helper.wait_threads_exit():
254-
handle = thread.start_joinable_thread(task)
255-
handles.append(handle)
256-
start_joinable_thread_returned.release()
257-
thread_detached.acquire()
258-
with self.assertRaisesRegex(ValueError, "not joinable"):
259-
handle.join()
269+
self_joiner_handle = thread.start_joinable_thread(self_joiner)
270+
# Wait for the self-joining thread to start
271+
self_joiner_started.acquire()
260272

261-
assert len(errors) == 0
273+
# Start the thread that joins the self-joiner
274+
joiner_handle = thread.start_joinable_thread(joiner)
262275

263-
def test_detach_then_join(self):
264-
lock = thread.allocate_lock()
265-
lock.acquire()
276+
# Wait for the joiner to start
277+
joiner_started.acquire()
266278

267-
def task():
268-
lock.acquire()
279+
# Not great, but I don't think there's a deterministic way to make
280+
# sure that the self-joining thread has been joined.
281+
time.sleep(0.1)
269282

270-
with threading_helper.wait_threads_exit():
271-
handle = thread.start_joinable_thread(task)
272-
# detach() returns even though the thread is blocked on lock
273-
handle.detach()
274-
# join() then cannot be called anymore
275-
with self.assertRaisesRegex(ValueError, "not joinable"):
276-
handle.join()
277-
lock.release()
278-
279-
def test_join_then_detach(self):
280-
def task():
281-
pass
283+
# Unblock the self-joiner
284+
self_joiner_barrier.release()
282285

283-
with threading_helper.wait_threads_exit():
284-
handle = thread.start_joinable_thread(task)
285-
handle.join()
286-
with self.assertRaisesRegex(ValueError, "not joinable"):
287-
handle.detach()
286+
self_joiner_handle.join()
287+
joiner_handle.join()
288+
289+
with self.assertRaisesRegex(RuntimeError, "Cannot join current thread"):
290+
raise error
288291

289292

290293
class Barrier:

Lib/threading.py

+7-17
Original file line numberDiff line numberDiff line change
@@ -931,7 +931,6 @@ class is implemented.
931931
if _HAVE_THREAD_NATIVE_ID:
932932
self._native_id = None
933933
self._tstate_lock = None
934-
self._join_lock = None
935934
self._handle = None
936935
self._started = Event()
937936
self._is_stopped = False
@@ -956,14 +955,11 @@ def _after_fork(self, new_ident=None):
956955
if self._tstate_lock is not None:
957956
self._tstate_lock._at_fork_reinit()
958957
self._tstate_lock.acquire()
959-
if self._join_lock is not None:
960-
self._join_lock._at_fork_reinit()
961958
else:
962959
# This thread isn't alive after fork: it doesn't have a tstate
963960
# anymore.
964961
self._is_stopped = True
965962
self._tstate_lock = None
966-
self._join_lock = None
967963
self._handle = None
968964

969965
def __repr__(self):
@@ -996,8 +992,6 @@ def start(self):
996992
if self._started.is_set():
997993
raise RuntimeError("threads can only be started once")
998994

999-
self._join_lock = _allocate_lock()
1000-
1001995
with _active_limbo_lock:
1002996
_limbo[self] = self
1003997
try:
@@ -1167,17 +1161,9 @@ def join(self, timeout=None):
11671161
self._join_os_thread()
11681162

11691163
def _join_os_thread(self):
1170-
join_lock = self._join_lock
1171-
if join_lock is None:
1172-
return
1173-
with join_lock:
1174-
# Calling join() multiple times would raise an exception
1175-
# in one of the callers.
1176-
if self._handle is not None:
1177-
self._handle.join()
1178-
self._handle = None
1179-
# No need to keep this around
1180-
self._join_lock = None
1164+
# self._handle may be cleared post-fork
1165+
if self._handle is not None:
1166+
self._handle.join()
11811167

11821168
def _wait_for_tstate_lock(self, block=True, timeout=-1):
11831169
# Issue #18808: wait for the thread state to be gone.
@@ -1478,6 +1464,10 @@ def __init__(self):
14781464
with _active_limbo_lock:
14791465
_active[self._ident] = self
14801466

1467+
def _join_os_thread(self):
1468+
# No ThreadHandle for main thread
1469+
pass
1470+
14811471

14821472
# Helper thread-local instance to detect when a _DummyThread
14831473
# is collected. Not a part of the public API.

0 commit comments

Comments
 (0)