Skip to content

Commit af80f57

Browse files
committed
pythongh-144295: Fix data race in dict method lookup and global load
In `_PyDict_GetMethodStackRef`, only use the fast-path unicode lookup when the dict is owned by the current thread or already marked as shared. This prevents a race between the lookup and concurrent dict resizes, which may free the PyDictKeysObject (i.e., it ensures that the resize uses QSBR). Address a similar issue in `_Py_dict_lookup_threadsafe_stackref` by calling `ensure_shared_on_read()`.
1 parent 48795b6 commit af80f57

File tree

2 files changed

+45
-13
lines changed

2 files changed

+45
-13
lines changed

Lib/test/test_free_threading/test_dict.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,5 +245,27 @@ def reader():
245245
with threading_helper.start_threads([t1, t2]):
246246
pass
247247

248+
def test_racing_dict_update_and_method_lookup(self):
249+
# gh-144295: test race between dict modifications and method lookups.
250+
# Uses BytesIO because the race requires a type without Py_TPFLAGS_INLINE_VALUES
251+
# for the _PyDict_GetMethodStackRef code path.
252+
import io
253+
obj = io.BytesIO()
254+
255+
def writer():
256+
for _ in range(10000):
257+
obj.x = 1
258+
del obj.x
259+
260+
def reader():
261+
for _ in range(10000):
262+
obj.getvalue()
263+
264+
t1 = Thread(target=writer)
265+
t2 = Thread(target=reader)
266+
267+
with threading_helper.start_threads([t1, t2]):
268+
pass
269+
248270
if __name__ == "__main__":
249271
unittest.main()

Objects/dictobject.c

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1615,7 +1615,9 @@ lookup_threadsafe_unicode(PyDictKeysObject *dk, PyObject *key, Py_hash_t hash, _
16151615
Py_ssize_t
16161616
_Py_dict_lookup_threadsafe_stackref(PyDictObject *mp, PyObject *key, Py_hash_t hash, _PyStackRef *value_addr)
16171617
{
1618-
PyDictKeysObject *dk = _Py_atomic_load_ptr(&mp->ma_keys);
1618+
ensure_shared_on_read(mp);
1619+
1620+
PyDictKeysObject *dk = _Py_atomic_load_ptr_acquire(&mp->ma_keys);
16191621
if (dk->dk_kind == DICT_KEYS_UNICODE && PyUnicode_CheckExact(key)) {
16201622
Py_ssize_t ix = lookup_threadsafe_unicode(dk, key, hash, value_addr);
16211623
if (ix != DKIX_KEY_CHANGED) {
@@ -1669,19 +1671,27 @@ _PyDict_GetMethodStackRef(PyDictObject *mp, PyObject *key, _PyStackRef *method)
16691671
Py_hash_t hash = hash_unicode_key(key);
16701672

16711673
#ifdef Py_GIL_DISABLED
1672-
PyDictKeysObject *dk = _Py_atomic_load_ptr_acquire(&mp->ma_keys);
1673-
if (dk->dk_kind == DICT_KEYS_UNICODE) {
1674-
_PyStackRef ref;
1675-
Py_ssize_t ix = lookup_threadsafe_unicode(dk, key, hash, &ref);
1676-
if (ix >= 0) {
1677-
assert(!PyStackRef_IsNull(ref));
1678-
PyStackRef_XSETREF(*method, ref);
1679-
return 1;
1680-
}
1681-
else if (ix == DKIX_EMPTY) {
1682-
return 0;
1674+
// NOTE: We can only do the fast-path lookup if we are on the owning
1675+
// thread or if the dict is already marked as shared so that the load
1676+
// of ma_keys is safe without a lock. We cannot call ensure_shared_on_read()
1677+
// in this code path without incref'ing the dict because the dict is a
1678+
// borrowed reference protected by QSBR, and acquiring the lock could lead
1679+
// to a quiescent state (allowing the dict to be freed).
1680+
if (_Py_IsOwnedByCurrentThread((PyObject *)mp) || IS_DICT_SHARED(mp)) {
1681+
PyDictKeysObject *dk = _Py_atomic_load_ptr_acquire(&mp->ma_keys);
1682+
if (dk->dk_kind == DICT_KEYS_UNICODE) {
1683+
_PyStackRef ref;
1684+
Py_ssize_t ix = lookup_threadsafe_unicode(dk, key, hash, &ref);
1685+
if (ix >= 0) {
1686+
assert(!PyStackRef_IsNull(ref));
1687+
PyStackRef_XSETREF(*method, ref);
1688+
return 1;
1689+
}
1690+
else if (ix == DKIX_EMPTY) {
1691+
return 0;
1692+
}
1693+
assert(ix == DKIX_KEY_CHANGED);
16831694
}
1684-
assert(ix == DKIX_KEY_CHANGED);
16851695
}
16861696
#endif
16871697

0 commit comments

Comments
 (0)