Skip to content

Commit 98cbbc0

Browse files
committed
gh-146270: Fix PyMember_SetOne(..., NULL) not being atomic
1 parent 5c5dae0 commit 98cbbc0

File tree

4 files changed

+171
-13
lines changed

4 files changed

+171
-13
lines changed

Lib/test/test_free_threading/test_slots.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,51 @@ def reader():
4343

4444
run_in_threads([writer, reader, reader, reader])
4545

46+
def test_del_object_is_atomic(self):
47+
# Testing whether the implementation of `del slots_object.attribute`
48+
# removes the attribute atomically, thus avoiding non-sequentially-
49+
# consistent behaviors.
50+
# https://github.com/python/cpython/issues/146270
51+
52+
class Spam:
53+
__slots__ = [
54+
"eggs",
55+
"foo",
56+
]
57+
58+
spam = Spam()
59+
iters = 1_000
60+
non_seq_cst_behaviour_observed = False
61+
62+
def deleter():
63+
barrier.wait()
64+
try:
65+
del spam.eggs
66+
except AttributeError:
67+
pass
68+
else:
69+
try:
70+
del spam.foo
71+
except AttributeError:
72+
nonlocal non_seq_cst_behaviour_observed
73+
non_seq_cst_behaviour_observed = True
74+
# The fact that the else branch was reached implies that
75+
# the `del spam.eggs` call was successful. If that were
76+
# atomic, there is no way for two threads to enter the else
77+
# branch, therefore it must be that only one thread
78+
# attempts to `del spam.foo`. Thus, hitting an
79+
# AttributeError here is non-sequentially-consistent,
80+
# falsifying the assumption that `del spam.eggs` was
81+
# atomic. The test fails if this point is reached.
82+
83+
for _ in range(iters):
84+
spam.eggs = 0
85+
spam.foo = 0
86+
barrier = _testcapi.SpinningBarrier(2)
87+
# threading.Barrier would not create enough contention here
88+
run_in_threads([deleter, deleter])
89+
self.assertFalse(non_seq_cst_behaviour_observed)
90+
4691
def test_T_BOOL(self):
4792
spam_old = _testcapi._test_structmembersType_OldAPI()
4893
spam_new = _testcapi._test_structmembersType_NewAPI()

Modules/_testcapimodule.c

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2642,6 +2642,108 @@ test_soft_deprecated_macros(PyObject *Py_UNUSED(self), PyObject *Py_UNUSED(args)
26422642
Py_RETURN_NONE;
26432643
}
26442644

