From 47d75fee96b929343e8713e98e3ce9400b34a15c Mon Sep 17 00:00:00 2001 From: Yongtao Huang Date: Sat, 31 Jan 2026 10:51:16 +0800 Subject: [PATCH 01/17] Fix data race in setiter_len() under no-gil setiter_len() was reading so->used without atomic access while concurrent mutations update it atomically under Py_GIL_DISABLED. Use an atomic load for so->used to avoid a data race. This preserves the existing semantics of __length_hint__ while making the access thread-safe. Signed-off-by: Yongtao Huang --- Objects/setobject.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Objects/setobject.c b/Objects/setobject.c index 5d4d1812282eed..85001c980b6f29 100644 --- a/Objects/setobject.c +++ b/Objects/setobject.c @@ -1056,7 +1056,8 @@ setiter_len(PyObject *op, PyObject *Py_UNUSED(ignored)) { setiterobject *si = (setiterobject*)op; Py_ssize_t len = 0; - if (si->si_set != NULL && si->si_used == si->si_set->used) + PySetObject *so = si->si_set; + if (so != NULL && si->si_used == FT_ATOMIC_LOAD_SSIZE_RELAXED(so->used)) len = si->len; return PyLong_FromSsize_t(len); } From 3e3785cb6cde129bb19b6db8278bae4351f45be9 Mon Sep 17 00:00:00 2001 From: Yongtao Huang Date: Sat, 31 Jan 2026 11:33:41 +0800 Subject: [PATCH 02/17] Add test case --- Lib/test/test_free_threading/test_set.py | 26 ++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Lib/test/test_free_threading/test_set.py b/Lib/test/test_free_threading/test_set.py index 9dd3d68d5dad13..1d5e09cb4089d4 100644 --- a/Lib/test/test_free_threading/test_set.py +++ b/Lib/test/test_free_threading/test_set.py @@ -148,6 +148,32 @@ def read_set(): for t in threads: t.join() + def test_iter_length_hint_mutate(self): + s = set(range(2000)) + it = iter(s) + stop = Event() + + def reader(): + while not stop.is_set(): + it.__length_hint__() + + def writer(): + i = 0 + while not stop.is_set(): + s.add(i) + s.discard(i - 1) + i += 1 + + threads = [Thread(target=reader) for _ in range(4)] + threads.append(Thread(target=writer)) + + for t in threads: + t.start() + + stop.set() + + for t in threads: + t.join() @threading_helper.requires_working_threading() class SmallSetTest(RaceTestBase, unittest.TestCase): From 229ced3b1c5be4e1cbeb4db70b7a5dd8645df955 Mon Sep 17 00:00:00 2001 From: "blurb-it[bot]" <43283697+blurb-it[bot]@users.noreply.github.com> Date: Sat, 31 Jan 2026 03:40:30 +0000 Subject: [PATCH 03/17] =?UTF-8?q?=F0=9F=93=9C=F0=9F=A4=96=20Added=20by=20b?= =?UTF-8?q?lurb=5Fit.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-01-31-03-40-28.gh-issue-144356.otfq_X.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-01-31-03-40-28.gh-issue-144356.otfq_X.rst diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-01-31-03-40-28.gh-issue-144356.otfq_X.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-01-31-03-40-28.gh-issue-144356.otfq_X.rst new file mode 100644 index 00000000000000..d5d67ad1e8dbb3 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-01-31-03-40-28.gh-issue-144356.otfq_X.rst @@ -0,0 +1 @@ +Fix a data race in ``set_iterator.__length_hint__`` under ``Py_GIL_DISABLED``. From cdcf88ad474f675f8bc4fbc6956aca71e4667fba Mon Sep 17 00:00:00 2001 From: Yongtao Huang Date: Sun, 1 Feb 2026 23:25:26 +0800 Subject: [PATCH 04/17] Resovle comments --- Lib/test/test_free_threading/test_set.py | 52 +++++++++++++++++++----- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/Lib/test/test_free_threading/test_set.py b/Lib/test/test_free_threading/test_set.py index 1d5e09cb4089d4..5d8abe72d607c2 100644 --- a/Lib/test/test_free_threading/test_set.py +++ b/Lib/test/test_free_threading/test_set.py @@ -148,32 +148,62 @@ def read_set(): for t in threads: t.join() - def test_iter_length_hint_mutate(self): + def test_length_hint_used_race(self): s = set(range(2000)) it = iter(s) - stop = Event() + + NUM_LOOPS = 50_000 + barrier = Barrier(2) def reader(): - while not stop.is_set(): + barrier.wait() + for _ in range(NUM_LOOPS): it.__length_hint__() def writer(): + barrier.wait() i = 0 - while not stop.is_set(): + for _ in range(NUM_LOOPS): s.add(i) s.discard(i - 1) i += 1 - threads = [Thread(target=reader) for _ in range(4)] - threads.append(Thread(target=writer)) + t1 = Thread(target=reader) + t2 = Thread(target=writer) + t1.start(); t2.start() + t1.join(); t2.join() - for t in threads: - t.start() + def test_length_hint_exhaust_race(self): + NUM_LOOPS = 10_000 + INNER_HINTS = 20 + barrier = Barrier(2) + box = {"it": None} - stop.set() + def exhauster(): + for _ in range(NUM_LOOPS): + s = set(range(256)) + box["it"] = iter(s) + barrier.wait() # start together + try: + while True: + next(box["it"]) + except StopIteration: + pass + barrier.wait() # end iteration + + def reader(): + for _ in range(NUM_LOOPS): + barrier.wait() + it = box["it"] + for _ in range(INNER_HINTS): + it.__length_hint__() + barrier.wait() + + t1 = Thread(target=reader) + t2 = Thread(target=exhauster) + t1.start(); t2.start() + t1.join(); t2.join() - for t in threads: - t.join() @threading_helper.requires_working_threading() class SmallSetTest(RaceTestBase, unittest.TestCase): From 21f1478423e09eee0ea7c082d46fe5d6291e2cc3 Mon Sep 17 00:00:00 2001 From: Yongtao Huang Date: Sun, 1 Feb 2026 23:26:08 +0800 Subject: [PATCH 05/17] Update test case: used_race and exhaust_race --- Objects/setobject.c | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/Objects/setobject.c b/Objects/setobject.c index 85001c980b6f29..d0291b98ebfb72 100644 --- a/Objects/setobject.c +++ b/Objects/setobject.c @@ -1056,9 +1056,23 @@ setiter_len(PyObject *op, PyObject *Py_UNUSED(ignored)) { setiterobject *si = (setiterobject*)op; Py_ssize_t len = 0; - PySetObject *so = si->si_set; - if (so != NULL && si->si_used == FT_ATOMIC_LOAD_SSIZE_RELAXED(so->used)) +#ifdef Py_GIL_DISABLED + PyObject *so_obj = FT_ATOMIC_LOAD_PTR_ACQUIRE(si->si_set); + if (so_obj != NULL) { + /* Turn borrowed si->si_set into a strong ref safely. */ + if (_Py_TryIncrefCompare((PyObject **)&si->si_set, so_obj)) { + PySetObject *so = (PySetObject *)so_obj; + if (si->si_used == FT_ATOMIC_LOAD_SSIZE_RELAXED(so->used)) { + len = si->len; + } + Py_DECREF(so_obj); + } + } +#else + if (si->si_set != NULL && si->si_used == si->si_set->used) { len = si->len; + } +#endif return PyLong_FromSsize_t(len); } @@ -1125,7 +1139,11 @@ static PyObject *setiter_iternext(PyObject *self) Py_END_CRITICAL_SECTION(); si->si_pos = i+1; if (key == NULL) { +#ifdef Py_GIL_DISABLED + FT_ATOMIC_STORE_PTR_RELEASE(si->si_set, NULL); +#else si->si_set = NULL; +#endif Py_DECREF(so); return NULL; } From a18c6989439e6b0d69ddc7300eb5df3eea8c7baf Mon Sep 17 00:00:00 2001 From: Yongtao Huang Date: Sat, 7 Feb 2026 00:07:30 +0800 Subject: [PATCH 06/17] Add test case --- Lib/test/test_free_threading/test_set.py | 29 ++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Lib/test/test_free_threading/test_set.py b/Lib/test/test_free_threading/test_set.py index 5d8abe72d607c2..5054bf78fb7289 100644 --- a/Lib/test/test_free_threading/test_set.py +++ b/Lib/test/test_free_threading/test_set.py @@ -204,6 +204,35 @@ def reader(): t1.start(); t2.start() t1.join(); t2.join() + def test_iternext_concurrent_exhaust_race(self): + NUM_LOOPS = 20_000 + barrier = Barrier(3) + box = {"it": None} + + def advancer(): + for _ in range(NUM_LOOPS): + barrier.wait() + it = box["it"] + while True: + try: + next(it) + except StopIteration: + break + barrier.wait() + + def producer(): + for _ in range(NUM_LOOPS): + s = set(range(64)) + box["it"] = iter(s) + barrier.wait() + barrier.wait() + + t1 = Thread(target=advancer) + t2 = Thread(target=advancer) + t3 = Thread(target=producer) + t1.start(); t2.start(); t3.start() + t1.join(); t2.join(); t3.join() + @threading_helper.requires_working_threading() class SmallSetTest(RaceTestBase, unittest.TestCase): From 79b5fbc0c3d0cd3f959ecf212af225511b7935d0 Mon Sep 17 00:00:00 2001 From: Yongtao Huang Date: Sat, 7 Feb 2026 01:06:02 +0800 Subject: [PATCH 07/17] Try to address the comments --- Objects/setobject.c | 48 ++++++++++++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/Objects/setobject.c b/Objects/setobject.c index d0291b98ebfb72..cc95d2a9498783 100644 --- a/Objects/setobject.c +++ b/Objects/setobject.c @@ -1057,16 +1057,16 @@ setiter_len(PyObject *op, PyObject *Py_UNUSED(ignored)) setiterobject *si = (setiterobject*)op; Py_ssize_t len = 0; #ifdef Py_GIL_DISABLED - PyObject *so_obj = FT_ATOMIC_LOAD_PTR_ACQUIRE(si->si_set); - if (so_obj != NULL) { - /* Turn borrowed si->si_set into a strong ref safely. */ - if (_Py_TryIncrefCompare((PyObject **)&si->si_set, so_obj)) { - PySetObject *so = (PySetObject *)so_obj; - if (si->si_used == FT_ATOMIC_LOAD_SSIZE_RELAXED(so->used)) { - len = si->len; - } - Py_DECREF(so_obj); + PySetObject *so = si->si_set; + if (so != NULL) { + Py_BEGIN_CRITICAL_SECTION(so); + Py_ssize_t pos = FT_ATOMIC_LOAD_SSIZE_RELAXED(si->si_pos); + if (pos >= 0 && + si->si_used == FT_ATOMIC_LOAD_SSIZE_RELAXED(so->used)) + { + len = si->len; } + Py_END_CRITICAL_SECTION(); } #else if (si->si_set != NULL && si->si_used == si->si_set->used) { @@ -1126,7 +1126,16 @@ static PyObject *setiter_iternext(PyObject *self) } Py_BEGIN_CRITICAL_SECTION(so); +#ifdef Py_GIL_DISABLED + /* si_pos may be read outside the lock; keep it atomic in FT builds */ + i = FT_ATOMIC_LOAD_SSIZE_RELAXED(si->si_pos); + if (i < 0) { + /* iterator already exhausted */ + goto done; + } +#else i = si->si_pos; +#endif assert(i>=0); entry = so->table; mask = so->mask; @@ -1135,19 +1144,30 @@ static PyObject *setiter_iternext(PyObject *self) } if (i <= mask) { key = Py_NewRef(entry[i].key); +#ifdef Py_GIL_DISABLED + FT_ATOMIC_STORE_SSIZE_RELAXED(si->si_pos, i + 1); +#else + si->si_pos = i + 1; +#endif + si->len--; } - Py_END_CRITICAL_SECTION(); - si->si_pos = i+1; - if (key == NULL) { + else { #ifdef Py_GIL_DISABLED - FT_ATOMIC_STORE_PTR_RELEASE(si->si_set, NULL); + /* free-threaded: keep si_set; just mark exhausted */ + FT_ATOMIC_STORE_SSIZE_RELAXED(si->si_pos, -1); + si->len = 0; #else si->si_set = NULL; #endif + } +done: + Py_END_CRITICAL_SECTION(); + if (key == NULL) { +#ifndef Py_GIL_DISABLED Py_DECREF(so); +#endif return NULL; } - si->len--; return key; } From 6ac15e06a61ec4411c853f210e4785a293ea618c Mon Sep 17 00:00:00 2001 From: Yongtao Huang Date: Sat, 7 Feb 2026 01:28:02 +0800 Subject: [PATCH 08/17] Post fix --- Objects/setobject.c | 46 ++++++++++++++++++++++++++++----------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/Objects/setobject.c b/Objects/setobject.c index cc95d2a9498783..29125a5b2a1f26 100644 --- a/Objects/setobject.c +++ b/Objects/setobject.c @@ -1111,6 +1111,7 @@ static PyObject *setiter_iternext(PyObject *self) Py_ssize_t i, mask; setentry *entry; PySetObject *so = si->si_set; + int exhausted = 0; if (so == NULL) return NULL; @@ -1135,33 +1136,44 @@ static PyObject *setiter_iternext(PyObject *self) } #else i = si->si_pos; -#endif - assert(i>=0); - entry = so->table; - mask = so->mask; - while (i <= mask && (entry[i].key == NULL || entry[i].key == dummy)) { - i++; + if (i < 0) { + /* iterator already exhausted */ + exhausted = 1; } - if (i <= mask) { - key = Py_NewRef(entry[i].key); +#endif + + if (!exhausted) { + assert(i >= 0); + entry = so->table; + mask = so->mask; + while (i <= mask && (entry[i].key == NULL || entry[i].key == dummy)) { + i++; + } + if (i <= mask) { + key = Py_NewRef(entry[i].key); #ifdef Py_GIL_DISABLED - FT_ATOMIC_STORE_SSIZE_RELAXED(si->si_pos, i + 1); + FT_ATOMIC_STORE_SSIZE_RELAXED(si->si_pos, i + 1); #else - si->si_pos = i + 1; + si->si_pos = i + 1; #endif - si->len--; - } - else { + si->len--; + } + else { #ifdef Py_GIL_DISABLED - /* free-threaded: keep si_set; just mark exhausted */ - FT_ATOMIC_STORE_SSIZE_RELAXED(si->si_pos, -1); - si->len = 0; + /* free-threaded: keep si_set; just mark exhausted */ + FT_ATOMIC_STORE_SSIZE_RELAXED(si->si_pos, -1); + si->len = 0; #else - si->si_set = NULL; + si->si_set = NULL; #endif + } } + +#ifdef Py_GIL_DISABLED done: +#endif Py_END_CRITICAL_SECTION(); + if (key == NULL) { #ifndef Py_GIL_DISABLED Py_DECREF(so); From 3222eef47fe2a034b035458957772b980fd58a75 Mon Sep 17 00:00:00 2001 From: Yongtao Huang Date: Sun, 8 Feb 2026 11:33:27 +0800 Subject: [PATCH 09/17] Address comments --- Lib/test/test_free_threading/test_set.py | 38 +++++++---- Objects/setobject.c | 82 ++++++++++++------------ 2 files changed, 65 insertions(+), 55 deletions(-) diff --git a/Lib/test/test_free_threading/test_set.py b/Lib/test/test_free_threading/test_set.py index 5054bf78fb7289..200d4342750160 100644 --- a/Lib/test/test_free_threading/test_set.py +++ b/Lib/test/test_free_threading/test_set.py @@ -168,10 +168,14 @@ def writer(): s.discard(i - 1) i += 1 - t1 = Thread(target=reader) - t2 = Thread(target=writer) - t1.start(); t2.start() - t1.join(); t2.join() + threads = [ + Thread(target=reader), + Thread(target=writer), + ] + for t in threads: + t.start() + for t in threads: + t.join() def test_length_hint_exhaust_race(self): NUM_LOOPS = 10_000 @@ -199,10 +203,14 @@ def reader(): it.__length_hint__() barrier.wait() - t1 = Thread(target=reader) - t2 = Thread(target=exhauster) - t1.start(); t2.start() - t1.join(); t2.join() + threads = [ + Thread(target=reader), + Thread(target=exhauster), + ] + for t in threads: + t.start() + for t in threads: + t.join() def test_iternext_concurrent_exhaust_race(self): NUM_LOOPS = 20_000 @@ -227,11 +235,15 @@ def producer(): barrier.wait() barrier.wait() - t1 = Thread(target=advancer) - t2 = Thread(target=advancer) - t3 = Thread(target=producer) - t1.start(); t2.start(); t3.start() - t1.join(); t2.join(); t3.join() + threads = [ + Thread(target=advancer), + Thread(target=advancer), + Thread(target=producer), + ] + for t in threads: + t.start() + for t in threads: + t.join() @threading_helper.requires_working_threading() diff --git a/Objects/setobject.c b/Objects/setobject.c index 29125a5b2a1f26..01bb20983e4526 100644 --- a/Objects/setobject.c +++ b/Objects/setobject.c @@ -1056,23 +1056,24 @@ setiter_len(PyObject *op, PyObject *Py_UNUSED(ignored)) { setiterobject *si = (setiterobject*)op; Py_ssize_t len = 0; + #ifdef Py_GIL_DISABLED PySetObject *so = si->si_set; if (so != NULL) { - Py_BEGIN_CRITICAL_SECTION(so); - Py_ssize_t pos = FT_ATOMIC_LOAD_SSIZE_RELAXED(si->si_pos); - if (pos >= 0 && + Py_BEGIN_CRITICAL_SECTION2(op, so); + if (si->si_pos >= 0 && si->si_used == FT_ATOMIC_LOAD_SSIZE_RELAXED(so->used)) { len = si->len; } - Py_END_CRITICAL_SECTION(); + Py_END_CRITICAL_SECTION2(); } #else if (si->si_set != NULL && si->si_used == si->si_set->used) { len = si->len; } #endif + return PyLong_FromSsize_t(len); } @@ -1104,18 +1105,22 @@ static PyMethodDef setiter_methods[] = { {NULL, NULL} /* sentinel */ }; -static PyObject *setiter_iternext(PyObject *self) +static PyObject * +setiter_iternext(PyObject *self) { setiterobject *si = (setiterobject*)self; PyObject *key = NULL; Py_ssize_t i, mask; setentry *entry; PySetObject *so = si->si_set; - int exhausted = 0; +#ifndef Py_GIL_DISABLED + int decref_so = 0; +#endif - if (so == NULL) + if (so == NULL) { return NULL; - assert (PyAnySet_Check(so)); + } + assert(PyAnySet_Check(so)); Py_ssize_t so_used = FT_ATOMIC_LOAD_SSIZE_RELAXED(so->used); Py_ssize_t si_used = FT_ATOMIC_LOAD_SSIZE_RELAXED(si->si_used); @@ -1126,61 +1131,54 @@ static PyObject *setiter_iternext(PyObject *self) return NULL; } - Py_BEGIN_CRITICAL_SECTION(so); #ifdef Py_GIL_DISABLED - /* si_pos may be read outside the lock; keep it atomic in FT builds */ - i = FT_ATOMIC_LOAD_SSIZE_RELAXED(si->si_pos); - if (i < 0) { - /* iterator already exhausted */ - goto done; - } -#else + Py_BEGIN_CRITICAL_SECTION2(self, so); i = si->si_pos; - if (i < 0) { - /* iterator already exhausted */ - exhausted = 1; - } -#endif - - if (!exhausted) { - assert(i >= 0); + if (i >= 0) { entry = so->table; mask = so->mask; - while (i <= mask && (entry[i].key == NULL || entry[i].key == dummy)) { + while (i <= mask && + (entry[i].key == NULL || entry[i].key == dummy)) { i++; } if (i <= mask) { key = Py_NewRef(entry[i].key); -#ifdef Py_GIL_DISABLED - FT_ATOMIC_STORE_SSIZE_RELAXED(si->si_pos, i + 1); -#else si->si_pos = i + 1; -#endif si->len--; } else { -#ifdef Py_GIL_DISABLED - /* free-threaded: keep si_set; just mark exhausted */ - FT_ATOMIC_STORE_SSIZE_RELAXED(si->si_pos, -1); + si->si_pos = -1; si->len = 0; -#else - si->si_set = NULL; -#endif } } - -#ifdef Py_GIL_DISABLED -done: -#endif + Py_END_CRITICAL_SECTION2(); + return key; +#else + Py_BEGIN_CRITICAL_SECTION(so); + i = si->si_pos; + entry = so->table; + mask = so->mask; + while (i <= mask && + (entry[i].key == NULL || entry[i].key == dummy)) { + i++; + } + if (i <= mask) { + key = Py_NewRef(entry[i].key); + si->si_pos = i + 1; + si->len--; + } + else { + si->si_set = NULL; + decref_so = 1; + } Py_END_CRITICAL_SECTION(); - if (key == NULL) { -#ifndef Py_GIL_DISABLED + if (decref_so) { Py_DECREF(so); -#endif return NULL; } return key; +#endif } PyTypeObject PySetIter_Type = { From 7fb39bc6ed261316dff5fc6a2a1d11b3c4168233 Mon Sep 17 00:00:00 2001 From: Yongtao Huang Date: Sun, 8 Feb 2026 15:27:23 +0800 Subject: [PATCH 10/17] Post fix --- Objects/setobject.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Objects/setobject.c b/Objects/setobject.c index 01bb20983e4526..188c008a7d0b67 100644 --- a/Objects/setobject.c +++ b/Objects/setobject.c @@ -1166,7 +1166,7 @@ setiter_iternext(PyObject *self) key = Py_NewRef(entry[i].key); si->si_pos = i + 1; si->len--; - } + } else { si->si_set = NULL; decref_so = 1; From 78241a862bd12a42ab369790ad8bedbe2fc2fc41 Mon Sep 17 00:00:00 2001 From: Yongtao Huang Date: Mon, 9 Feb 2026 19:09:38 +0800 Subject: [PATCH 11/17] Update Lib/test/test_free_threading/test_set.py Co-authored-by: Pieter Eendebak --- Lib/test/test_free_threading/test_set.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/Lib/test/test_free_threading/test_set.py b/Lib/test/test_free_threading/test_set.py index 200d4342750160..111a87434f65c9 100644 --- a/Lib/test/test_free_threading/test_set.py +++ b/Lib/test/test_free_threading/test_set.py @@ -214,7 +214,8 @@ def reader(): def test_iternext_concurrent_exhaust_race(self): NUM_LOOPS = 20_000 - barrier = Barrier(3) + number_of_threads = 3 + barrier = Barrier(number_of_threads) box = {"it": None} def advancer(): @@ -235,11 +236,7 @@ def producer(): barrier.wait() barrier.wait() - threads = [ - Thread(target=advancer), - Thread(target=advancer), - Thread(target=producer), - ] + threads = [Thread(target=advancer) for _ in range(number_of_threads)] for t in threads: t.start() for t in threads: From 52285a27f79c1ca47b62fc8db8aaf317065cdb25 Mon Sep 17 00:00:00 2001 From: Yongtao Huang Date: Mon, 9 Feb 2026 19:35:31 +0800 Subject: [PATCH 12/17] Revert "Update Lib/test/test_free_threading/test_set.py" This reverts commit 78241a862bd12a42ab369790ad8bedbe2fc2fc41. --- Lib/test/test_free_threading/test_set.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_free_threading/test_set.py b/Lib/test/test_free_threading/test_set.py index 111a87434f65c9..200d4342750160 100644 --- a/Lib/test/test_free_threading/test_set.py +++ b/Lib/test/test_free_threading/test_set.py @@ -214,8 +214,7 @@ def reader(): def test_iternext_concurrent_exhaust_race(self): NUM_LOOPS = 20_000 - number_of_threads = 3 - barrier = Barrier(number_of_threads) + barrier = Barrier(3) box = {"it": None} def advancer(): @@ -236,7 +235,11 @@ def producer(): barrier.wait() barrier.wait() - threads = [Thread(target=advancer) for _ in range(number_of_threads)] + threads = [ + Thread(target=advancer), + Thread(target=advancer), + Thread(target=producer), + ] for t in threads: t.start() for t in threads: From c9ece317157c650c44b744128608e66b31ef8acf Mon Sep 17 00:00:00 2001 From: Yongtao Huang Date: Mon, 9 Feb 2026 22:15:50 +0800 Subject: [PATCH 13/17] Address comments --- Objects/setobject.c | 46 ++++++++++++++++++++++----------------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/Objects/setobject.c b/Objects/setobject.c index 188c008a7d0b67..d3af7f95a55553 100644 --- a/Objects/setobject.c +++ b/Objects/setobject.c @@ -1061,8 +1061,7 @@ setiter_len(PyObject *op, PyObject *Py_UNUSED(ignored)) PySetObject *so = si->si_set; if (so != NULL) { Py_BEGIN_CRITICAL_SECTION2(op, so); - if (si->si_pos >= 0 && - si->si_used == FT_ATOMIC_LOAD_SSIZE_RELAXED(so->used)) + if (si->si_pos >= 0 && si->si_used == so->used) { len = si->len; } @@ -1133,33 +1132,21 @@ setiter_iternext(PyObject *self) #ifdef Py_GIL_DISABLED Py_BEGIN_CRITICAL_SECTION2(self, so); - i = si->si_pos; - if (i >= 0) { - entry = so->table; - mask = so->mask; - while (i <= mask && - (entry[i].key == NULL || entry[i].key == dummy)) { - i++; - } - if (i <= mask) { - key = Py_NewRef(entry[i].key); - si->si_pos = i + 1; - si->len--; - } - else { - si->si_pos = -1; - si->len = 0; - } - } - Py_END_CRITICAL_SECTION2(); - return key; #else Py_BEGIN_CRITICAL_SECTION(so); +#endif + i = si->si_pos; +#ifdef Py_GIL_DISABLED + if (i < 0) { + /* iterator already exhausted */ + goto done; + } +#endif + entry = so->table; mask = so->mask; - while (i <= mask && - (entry[i].key == NULL || entry[i].key == dummy)) { + while (i <= mask && (entry[i].key == NULL || entry[i].key == dummy)) { i++; } if (i <= mask) { @@ -1168,9 +1155,20 @@ setiter_iternext(PyObject *self) si->len--; } else { + /* exhausted */ + si->si_pos = -1; + si->len = 0; +#ifndef Py_GIL_DISABLED si->si_set = NULL; decref_so = 1; +#endif } + +#ifdef Py_GIL_DISABLED +done: + Py_END_CRITICAL_SECTION2(); + return key; +#else Py_END_CRITICAL_SECTION(); if (decref_so) { From e41e852002dbec727ad6d1a81e3dfd87725f0aef Mon Sep 17 00:00:00 2001 From: Yongtao Huang Date: Tue, 10 Feb 2026 16:36:41 +0800 Subject: [PATCH 14/17] Enhance the test case as test_itertools.py --- Lib/test/test_free_threading/test_set.py | 114 +++++++---------------- 1 file changed, 36 insertions(+), 78 deletions(-) diff --git a/Lib/test/test_free_threading/test_set.py b/Lib/test/test_free_threading/test_set.py index 200d4342750160..17b60360d0a032 100644 --- a/Lib/test/test_free_threading/test_set.py +++ b/Lib/test/test_free_threading/test_set.py @@ -148,102 +148,60 @@ def read_set(): for t in threads: t.join() + @threading_helper.reap_threads def test_length_hint_used_race(self): - s = set(range(2000)) - it = iter(s) - - NUM_LOOPS = 50_000 - barrier = Barrier(2) + NUM_ITERS = 10 + NUM_THREADS = 10 + NUM_LOOPS = 2_000 - def reader(): - barrier.wait() - for _ in range(NUM_LOOPS): - it.__length_hint__() + for _ in range(NUM_ITERS): + s = set(range(2000)) + it = iter(s) - def writer(): - barrier.wait() - i = 0 - for _ in range(NUM_LOOPS): - s.add(i) - s.discard(i - 1) - i += 1 + def worker(): + for i in range(NUM_LOOPS): + it.__length_hint__() + s.add(i) + s.discard(i - 1) - threads = [ - Thread(target=reader), - Thread(target=writer), - ] - for t in threads: - t.start() - for t in threads: - t.join() + threading_helper.run_concurrently(worker, nthreads=NUM_THREADS) + @threading_helper.reap_threads def test_length_hint_exhaust_race(self): - NUM_LOOPS = 10_000 - INNER_HINTS = 20 - barrier = Barrier(2) - box = {"it": None} + NUM_ITERS = 50 + NUM_THREADS = 10 - def exhauster(): - for _ in range(NUM_LOOPS): - s = set(range(256)) - box["it"] = iter(s) - barrier.wait() # start together - try: - while True: - next(box["it"]) - except StopIteration: - pass - barrier.wait() # end iteration - - def reader(): - for _ in range(NUM_LOOPS): - barrier.wait() - it = box["it"] - for _ in range(INNER_HINTS): + for _ in range(NUM_ITERS): + s = set(range(256)) + it = iter(s) + + def worker(): + while True: it.__length_hint__() - barrier.wait() + try: + next(it) + except StopIteration: + break - threads = [ - Thread(target=reader), - Thread(target=exhauster), - ] - for t in threads: - t.start() - for t in threads: - t.join() + threading_helper.run_concurrently(worker, nthreads=NUM_THREADS) + @threading_helper.reap_threads def test_iternext_concurrent_exhaust_race(self): - NUM_LOOPS = 20_000 - barrier = Barrier(3) - box = {"it": None} + NUM_ITERS = 200 + NUM_THREADS = 10 - def advancer(): - for _ in range(NUM_LOOPS): - barrier.wait() - it = box["it"] + for _ in range(NUM_ITERS): + s = set(range(64)) + it = iter(s) + + def worker(): while True: try: next(it) except StopIteration: break - barrier.wait() - def producer(): - for _ in range(NUM_LOOPS): - s = set(range(64)) - box["it"] = iter(s) - barrier.wait() - barrier.wait() - - threads = [ - Thread(target=advancer), - Thread(target=advancer), - Thread(target=producer), - ] - for t in threads: - t.start() - for t in threads: - t.join() + threading_helper.run_concurrently(worker, nthreads=NUM_THREADS) @threading_helper.requires_working_threading() From b32b5ec7ec21a9386dadcc61d6529175d3df607b Mon Sep 17 00:00:00 2001 From: Yongtao Huang Date: Tue, 10 Feb 2026 17:17:44 +0800 Subject: [PATCH 15/17] Update NEWS --- .../2026-01-31-03-40-28.gh-issue-144356.otfq_X.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-01-31-03-40-28.gh-issue-144356.otfq_X.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-01-31-03-40-28.gh-issue-144356.otfq_X.rst index d5d67ad1e8dbb3..f87b3042fe7195 100644 --- a/Misc/NEWS.d/next/Core_and_Builtins/2026-01-31-03-40-28.gh-issue-144356.otfq_X.rst +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-01-31-03-40-28.gh-issue-144356.otfq_X.rst @@ -1 +1 @@ -Fix a data race in ``set_iterator.__length_hint__`` under ``Py_GIL_DISABLED``. +Fix potential races in set iterators (``__length_hint__`` and iteration) in free-threaded builds. \ No newline at end of file From d14856c7fe1f670ec7042ae43018825a68c7ada2 Mon Sep 17 00:00:00 2001 From: Yongtao Huang Date: Tue, 10 Feb 2026 21:44:06 +0800 Subject: [PATCH 16/17] Address comment --- Lib/test/test_free_threading/test_set.py | 4 ++-- ...-01-31-03-40-28.gh-issue-144356.otfq_X.rst | 2 +- Objects/setobject.c | 20 ++++++++----------- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/Lib/test/test_free_threading/test_set.py b/Lib/test/test_free_threading/test_set.py index 17b60360d0a032..80475460111274 100644 --- a/Lib/test/test_free_threading/test_set.py +++ b/Lib/test/test_free_threading/test_set.py @@ -168,7 +168,7 @@ def worker(): @threading_helper.reap_threads def test_length_hint_exhaust_race(self): - NUM_ITERS = 50 + NUM_ITERS = 10 NUM_THREADS = 10 for _ in range(NUM_ITERS): @@ -187,7 +187,7 @@ def worker(): @threading_helper.reap_threads def test_iternext_concurrent_exhaust_race(self): - NUM_ITERS = 200 + NUM_ITERS = 10 NUM_THREADS = 10 for _ in range(NUM_ITERS): diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-01-31-03-40-28.gh-issue-144356.otfq_X.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-01-31-03-40-28.gh-issue-144356.otfq_X.rst index f87b3042fe7195..1d4a4075c95e44 100644 --- a/Misc/NEWS.d/next/Core_and_Builtins/2026-01-31-03-40-28.gh-issue-144356.otfq_X.rst +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-01-31-03-40-28.gh-issue-144356.otfq_X.rst @@ -1 +1 @@ -Fix potential races in set iterators (``__length_hint__`` and iteration) in free-threaded builds. \ No newline at end of file +Fix potential races in set iterators (``__length_hint__`` and iteration) in free-threaded builds. diff --git a/Objects/setobject.c b/Objects/setobject.c index d3af7f95a55553..c5fc8de3598086 100644 --- a/Objects/setobject.c +++ b/Objects/setobject.c @@ -1059,14 +1059,13 @@ setiter_len(PyObject *op, PyObject *Py_UNUSED(ignored)) #ifdef Py_GIL_DISABLED PySetObject *so = si->si_set; - if (so != NULL) { - Py_BEGIN_CRITICAL_SECTION2(op, so); - if (si->si_pos >= 0 && si->si_used == so->used) - { - len = si->len; - } - Py_END_CRITICAL_SECTION2(); + assert(so != NULL); + + Py_BEGIN_CRITICAL_SECTION2(op, so); + if (si->si_pos >= 0 && si->si_used == so->used) { + len = si->len; } + Py_END_CRITICAL_SECTION2(); #else if (si->si_set != NULL && si->si_used == si->si_set->used) { len = si->len; @@ -1112,9 +1111,6 @@ setiter_iternext(PyObject *self) Py_ssize_t i, mask; setentry *entry; PySetObject *so = si->si_set; -#ifndef Py_GIL_DISABLED - int decref_so = 0; -#endif if (so == NULL) { return NULL; @@ -1160,7 +1156,6 @@ setiter_iternext(PyObject *self) si->len = 0; #ifndef Py_GIL_DISABLED si->si_set = NULL; - decref_so = 1; #endif } @@ -1171,7 +1166,8 @@ setiter_iternext(PyObject *self) #else Py_END_CRITICAL_SECTION(); - if (decref_so) { + if (key == NULL) { + /* exhausted */ Py_DECREF(so); return NULL; } From 2a512a8fc72d95b4986cb16b7698155585d50cfb Mon Sep 17 00:00:00 2001 From: Yongtao Huang Date: Tue, 10 Feb 2026 21:50:02 +0800 Subject: [PATCH 17/17] Address comments --- Objects/setobject.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Objects/setobject.c b/Objects/setobject.c index c5fc8de3598086..fb9064d9f74613 100644 --- a/Objects/setobject.c +++ b/Objects/setobject.c @@ -1112,9 +1112,13 @@ setiter_iternext(PyObject *self) setentry *entry; PySetObject *so = si->si_set; +#ifdef Py_GIL_DISABLED + assert(so != NULL); +#else if (so == NULL) { return NULL; } +#endif assert(PyAnySet_Check(so)); Py_ssize_t so_used = FT_ATOMIC_LOAD_SSIZE_RELAXED(so->used);