Skip to content

Commit 7e38e67

Browse files
authored
gh-123271: Make builtin zip method safe under free-threading (#123272)
The `zip_next` function uses a common optimization technique for methods that generate tuples. The iterator maintains an internal reference to the returned tuple. When the method is called again, it checks if the internal tuple's reference count is 1. If so, the tuple can be reused. However, this approach is not safe under the free-threading build: after checking the reference count, another thread may perform the same check and also reuse the tuple. This can result in a double decref on the items of the replaced tuple and a double incref (memory leak) on the items of the tuple being set. This adds a function, `_PyObject_IsUniquelyReferenced` that encapsulates the stricter logic necessary for the free-threaded build: the internal tuple must be owned by the current thread, have a local refcount of one, and a shared refcount of zero.
1 parent d24d1c9 commit 7e38e67

File tree

4 files changed

+61
-1
lines changed

4 files changed

+61
-1
lines changed

Include/internal/pycore_object.h

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,23 @@ static inline void _Py_RefcntAdd(PyObject* op, Py_ssize_t n)
159159
}
160160
#define _Py_RefcntAdd(op, n) _Py_RefcntAdd(_PyObject_CAST(op), n)
161161

162+
// Checks if an object has a single, unique reference. If the caller holds a
163+
// unique reference, it may be able to safely modify the object in-place.
164+
static inline int
165+
_PyObject_IsUniquelyReferenced(PyObject *ob)
166+
{
167+
#if !defined(Py_GIL_DISABLED)
168+
return Py_REFCNT(ob) == 1;
169+
#else
170+
// NOTE: the entire ob_ref_shared field must be zero, including flags, to
171+
// ensure that other threads cannot concurrently create new references to
172+
// this object.
173+
return (_Py_IsOwnedByCurrentThread(ob) &&
174+
_Py_atomic_load_uint32_relaxed(&ob->ob_ref_local) == 1 &&
175+
_Py_atomic_load_ssize_relaxed(&ob->ob_ref_shared) == 0);
176+
#endif
177+
}
178+
162179
PyAPI_FUNC(void) _Py_SetImmortal(PyObject *op);
163180
PyAPI_FUNC(void) _Py_SetImmortalUntracked(PyObject *op);
164181

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import unittest
2+
from threading import Thread
3+
4+
from test.support import threading_helper
5+
6+
7+
class ZipThreading(unittest.TestCase):
8+
@staticmethod
9+
def work(enum):
10+
while True:
11+
try:
12+
next(enum)
13+
except StopIteration:
14+
break
15+
16+
@threading_helper.reap_threads
17+
@threading_helper.requires_working_threading()
18+
def test_threading(self):
19+
number_of_threads = 8
20+
number_of_iterations = 8
21+
n = 40_000
22+
enum = zip(range(n), range(n))
23+
for _ in range(number_of_iterations):
24+
worker_threads = []
25+
for ii in range(number_of_threads):
26+
worker_threads.append(
27+
Thread(
28+
target=self.work,
29+
args=[
30+
enum,
31+
],
32+
)
33+
)
34+
for t in worker_threads:
35+
t.start()
36+
for t in worker_threads:
37+
t.join()
38+
39+
40+
if __name__ == "__main__":
41+
unittest.main()
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Make concurrent iterations over the same :func:`zip` iterator safe under free-threading.

Python/bltinmodule.c

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2971,7 +2971,8 @@ zip_next(zipobject *lz)
29712971

29722972
if (tuplesize == 0)
29732973
return NULL;
2974-
if (Py_REFCNT(result) == 1) {
2974+
2975+
if (_PyObject_IsUniquelyReferenced(result)) {
29752976
Py_INCREF(result);
29762977
for (i=0 ; i < tuplesize ; i++) {
29772978
it = PyTuple_GET_ITEM(lz->ittuple, i);

0 commit comments

Comments
 (0)