Skip to content

slot.__delete__() behaves as non-atomic in free-threading #146270

@x42005e1f

Description

@x42005e1f

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:

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions