Skip to content

Commit 388e1ca

Browse files
authored
gh-115999: Make list and tuple iteration more thread-safe. (#128637)
Make tuple iteration more thread-safe, and actually test concurrent iteration of tuple, range and list. (This is prep work for enabling specialization of FOR_ITER in free-threaded builds.) The basic premise is: Iterating over a shared iterable (list, tuple or range) should be safe, not involve data races, and behave like iteration normally does. Using a shared iterator should not crash or involve data races, and should only produce items regular iteration would produce. It is not guaranteed to produce all items, or produce each item only once. (This is not the case for range iteration even after this PR.) Providing stronger guarantees is possible for some of these iterators, but it's not always straight-forward and can significantly hamper the common case. Since iterators in general aren't shared between threads, and it's simply impossible to concurrently use many iterators (like generators), better to make sharing iterators without explicit synchronization clearly wrong. Specific issues fixed in order to make the tests pass: - List iteration could occasionally fail an assertion when a shared list was shrunk and an item past the new end was retrieved concurrently. There's still some unsafety when deleting/inserting multiple items through for example slice assignment, which uses memmove/memcpy. - Tuple iteration could occasionally crash when the iterator's reference to the tuple was cleared on exhaustion. Like with list iteration, in free-threaded builds we can't safely and efficiently clear the iterator's reference to the iterable (doing it safely would mean extra, slow refcount operations), so just keep the iterable reference around.
1 parent 736ad66 commit 388e1ca

File tree

4 files changed

+177
-18
lines changed

4 files changed

+177
-18
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import threading
2+
import unittest
3+
from test import support
4+
5+
# The race conditions these tests were written for only happen every now and
6+
# then, even with the current numbers. To find rare race conditions, bumping
7+
# these up will help, but it makes the test runtime highly variable under
8+
# free-threading. Overhead is much higher under ThreadSanitizer, but it's
9+
# also much better at detecting certain races, so we don't need as many
10+
# items/threads.
11+
if support.check_sanitizer(thread=True):
12+
NUMITEMS = 1000
13+
NUMTHREADS = 2
14+
else:
15+
NUMITEMS = 100000
16+
NUMTHREADS = 5
17+
NUMMUTATORS = 2
18+
19+
class ContendedTupleIterationTest(unittest.TestCase):
20+
def make_testdata(self, n):
21+
return tuple(range(n))
22+
23+
def assert_iterator_results(self, results, expected):
24+
# Most iterators are not atomic (yet?) so they can skip or duplicate
25+
# items, but they should not invent new items (like the range
26+
# iterator currently does).
27+
extra_items = set(results) - set(expected)
28+
self.assertEqual(set(), extra_items)
29+
30+
def run_threads(self, func, *args, numthreads=NUMTHREADS):
31+
threads = []
32+
for _ in range(numthreads):
33+
t = threading.Thread(target=func, args=args)
34+
t.start()
35+
threads.append(t)
36+
return threads
37+
38+
def test_iteration(self):
39+
"""Test iteration over a shared container"""
40+
seq = self.make_testdata(NUMITEMS)
41+
results = []
42+
start = threading.Barrier(NUMTHREADS)
43+
def worker():
44+
idx = 0
45+
start.wait()
46+
for item in seq:
47+
idx += 1
48+
results.append(idx)
49+
threads = self.run_threads(worker)
50+
for t in threads:
51+
t.join()
52+
# Each thread has its own iterator, so results should be entirely predictable.
53+
self.assertEqual(results, [NUMITEMS] * NUMTHREADS)
54+
55+
def test_shared_iterator(self):
56+
"""Test iteration over a shared iterator"""
57+
seq = self.make_testdata(NUMITEMS)
58+
it = iter(seq)
59+
results = []
60+
start = threading.Barrier(NUMTHREADS)
61+
def worker():
62+
items = []
63+
start.wait()
64+
# We want a tight loop, so put items in the shared list at the end.
65+
for item in it:
66+
items.append(item)
67+
results.extend(items)
68+
threads = self.run_threads(worker)
69+
for t in threads:
70+
t.join()
71+
self.assert_iterator_results(results, seq)
72+
73+
class ContendedListIterationTest(ContendedTupleIterationTest):
74+
def make_testdata(self, n):
75+
return list(range(n))
76+
77+
def test_iteration_while_mutating(self):
78+
"""Test iteration over a shared mutating container."""
79+
seq = self.make_testdata(NUMITEMS)
80+
results = []
81+
start = threading.Barrier(NUMTHREADS + NUMMUTATORS)
82+
endmutate = threading.Event()
83+
def mutator():
84+
orig = seq[:]
85+
# Make changes big enough to cause resizing of the list, with
86+
# items shifted around for good measure.
87+
replacement = (orig * 3)[NUMITEMS//2:]
88+
start.wait()
89+
while not endmutate.is_set():
90+
seq.extend(replacement)
91+
seq[:0] = orig
92+
seq.__imul__(2)
93+
seq.extend(seq)
94+
seq[:] = orig
95+
def worker():
96+
items = []
97+
start.wait()
98+
# We want a tight loop, so put items in the shared list at the end.
99+
for item in seq:
100+
items.append(item)
101+
results.extend(items)
102+
mutators = ()
103+
try:
104+
threads = self.run_threads(worker)
105+
mutators = self.run_threads(mutator, numthreads=NUMMUTATORS)
106+
for t in threads:
107+
t.join()
108+
finally:
109+
endmutate.set()
110+
for m in mutators:
111+
m.join()
112+
self.assert_iterator_results(results, list(seq))
113+
114+
115+
class ContendedRangeIterationTest(ContendedTupleIterationTest):
116+
def make_testdata(self, n):
117+
return range(n)
118+
119+
def assert_iterator_results(self, results, expected):
120+
# Range iterators that are shared between threads will (right now)
121+
# sometimes produce items after the end of the range, sometimes
122+
# _far_ after the end of the range. That should be fixed, but for
123+
# now, let's just check they're integers that could have resulted
124+
# from stepping beyond the range bounds.
125+
extra_items = set(results) - set(expected)
126+
for item in extra_items:
127+
self.assertEqual((item - expected.start) % expected.step, 0)

Objects/listobject.c

+18-10
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ list_get_item_ref(PyListObject *op, Py_ssize_t i)
357357
return NULL;
358358
}
359359
Py_ssize_t cap = list_capacity(ob_item);
360-
assert(cap != -1 && cap >= size);
360+
assert(cap != -1);
361361
if (!valid_index(i, cap)) {
362362
return NULL;
363363
}
@@ -784,7 +784,8 @@ list_repeat_lock_held(PyListObject *a, Py_ssize_t n)
784784
_Py_RefcntAdd(*src, n);
785785
*dest++ = *src++;
786786
}
787-
787+
// TODO: _Py_memory_repeat calls are not safe for shared lists in
788+
// GIL_DISABLED builds. (See issue #129069)
788789
_Py_memory_repeat((char *)np->ob_item, sizeof(PyObject *)*output_size,
789790
sizeof(PyObject *)*input_size);
790791
}
@@ -919,6 +920,8 @@ list_ass_slice_lock_held(PyListObject *a, Py_ssize_t ilow, Py_ssize_t ihigh, PyO
919920
if (d < 0) { /* Delete -d items */
920921
Py_ssize_t tail;
921922
tail = (Py_SIZE(a) - ihigh) * sizeof(PyObject *);
923+
// TODO: these memmove/memcpy calls are not safe for shared lists in
924+
// GIL_DISABLED builds. (See issue #129069)
922925
memmove(&item[ihigh+d], &item[ihigh], tail);
923926
if (list_resize(a, Py_SIZE(a) + d) < 0) {
924927
memmove(&item[ihigh], &item[ihigh+d], tail);
@@ -932,12 +935,14 @@ list_ass_slice_lock_held(PyListObject *a, Py_ssize_t ilow, Py_ssize_t ihigh, PyO
932935
if (list_resize(a, k+d) < 0)
933936
goto Error;
934937
item = a->ob_item;
938+
// TODO: these memmove/memcpy calls are not safe for shared lists in
939+
// GIL_DISABLED builds. (See issue #129069)
935940
memmove(&item[ihigh+d], &item[ihigh],
936941
(k - ihigh)*sizeof(PyObject *));
937942
}
938943
for (k = 0; k < n; k++, ilow++) {
939944
PyObject *w = vitem[k];
940-
item[ilow] = Py_XNewRef(w);
945+
FT_ATOMIC_STORE_PTR_RELEASE(item[ilow], Py_XNewRef(w));
941946
}
942947
for (k = norig - 1; k >= 0; --k)
943948
Py_XDECREF(recycle[k]);
@@ -1017,6 +1022,8 @@ list_inplace_repeat_lock_held(PyListObject *self, Py_ssize_t n)
10171022
for (Py_ssize_t j = 0; j < input_size; j++) {
10181023
_Py_RefcntAdd(items[j], n-1);
10191024
}
1025+
// TODO: _Py_memory_repeat calls are not safe for shared lists in
1026+
// GIL_DISABLED builds. (See issue #129069)
10201027
_Py_memory_repeat((char *)items, sizeof(PyObject *)*output_size,
10211028
sizeof(PyObject *)*input_size);
10221029
return 0;
@@ -3993,7 +4000,7 @@ listiter_setstate(PyObject *self, PyObject *state)
39934000
index = -1;
39944001
else if (index > PyList_GET_SIZE(it->it_seq))
39954002
index = PyList_GET_SIZE(it->it_seq); /* iterator exhausted */
3996-
it->it_index = index;
4003+
FT_ATOMIC_STORE_SSIZE_RELAXED(it->it_index, index);
39974004
}
39984005
Py_RETURN_NONE;
39994006
}
@@ -4145,7 +4152,7 @@ listreviter_setstate(PyObject *self, PyObject *state)
41454152
index = -1;
41464153
else if (index > PyList_GET_SIZE(it->it_seq) - 1)
41474154
index = PyList_GET_SIZE(it->it_seq) - 1;
4148-
it->it_index = index;
4155+
FT_ATOMIC_STORE_SSIZE_RELAXED(it->it_index, index);
41494156
}
41504157
Py_RETURN_NONE;
41514158
}
@@ -4162,18 +4169,19 @@ listiter_reduce_general(void *_it, int forward)
41624169
* call must be before access of iterator pointers.
41634170
* see issue #101765 */
41644171

