Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions Lib/test/test_free_threading/test_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,5 +356,61 @@ def writer():

threading_helper.run_concurrently([reader, writer])

def test_racing_dict_update_and_method_lookup_with_inline_values(self):
# gh-149816: sub-case 108
# The race below checks that a detached dict is still valid
# when racing setattr with __dict__ replacement
class Target:
pass

def appender(obj: Target, start: Barrier, iter_times: int) -> None:
start.wait()
index = 0
for _ in range(iter_times):
setattr(obj, f"probe_{index}", index)
index += 1
time.sleep(0)

def replacer(obj: Target, start: Barrier, iter_times: int, churn_size: int) -> None:
start.wait()
for _ in range(iter_times):
old_dict = obj.__dict__
obj.__dict__ = {}
del old_dict
time.sleep(0)
# create a list of dicts to trigger a realloc of the dict's table
# and ensure that the old dict is not used after it is deleted
realloc_trigger_list = [{"k": j} for j in range(churn_size)]
del realloc_trigger_list

def race(iter_times: int,
churn_size: int,
appender_threads: int,
replacer_threads: int) -> None:
obj = Target()
setattr(obj, "origin", 0)
_ = obj.__dict__ # Access __dict__ to ensure it's initialized

start = Barrier(appender_threads + replacer_threads)
threads = []
for _ in range(appender_threads):
threads.append(Thread(target=appender, args=(obj, start, iter_times),
name="appender"))
for _ in range(replacer_threads):
threads.append(Thread(target=replacer, args=(obj, start, iter_times, churn_size),
name="replacer"))

with threading_helper.catch_threading_exception() as cm:
with threading_helper.start_threads(threads):
pass
if cm.exc_type is not None:
raise cm.exc_value

ITER_TIMES = 2_000
APPENDER_THREADS = 8
REPLACER_THREADS = 8
CHURN_SIZE = 512
race(ITER_TIMES, CHURN_SIZE, APPENDER_THREADS, REPLACER_THREADS)

if __name__ == "__main__":
unittest.main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Fix a race in free-threaded builds where storing an instance attribute could
use a stale borrowed reference to an instance ``__dict__`` after another thread
replaced it.
18 changes: 13 additions & 5 deletions Objects/dictobject.c
Original file line number Diff line number Diff line change
Expand Up @@ -7556,17 +7556,25 @@ _PyObject_StoreInstanceAttribute(PyObject *obj, PyObject *name, PyObject *value)
{
PyDictValues *values = _PyObject_InlineValues(obj);
if (!FT_ATOMIC_LOAD_UINT8(values->valid)) {
PyDictObject *dict = _PyObject_GetManagedDict(obj);
PyDictObject *dict;
int res;
#ifdef Py_GIL_DISABLED
Py_BEGIN_CRITICAL_SECTION(obj);
#endif
dict = _PyObject_GetManagedDict(obj);
Py_XINCREF(dict);
#ifdef Py_GIL_DISABLED
Py_END_CRITICAL_SECTION();
#endif
if (dict == NULL) {
dict = (PyDictObject *)PyObject_GenericGetDict(obj, NULL);
if (dict == NULL) {
return -1;
}
int res = store_instance_attr_dict(obj, dict, name, value);
Py_DECREF(dict);
return res;
}
return store_instance_attr_dict(obj, dict, name, value);
res = store_instance_attr_dict(obj, dict, name, value);
Py_DECREF(dict);
return res;
}

#ifdef Py_GIL_DISABLED
Expand Down
Loading