-
-
Notifications
You must be signed in to change notification settings - Fork 30.8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
gh-115999: Make list, tuple and range iteration more thread-safe. #128637
base: main
Are you sure you want to change the base?
Conversation
concurrent iteration. (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. 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 crash when a shared list wasn't already marked as shared when reallocated. - 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. - Fast range iterators (for integers that fit in C longs) shared between threads would sometimes produce values past the end of the range, because the iterators use two bits of state that we can't efficiently update atomically. Rewriting the iterators to have a single bit of state is possible, but probably means more math for each iteration and may not be worth it. - Long range iterators (for other numbers) shared between threads would crash catastrophically in a variety of ways. This now uses a critical section. Rewriting this to be more efficient is probably possible, but since it deals with arbitrary Python objects it's difficult to get right. There seem to be no more exising races in list_get_item_ref, so drop it from the tsan suppression list.
Py_INCREF(r->len); | ||
return r->len; | ||
PyObject *len; | ||
Py_BEGIN_CRITICAL_SECTION(r); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Out of curiosity, using atomic operation is not enough for this operation?
(Maybe with _Py_TryIncref?)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's not enough to make this an atomic read without also making the writes in the other critical sections atomic (in addition to using TryIncrefCompare here); it's still a data race to write non-atomically even if all reads are atomic. I didn't really want to do all that work just to avoid a critical section in this function, when this isn't a particularly common or performance-sensitive function, and an uncontested critical section is probably just as fast anyway.
actually correct and the real problem was an incorrect assert. The fast path still contains notionally unsafe uses of memcpy/memmove, so add list_get_item_ref back to the TSan suppressions file.
iterators, and fix build failures because labels can't technically be at the end of compound statements (a statement must follow the label, even if it's empty).
"""Test iteration over a shared container""" | ||
seq = self.make_testdata(NUMITEMS) | ||
results = [] | ||
start = threading.Event() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you are trying to synchronize when the workers start running I think threading.Barrier
more closely matches that behavior.
Same comment applies below.
@@ -937,7 +937,7 @@ list_ass_slice_lock_held(PyListObject *a, Py_ssize_t ilow, Py_ssize_t ihigh, PyO | |||
} | |||
for (k = 0; k < n; k++, ilow++) { | |||
PyObject *w = vitem[k]; | |||
item[ilow] = Py_XNewRef(w); | |||
FT_ATOMIC_STORE_PTR_RELAXED(item[ilow], Py_XNewRef(w)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this should be FT_ATOMIC_STORE_PTR_RELEASE
. We generally need release
when writing pointers that may be load and dereferenced outside of the lock to ensure that the previously written contents are visible before the pointer itself.
fail: | ||
; // A statement must follow the label before Py_END_CRITICAL_SECTION. | ||
Py_END_CRITICAL_SECTION(); | ||
return result; | ||
} | ||
|
||
static PyObject * | ||
longrangeiter_setstate(longrangeiterobject *r, PyObject *state) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we need to wrap functions in critical sections, I think we should generally convert them to argument clinic, if possible, and use the @critical_section
annotation to keep the boilerplate mostly in a generated file.
That's not yet possible for longrangeiter_next
(because it's a tp_iternext, not a PyMethodDef entry), but it's doable for the other functions.
#ifdef Py_GIL_DISABLED | ||
it->stop = stop; | ||
#endif |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not enthusiastic about the idea of growing the iterator size. Can we unconditionally replace len
with stop
?
Make tuple and range 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.
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.
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.
Fast range iterators (for integers that fit in C longs) shared between threads would sometimes produce values past the end of the range, because the iterators use two pieces of state that we can't efficiently update atomically. Rewriting the iterators to have a single piece of state is possible, but probably means more math for each iteration and may not be worth it.
Long range iterators (for other numbers) shared between threads would crash catastrophically in a variety of ways. This now uses a critical section. Rewriting this to be more efficient is probably possible, but since it deals with arbitrary Python objects it's difficult to get right.
--disable-gil
builds #115999