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
15 changes: 12 additions & 3 deletions Doc/library/functools.rst
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ The :mod:`!functools` module defines the following functions:
It is possible for the wrapped function to be called more than once if
another thread makes an additional call before the initial call has been
completed and cached.
However, even when this happens all threads will return the same object.

.. versionchanged:: next
In earlier versions the unbound cache was not guaranteed idempotent
for threaded access.

Call-once behavior is not guaranteed because locks are not held during the
function call. Potentially another call with the same arguments could
Expand Down Expand Up @@ -90,9 +95,8 @@ The :mod:`!functools` module defines the following functions:

The *cached_property* does not prevent a possible race condition in
multi-threaded usage. The getter function could run more than once on the
same instance, with the latest run setting the cached value. If the cached
property is idempotent or otherwise not harmful to run more than once on an
instance, this is fine. If synchronization is needed, implement the necessary
same instance, with the first run setting the cached value.
If it is necessary to guarantee work is only done once, implement the necessary
locking inside the decorated getter function or around the cached property
access.

Expand All @@ -114,6 +118,11 @@ The :mod:`!functools` module defines the following functions:

.. versionadded:: 3.8

.. versionchanged:: next
In earlier versions the :deco:`cached_property` decorator was not guaranteed
idempotent for threaded access. For simultaneous access multiple threads
might all set the cached value.

.. versionchanged:: 3.12
Prior to Python 3.12, :deco:`!cached_property` included an undocumented lock to
ensure that in multi-threaded usage the getter function was guaranteed to
Expand Down
5 changes: 5 additions & 0 deletions Include/internal/pycore_dict.h
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ PyAPI_FUNC(int) _PyDict_DelItemIf(PyObject *mp, PyObject *key,
// Export for '_asyncio' shared extension
PyAPI_FUNC(int) _PyDict_SetItem_KnownHash(PyObject *mp, PyObject *key,
PyObject *item, Py_hash_t hash);
// Same return codes as PyDict_SetDefaultRef (0 inserted, 1 present, -1 error).
PyAPI_FUNC(int) _PyDict_SetDefaultRef_KnownHash(PyObject *mp, PyObject *key,
PyObject *default_value,
Py_hash_t hash,
PyObject **result);
// Export for '_asyncio' shared extension
PyAPI_FUNC(int) _PyDict_DelItem_KnownHash(PyObject *mp, PyObject *key,
Py_hash_t hash);
Expand Down
7 changes: 4 additions & 3 deletions Lib/functools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1173,13 +1173,14 @@ def __get__(self, instance, owner=None):
if val is _NOT_FOUND:
val = self.func(instance)
try:
cache[self.attrname] = val
except TypeError:
setdefault = cache.setdefault
except AttributeError:
msg = (
f"The '__dict__' attribute on {type(instance).__name__!r} instance "
f"does not support item assignment for caching {self.attrname!r} property."
f"does not support 'setdefault' for caching {self.attrname!r} property."
)
raise TypeError(msg) from None
val = setdefault(self.attrname, val)
return val

__class_getitem__ = classmethod(GenericAlias)
56 changes: 55 additions & 1 deletion Lib/test/test_free_threading/test_functools.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import random
import time
import unittest

from functools import lru_cache
from functools import lru_cache, cached_property
from threading import Barrier, Thread

from test.support import threading_helper
Expand Down Expand Up @@ -70,6 +71,59 @@ def test_reentrant_cache_clear_unbounded(self):
def test_reentrant_cache_clear_bounded(self):
self._test_reentrant_cache_clear(maxsize=128)

def test_unbounded_cache_idempotent(self):
all_identical = True

@lru_cache(maxsize=None)
def func(arg=0):
time.sleep(0.01)
return object()

def thread_func():
nonlocal all_identical

for i in range(1000):
if func(i) is not func(i):
all_identical = False

threads = []
for _ in range(10):
t = Thread(target=thread_func)
threads.append(t)

with threading_helper.start_threads(threads):
pass

self.assertTrue(all_identical)


def test_cached_property_idempotent(self):
all_identical = True

class C:
@cached_property
def prop(self):
time.sleep(0.01)
return object()

c = C()

def thread_func():
nonlocal all_identical

if c.prop is not c.prop:
all_identical = False

threads = []
for _ in range(10):
t = Thread(target=thread_func)
threads.append(t)

with threading_helper.start_threads(threads):
pass

self.assertTrue(all_identical)


if __name__ == "__main__":
unittest.main()
2 changes: 1 addition & 1 deletion Lib/test/test_functools.py
Original file line number Diff line number Diff line change
Expand Up @@ -3723,7 +3723,7 @@ class MyClass(metaclass=MyMeta):

with self.assertRaisesRegex(
TypeError,
"The '__dict__' attribute on 'MyMeta' instance does not support item assignment for caching 'prop' property.",
"The '__dict__' attribute on 'MyMeta' instance does not support 'setdefault' for caching 'prop' property.",
):
MyClass.prop

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
:deco:`functools.cache` and :deco:`functools.cached_property` now guarantee an idempotent result on threaded access. This does not guarantee that the underlying function will be called exactly once, but once a value is cached, concurrent accesses will not overwrite it.
12 changes: 7 additions & 5 deletions Modules/_functoolsmodule.c
Original file line number Diff line number Diff line change
Expand Up @@ -1329,13 +1329,15 @@ infinite_lru_cache_wrapper(lru_cache_object *self, PyObject *args, PyObject *kwd
Py_DECREF(key);
return NULL;
}
if (_PyDict_SetItem_KnownHash(self->cache, key, result, hash) < 0) {
Py_DECREF(result);
Py_DECREF(key);
PyObject *cached_value;
res = _PyDict_SetDefaultRef_KnownHash(
self->cache, key, result, hash, &cached_value);
Py_DECREF(result);
Py_DECREF(key);
if (res < 0) {
return NULL;
}
Py_DECREF(key);
return result;
return cached_value;
}

static void
Expand Down
94 changes: 69 additions & 25 deletions Objects/dictobject.c
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,10 @@ setitem_lock_held(PyDictObject *mp, PyObject *key, PyObject *value);
static int
dict_setdefault_ref_lock_held(PyObject *d, PyObject *key, PyObject *default_value,
PyObject **result, int incref_result);
static int
setdefault_ref_lock_held_known_hash(PyDictObject *mp, PyObject *key,
PyObject *default_value, Py_hash_t hash,
PyObject **result, int incref_result);

#ifndef NDEBUG
static int _PyObject_InlineValuesConsistencyCheck(PyObject *obj);
Expand Down Expand Up @@ -4810,37 +4814,16 @@ dict_get_impl(PyDictObject *self, PyObject *key, PyObject *default_value)
}

