-
-
Notifications
You must be signed in to change notification settings - Fork 34.3k
Description
Bug report
Bug description:
In #119368+#123211, member descriptors (related to user-defined slots) were made thread-safe. But not everywhere. The branch related to deletion remained unchanged:
Lines 174 to 187 in 83360b5
| if (v == NULL) { | |
| if (l->type == Py_T_OBJECT_EX) { | |
| /* Check if the attribute is set. */ | |
| if (*(PyObject **)addr == NULL) { | |
| PyErr_SetString(PyExc_AttributeError, l->name); | |
| return -1; | |
| } | |
| } | |
| else if (l->type != _Py_T_OBJECT) { | |
| PyErr_SetString(PyExc_TypeError, | |
| "can't delete numeric/char attribute"); | |
| return -1; | |
| } | |
| } |
Since threads perform the check (addr == NULL) in a non-thread-safe manner, the operation may succeed even if it has already been executed by another thread. At the Python level, this means that an AttributeError may not be raised during two or more concurrent attempts to delete an attribute, and as a result, code that was supposed to execute only once (after a successful deletion) will be executed more than once.
The behavior matches what is expected on CPython with GIL and on PyPy (and possibly other interpreters), but not on CPython without GIL. Without slots (using __dict__), the behavior also matches what is expected even in free-threading. Meanwhile, user-defined slots already rely on critical sections, so the simplest solution would be to move the check for Py_T_OBJECT_EX under the critical section (into the case block).
Code to reproduce (del obj.b raises an AttributeError):
#!/usr/bin/env python3
import time
from concurrent.futures import ThreadPoolExecutor
class ObjectWithSlots:
__slots__ = (
"a",
"b",
)
def __init__(self):
self.a = None
self.b = None
def main():
def test():
try:
del obj.a
except AttributeError: # not the first thread
pass
else:
del obj.b
with ThreadPoolExecutor(2) as executor:
start = time.perf_counter()
while time.perf_counter() - start < 1: # one second
obj = ObjectWithSlots()
f1 = executor.submit(test)
f2 = executor.submit(test)
f1.result() # reraise
f2.result() # reraise
if __name__ == "__main__":
main()CPython versions tested on:
3.14
Operating systems tested on:
Linux