2645+
/**
2646+
* A spinning barrier is a multithreading barrier similar to threading.Barrier,
2647+
* except that it never parks threads that are waiting on the barrier.
2648+
*
2649+
* This is useful in scenarios where it is desirable to increase contention on
2650+
* the code that follows the barrier. For instance, consider this test:
2651+
*
2652+
* def test_my_method_is_atomic():
2653+
* x = MyClass()
2654+
* b = _testcapi.SpinningBarrier()
2655+
*
2656+
* def thread():
2657+
* b.wait()
2658+
* x.my_method()
2659+
*
2660+
* for _ in range(1_000):
2661+
* threads = [Thread(target=thread), Thread(target=thread)]
2662+
* for t in threads: t.start()
2663+
* for t in threads: t.join()
2664+
*
2665+
* It can be desirable (and sometimes necessary) to increase the contention
2666+
* on x's internal data structure by avoiding threads parking.
2667+
* Here, not parking may become necessary when the code in my_method() is so
2668+
* short that contention-related code paths are never exercised otherwise.
2669+
*
2670+
* It is roughly equivalent to this Python class:
2671+
*
2672+
* class SpinningBarrier:
2673+
* def __init__(self, parties: int):
2674+
* self.parties = AtomicInt(parties) # if only we had atomic integers
2675+
*
2676+
* def wait(self):
2677+
* v = self.parties.decrement_and_get()
2678+
* while True:
2679+
* if v < 0:
2680+
* raise ValueError("wait was called too many times")
2681+
* if v == 0:
2682+
* return
2683+
* v = self.parties.get()
2684+
*
2685+
*/
2686+
2687+
typedef struct {
2688+
PyObject_HEAD
2689+
int parties;
2690+
} SpinningBarrier;
2691+
2692+
int
2693+
SpinningBarrier_init(PyObject *self, PyObject *args, PyObject *kwargs)
2694+
{
2695+
int parties = 0;
2696+
const char *kwlist[] = {"parties", NULL};
2697+
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "i", (char **)kwlist, &parties)) {
2698+
return -1;
2699+
}
2700+
if (parties <= 0) {
2701+
PyErr_SetString(PyExc_ValueError, "parties must be greater than zero");
2702+
return -1;
2703+
}
2704+
2705+
SpinningBarrier *self_b = (SpinningBarrier *) self;
2706+
self_b->parties = parties;
2707+
2708+
return 0;
2709+
}
2710+
2711+
PyObject *
2712+
SpinningBarrier_wait(PyObject *self, PyObject *Py_UNUSED(args))
2713+
{
2714+
SpinningBarrier *self_b = (SpinningBarrier *) self;
2715+
const long decremented = _Py_atomic_add_int(&self_b->parties, -1) - 1;
2716+
long v = decremented;
2717+
while (1) {
2718+
if (v < 0) {
2719+
PyErr_SetString(PyExc_ValueError, "wait was called too many times");
2720+
return NULL;
2721+
}
2722+
if (v == 0) {
2723+
return PyLong_FromLong(decremented);
2724+
}
2725+
v = _Py_atomic_load_int_relaxed(&self_b->parties);
2726+
if (PyErr_CheckSignals()) {
2727+
return NULL;
2728+
}
2729+
}
2730+
}
2731+
2732+
static PyMethodDef SpinningBarrier_methods[] = {
2733+
{"wait", SpinningBarrier_wait, METH_NOARGS},
2734+
{NULL, NULL},
2735+
};
2736+
2737+
static PyTypeObject SpinningBarrier_Type = {
2738+
PyVarObject_HEAD_INIT(NULL, 0)
2739+
.tp_name = "SpinningBarrier",
2740+
.tp_basicsize = sizeof(SpinningBarrier),
2741+
.tp_new = PyType_GenericNew,
2742+
.tp_free = PyObject_Free,
2743+
.tp_init = &SpinningBarrier_init,
2744+
.tp_methods = SpinningBarrier_methods,
2745+
};
2746+
26452747
static PyMethodDef TestMethods[] = {
26462748
{"set_errno", set_errno, METH_VARARGS},
26472749
{"test_config", test_config, METH_NOARGS},
@@ -3369,6 +3471,12 @@ _testcapi_exec(PyObject *m)
33693471
Py_INCREF(&MethStatic_Type);
33703472
PyModule_AddObject(m, "MethStatic", (PyObject *)&MethStatic_Type);
33713473

3474+
if (PyType_Ready(&SpinningBarrier_Type) < 0) {
3475+
return -1;
3476+
}
3477+
Py_INCREF(&SpinningBarrier_Type);
3478+
PyModule_AddObject(m, "SpinningBarrier", (PyObject *)&SpinningBarrier_Type);
3479+
33723480
PyModule_AddObject(m, "CHAR_MAX", PyLong_FromLong(CHAR_MAX));
33733481
PyModule_AddObject(m, "CHAR_MIN", PyLong_FromLong(CHAR_MIN));
33743482
PyModule_AddObject(m, "UCHAR_MAX", PyLong_FromLong(UCHAR_MAX));

Python/structmember.c

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -171,19 +171,10 @@ PyMember_SetOne(char *addr, PyMemberDef *l, PyObject *v)
171171
PyErr_SetString(PyExc_AttributeError, "readonly attribute");
172172
return -1;
173173
}
174-
if (v == NULL) {
175-
if (l->type == Py_T_OBJECT_EX) {
176-
/* Check if the attribute is set. */
177-
if (*(PyObject **)addr == NULL) {
178-
PyErr_SetString(PyExc_AttributeError, l->name);
179-
return -1;
180-
}
181-
}
182-
else if (l->type != _Py_T_OBJECT) {
183-
PyErr_SetString(PyExc_TypeError,
184-
"can't delete numeric/char attribute");
185-
return -1;
186-
}
174+
if (v == NULL && l->type != Py_T_OBJECT_EX && l->type != _Py_T_OBJECT) {
175+
PyErr_SetString(PyExc_TypeError,
176+
"can't delete numeric/char attribute");
177+
return -1;
187178
}
188179
switch (l->type) {
189180
case Py_T_BOOL:{
@@ -335,6 +326,19 @@ PyMember_SetOne(char *addr, PyMemberDef *l, PyObject *v)
335326
FT_ATOMIC_STORE_PTR_RELEASE(*(PyObject **)addr, Py_XNewRef(v));
336327
Py_END_CRITICAL_SECTION();
337328
Py_XDECREF(oldv);
329+
if (v == NULL && oldv == NULL && l->type == Py_T_OBJECT_EX) {
330+
// Pseudo-non-existing attribute is deleted: raise AttributeError.
331+
// The attribute doesn't exist to Python, but CPython knows that it
332+
// could have existed because it was declared in __slots__.
333+
// _Py_T_OBJECT does not raise an exception here, and
334+
// PyMember_GetOne will return Py_None instead of NULL.
335+
PyErr_SetString(PyExc_AttributeError, l->name);
336+
return -1;
337+
}
338+
// Other cases are already covered by the above:
339+
// oldv == NULL && v != NULL: pseudo-non-existing attribute is set, ok
340+
// oldv != NULL && v == NULL: existing attribute is deleted, ok
341+
// oldv != NULL && v != NULL: existing attribute is set, ok
338342
break;
339343
case Py_T_CHAR: {
340344
const char *string;

Tools/c-analyzer/cpython/ignored.tsv

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,7 @@ Modules/_testcapimodule.c - meth_class_methods -
542542
Modules/_testcapimodule.c - meth_instance_methods -
543543
Modules/_testcapimodule.c - meth_static_methods -
544544
Modules/_testcapimodule.c - ml -
545+
Modules/_testcapimodule.c - SpinningBarrier_Type -
545546
Modules/_testcapimodule.c - str1 -
546547
Modules/_testcapimodule.c - str2 -
547548
Modules/_testcapimodule.c - test_c_thread -

0 commit comments

Comments
 (0)