4165-
/* the objects are not the same, index is of different types! */
41664172
if (forward) {
41674173
iter = _PyEval_GetBuiltin(&_Py_ID(iter));
41684174
_PyListIterObject *it = (_PyListIterObject *)_it;
4169-
if (it->it_index >= 0) {
4170-
return Py_BuildValue("N(O)n", iter, it->it_seq, it->it_index);
4175+
Py_ssize_t idx = FT_ATOMIC_LOAD_SSIZE_RELAXED(it->it_index);
4176+
if (idx >= 0) {
4177+
return Py_BuildValue("N(O)n", iter, it->it_seq, idx);
41714178
}
41724179
} else {
41734180
iter = _PyEval_GetBuiltin(&_Py_ID(reversed));
41744181
listreviterobject *it = (listreviterobject *)_it;
4175-
if (it->it_index >= 0) {
4176-
return Py_BuildValue("N(O)n", iter, it->it_seq, it->it_index);
4182+
Py_ssize_t idx = FT_ATOMIC_LOAD_SSIZE_RELAXED(it->it_index);
4183+
if (idx >= 0) {
4184+
return Py_BuildValue("N(O)n", iter, it->it_seq, idx);
41774185
}
41784186
}
41794187
/* empty iterator, create an empty list */

Objects/tupleobject.c

+23-6
Original file line numberDiff line numberDiff line change
@@ -1014,18 +1014,23 @@ tupleiter_next(PyObject *self)
10141014

10151015
assert(it != NULL);
10161016
seq = it->it_seq;
1017+
#ifndef Py_GIL_DISABLED
10171018
if (seq == NULL)
10181019
return NULL;
1020+
#endif
10191021
assert(PyTuple_Check(seq));
10201022

1021-
if (it->it_index < PyTuple_GET_SIZE(seq)) {
1022-
item = PyTuple_GET_ITEM(seq, it->it_index);
1023-
++it->it_index;
1023+
Py_ssize_t index = FT_ATOMIC_LOAD_SSIZE_RELAXED(it->it_index);
1024+
if (index < PyTuple_GET_SIZE(seq)) {
1025+
FT_ATOMIC_STORE_SSIZE_RELAXED(it->it_index, index + 1);
1026+
item = PyTuple_GET_ITEM(seq, index);
10241027
return Py_NewRef(item);
10251028
}
10261029

1030+
#ifndef Py_GIL_DISABLED
10271031
it->it_seq = NULL;
10281032
Py_DECREF(seq);
1033+
#endif
10291034
return NULL;
10301035
}
10311036

@@ -1034,8 +1039,15 @@ tupleiter_len(PyObject *self, PyObject *Py_UNUSED(ignored))
10341039
{
10351040
_PyTupleIterObject *it = _PyTupleIterObject_CAST(self);
10361041
Py_ssize_t len = 0;
1042+
#ifdef Py_GIL_DISABLED
1043+
Py_ssize_t idx = FT_ATOMIC_LOAD_SSIZE_RELAXED(it->it_index);
1044+
Py_ssize_t seq_len = PyTuple_GET_SIZE(it->it_seq);
1045+
if (idx < seq_len)
1046+
len = seq_len - idx;
1047+
#else
10371048
if (it->it_seq)
10381049
len = PyTuple_GET_SIZE(it->it_seq) - it->it_index;
1050+
#endif
10391051
return PyLong_FromSsize_t(len);
10401052
}
10411053

@@ -1051,10 +1063,15 @@ tupleiter_reduce(PyObject *self, PyObject *Py_UNUSED(ignored))
10511063
* see issue #101765 */
10521064
_PyTupleIterObject *it = _PyTupleIterObject_CAST(self);
10531065

1066+
#ifdef Py_GIL_DISABLED
1067+
Py_ssize_t idx = FT_ATOMIC_LOAD_SSIZE_RELAXED(it->it_index);
1068+
if (idx < PyTuple_GET_SIZE(it->it_seq))
1069+
return Py_BuildValue("N(O)n", iter, it->it_seq, idx);
1070+
#else
10541071
if (it->it_seq)
10551072
return Py_BuildValue("N(O)n", iter, it->it_seq, it->it_index);
1056-
else
1057-
return Py_BuildValue("N(())", iter);
1073+
#endif
1074+
return Py_BuildValue("N(())", iter);
10581075
}
10591076

10601077
static PyObject *
@@ -1069,7 +1086,7 @@ tupleiter_setstate(PyObject *self, PyObject *state)
10691086
index = 0;
10701087
else if (index > PyTuple_GET_SIZE(it->it_seq))
10711088
index = PyTuple_GET_SIZE(it->it_seq); /* exhausted iterator */
1072-
it->it_index = index;
1089+
FT_ATOMIC_STORE_SSIZE_RELAXED(it->it_index, index);
10731090
}
10741091
Py_RETURN_NONE;
10751092
}

Tools/tsan/suppressions_free_threading.txt

+9-2
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,10 @@ race:set_allocator_unlocked
1818
# https://gist.github.com/swtaarrs/08dfe7883b4c975c31ecb39388987a67
1919
race:free_threadstate
2020

21-
2221
# These warnings trigger directly in a CPython function.
2322

2423
race_top:assign_version_tag
2524
race_top:_multiprocessing_SemLock_acquire_impl
26-
race_top:list_get_item_ref
2725
race_top:_Py_slot_tp_getattr_hook
2826
race_top:add_threadstate
2927
race_top:dump_traceback
@@ -54,3 +52,12 @@ race_top:update_one_slot
5452

5553
# https://gist.github.com/mpage/6962e8870606cfc960e159b407a0cb40
5654
thread:pthread_create
55+
56+
# Range iteration is not thread-safe yet (issue #129068)
57+
race_top:rangeiter_next
58+
59+
# List resizing happens through different paths ending in memcpy or memmove
60+
# (for efficiency), which will probably need to rewritten as explicit loops
61+
# of ptr-sized copies to be thread-safe. (Issue #129069)
62+
race:list_ass_slice_lock_held
63+
race:list_inplace_repeat_lock_held

0 commit comments

Comments
 (0)