static int
dict_setdefault_ref_lock_held(PyObject *d, PyObject *key, PyObject *default_value,
setdefault_ref_lock_held_known_hash(PyDictObject *mp, PyObject *key,
PyObject *default_value, Py_hash_t hash,
PyObject **result, int incref_result)
{
if (!PyDict_Check(d)) {
if (PyFrozenDict_Check(d)) {
frozendict_does_not_support("assignment");
}
else {
PyErr_BadInternalCall();
}
if (result) {
*result = NULL;
}
return -1;
}
assert(can_modify_dict((PyDictObject*)d));
assert(can_modify_dict(mp));
assert(hash != -1);

PyDictObject *mp = (PyDictObject *)d;
PyObject *value;
Py_hash_t hash;
Py_ssize_t ix;

hash = _PyObject_HashDictKey(key);
if (hash == -1) {
dict_unhashable_type(d, key);
if (result) {
*result = NULL;
}
return -1;
}

if (mp->ma_keys == Py_EMPTY_KEYS) {
if (insert_to_emptydict(mp, Py_NewRef(key), hash,
Py_NewRef(default_value)) < 0) {
Expand Down Expand Up @@ -4911,6 +4894,67 @@ dict_setdefault_ref_lock_held(PyObject *d, PyObject *key, PyObject *default_valu
return 1;
}

static int
dict_setdefault_ref_lock_held(PyObject *d, PyObject *key, PyObject *default_value,
PyObject **result, int incref_result)
{
if (!PyDict_Check(d)) {
if (PyFrozenDict_Check(d)) {
frozendict_does_not_support("assignment");
}
else {
PyErr_BadInternalCall();
}
if (result) {
*result = NULL;
}
return -1;
}

Py_hash_t hash = _PyObject_HashDictKey(key);
if (hash == -1) {
dict_unhashable_type(d, key);
if (result) {
*result = NULL;
}
return -1;
}

return setdefault_ref_lock_held_known_hash(
(PyDictObject *)d, key, default_value, hash, result, incref_result);
}

int
_PyDict_SetDefaultRef_KnownHash(PyObject *d, PyObject *key,
PyObject *default_value, Py_hash_t hash,
PyObject **result)
{
assert(key);
assert(default_value);
assert(hash != -1);

if (!PyDict_Check(d)) {
if (PyFrozenDict_Check(d)) {
frozendict_does_not_support("assignment");
}
else {
PyErr_BadInternalCall();
}
if (result) {
*result = NULL;
}
return -1;
}

int res;
Py_BEGIN_CRITICAL_SECTION(d);
res = setdefault_ref_lock_held_known_hash(
(PyDictObject *)d, key, default_value, hash, result, 1);
Py_END_CRITICAL_SECTION();
return res;
}


int
PyDict_SetDefaultRef(PyObject *d, PyObject *key, PyObject *default_value,
PyObject **result)
Expand Down
Loading