Skip to content

Commit c41fa1f

Browse files
committed
gh-154130: Fix double decref of the dict in shared dict iterators
In the free-threaded build, threads advancing the same dict iterator each read `di_dict` as a borrowed reference, so concurrently exhausting it could run `di->di_dict = NULL; Py_DECREF(d)` more than once and free the dict while other threads were still using it. Reversed iterators were also affected: they dropped the reference while holding the dict's critical section. In the free-threaded build, keep the iterator's reference to the dict until the iterator is deallocated (as list and tuple iterators do) and mark exhaustion by setting `di_pos` to -1.
1 parent f65e2a2 commit c41fa1f

2 files changed

Lines changed: 72 additions & 11 deletions

File tree

‎Lib/test/test_free_threading/test_dict.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,5 +493,42 @@ def race(iter_times: int,
493493
CHURN_SIZE = 512
494494
race(ITER_TIMES, CHURN_SIZE, APPENDER_THREADS, REPLACER_THREADS)
495495

496+
def test_racing_shared_iterator_exhaustion(self):
497+
# gh-154130: exhausting a dict iterator shared between threads must
498+
# not release the iterator's reference to the dict more than once.
499+
makers = [
500+
lambda d: iter(d),
501+
lambda d: iter(d.values()),
502+
lambda d: iter(d.items()),
503+
lambda d: reversed(d),
504+
lambda d: reversed(d.items()),
505+
]
506+
for make in makers:
507+
cell = [make({i: i for i in range(8)})]
508+
509+
def worker():
510+
for _ in range(5000):
511+
it = cell[0]
512+
try:
513+
next(it)
514+
except StopIteration:
515+
cell[0] = make({i: i for i in range(8)})
516+
except RuntimeError:
517+
# Concurrent next() calls may observe each other's
518+
# updates as a modification; only crashes are bugs.
519+
pass
520+
521+
with self.subTest(make=make):
522+
threading_helper.run_concurrently(worker, nthreads=4)
523+
524+
def test_exhausted_iterator_stays_exhausted(self):
525+
d = {1: 1}
526+
for it in (iter(d), iter(d.items()), reversed(d)):
527+
self.assertEqual(len(list(it)), 1)
528+
d[2] = 2
529+
self.assertRaises(StopIteration, next, it)
530+
self.assertEqual(it.__length_hint__(), 0)
531+
del d[2]
532+
496533
if __name__ == "__main__":
497534
unittest.main()

‎Objects/dictobject.c‎

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5705,6 +5705,23 @@ static PyMethodDef dictiter_methods[] = {
57055705
{NULL, NULL} /* sentinel */
57065706
};
57075707

5708+
/* Mark the iterator as exhausted. In the free-threaded build an iterator
5709+
may be advanced by several threads at once, each holding a borrowed
5710+
pointer to di_dict, so the iterator keeps its reference to the dict until
5711+
it is deallocated (as list and tuple iterators do) and records exhaustion
5712+
by setting di_pos to -1. */
5713+
static void
5714+
dictiter_set_exhausted(dictiterobject *di, PyDictObject *d)
5715+
{
5716+
#ifdef Py_GIL_DISABLED
5717+
_Py_atomic_store_ssize_relaxed(&di->di_pos, -1);
5718+
_Py_atomic_store_ssize_relaxed(&di->len, 0);
5719+
#else
5720+
di->di_dict = NULL;
5721+
Py_DECREF(d);
5722+
#endif
5723+
}
5724+
57085725
#ifdef Py_GIL_DISABLED
57095726

57105727
static int
@@ -5970,16 +5987,18 @@ dictiter_iternextitem_lock_held(PyDictObject *d, PyObject *self,
59705987
assert (PyAnyDict_Check(d));
59715988
ASSERT_DICT_LOCKED(d);
59725989

5990+
i = FT_ATOMIC_LOAD_SSIZE_RELAXED(di->di_pos);
5991+
if (i < 0) {
5992+
return -1; /* exhausted */
5993+
}
5994+
59735995
if (di->di_used != d->ma_used) {
59745996
PyErr_SetString(PyExc_RuntimeError,
59755997
"dictionary changed size during iteration");
59765998
di->di_used = -1; /* Make this state sticky */
59775999
return -1;
59786000
}
59796001

5980-
i = FT_ATOMIC_LOAD_SSIZE_RELAXED(di->di_pos);
5981-
5982-
assert(i >= 0);
59836002
if (_PyDict_HasSplitTable(d)) {
59846003
if (i >= d->ma_used)
59856004
goto fail;
@@ -6030,8 +6049,7 @@ dictiter_iternextitem_lock_held(PyDictObject *d, PyObject *self,
60306049
return 0;
60316050

60326051
fail:
6033-
di->di_dict = NULL;
6034-
Py_DECREF(d);
6052+
dictiter_set_exhausted(di, d);
60356053
return -1;
60366054
}
60376055

@@ -6075,6 +6093,11 @@ dictiter_iternext_threadsafe(PyDictObject *d, PyObject *self,
60756093

60766094
assert (PyAnyDict_Check(d));
60776095

6096+
i = _Py_atomic_load_ssize_relaxed(&di->di_pos);
6097+
if (i < 0) {
6098+
return -1; /* exhausted */
6099+
}
6100+
60786101
if (di->di_used != _Py_atomic_load_ssize_relaxed(&d->ma_used)) {
60796102
PyErr_SetString(PyExc_RuntimeError,
60806103
"dictionary changed size during iteration");
@@ -6084,9 +6107,7 @@ dictiter_iternext_threadsafe(PyDictObject *d, PyObject *self,
60846107

60856108
ensure_shared_on_read(d);
60866109

6087-
i = _Py_atomic_load_ssize_relaxed(&di->di_pos);
60886110
k = _Py_atomic_load_ptr_acquire(&d->ma_keys);
6089-
assert(i >= 0);
60906111
if (_PyDict_HasSplitTable(d)) {
60916112
PyDictValues *values = _Py_atomic_load_ptr_consume(&d->ma_values);
60926113
if (values == NULL) {
@@ -6165,8 +6186,7 @@ dictiter_iternext_threadsafe(PyDictObject *d, PyObject *self,
61656186
"dictionary keys changed during iteration");
61666187

61676188
fail:
6168-
di->di_dict = NULL;
6169-
Py_DECREF(d);
6189+
dictiter_set_exhausted(di, d);
61706190
return -1;
61716191

61726192
try_locked:
@@ -6354,8 +6374,7 @@ dictreviter_iter_lock_held(PyDictObject *d, PyObject *self)
63546374
}
63556375

63566376
fail:
6357-
di->di_dict = NULL;
6358-
Py_DECREF(d);
6377+
dictiter_set_exhausted(di, d);
63596378
return NULL;
63606379
}
63616380

@@ -6367,6 +6386,11 @@ dictreviter_iternext(PyObject *self)
63676386

63686387
if (d == NULL)
63696388
return NULL;
6389+
#ifdef Py_GIL_DISABLED
6390+
if (_Py_atomic_load_ssize_relaxed(&di->di_pos) < 0) {
6391+
return NULL; /* exhausted */
6392+
}
6393+
#endif
63706394

63716395
PyObject *value;
63726396
Py_BEGIN_CRITICAL_SECTION(d);

0 commit comments

Comments
 (0)