-
-
Notifications
You must be signed in to change notification settings - Fork 31.8k
functools.cached_property incorrectly locks the entire descriptor on class instead of per-instance locking #87634
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Comments
The locking on functools.cached_property ( Line 934 in 87f649a
Here's the code of __get__ in cached_property: def __get__(self, instance, owner=None):
if instance is None:
return self
if self.attrname is None:
raise TypeError(
"Cannot use cached_property instance without calling __set_name__ on it.")
try:
cache = instance.__dict__
except AttributeError: # not all objects have __dict__ (e.g. class defines slots)
msg = (
f"No '__dict__' attribute on {type(instance).__name__!r} "
f"instance to cache {self.attrname!r} property."
)
raise TypeError(msg) from None
val = cache.get(self.attrname, _NOT_FOUND)
if val is _NOT_FOUND:
with self.lock:
# check if another thread filled cache while we awaited lock
val = cache.get(self.attrname, _NOT_FOUND)
if val is _NOT_FOUND:
val = self.func(instance)
try:
cache[self.attrname] = val
except TypeError:
msg = (
f"The '__dict__' attribute on {type(instance).__name__!r} instance "
f"does not support item assignment for caching {self.attrname!r} property."
)
raise TypeError(msg) from None
return val I noticed this because I was recommending that Pyramid web framework deprecate its much simpler Here is the test case for cached_property: from functools import cached_property
from threading import Thread
from random import randint
import time
class Spam:
@cached_property
def ham(self):
print(f'Calculating amount of ham in {self}')
time.sleep(10)
return randint(0, 100)
def bacon():
spam = Spam()
print(f'The amount of ham in {spam} is {spam.ham}')
start = time.time()
threads = []
for _ in range(3):
t = Thread(target=bacon)
threads.append(t)
t.start()
for t in threads:
t.join() print(f'Total running time was {time.time() - start}') Calculating amount of ham in <main.Spam object at 0x7fa50bcaa220> The runtime is 30 seconds; for Calculating amount of ham in <main.Spam object at 0x7fc4d8272430>
|
Django was going to replace their cached_property by the standard library one https://code.djangoproject.com/ticket/30949 |
I've been giving thought to implementing the locking on the instance or per instance instead, and there are bad and worse ideas like inserting per (instance, descriptor) into the instance So far best ideas that I have heard from others or discovered myself are along the lines of "remove locking altogether" (breaks compatibility); "add |
how about deprecating the functools.cached_property? |
there's an issue for that too: https://bugs.python.org/issue44140 |
Antti Haapala, I agree that this situation is catastrophic and that we need some way to avoid blocking parallel calculations of cached values for distinct instances of the same class. Here's an idea that might possibly work. Perhaps, hold one lock only briefly to atomically test and set a variable to track which instances are actively being updated. If another thread is performing the update, use a separate condition condition variable to wait for the update to complete. If no other thread is doing the update, we don't need to hold a lock while performing the I/O bound underlying function. And when we're done updating this specific instance, atomically update the set of instances being actively updated and notify threads waiting on the condition variable to wake-up. The key idea is to hold the lock only for variable updates (which are fast) rather than for the duration of the underlying function call (which is slow). Only when this specific instance is being updated do we use a separate lock (wrapped in a condition variable) to block until the slow function call is complete. The logic is hairy, so I've added Serhiy and Tim to the nosy list to help think it through. --- Untested sketch --------------------------------- class cached_property:
def __init__(self, func):
self.update_lock = RLock()
self.instances_other_thread_is_updating = {}
self.cv = Condition(RLock())
...
def __get__(self, instance, owner=None):
if instance is None:
return self
if self.attrname is None:
raise TypeError(
"Cannot use cached_property instance without calling __set_name__ on it.")
try:
cache = instance.__dict__
except AttributeError: # not all objects have __dict__ (e.g. class defines slots)
msg = (
f"No '__dict__' attribute on {type(instance).__name__!r} "
f"instance to cache {self.attrname!r} property."
)
raise TypeError(msg) from None
val = cache.get(self.attrname, _NOT_FOUND)
if val is not _NOT_FOUND:
return val
# Now we need to either call the function or wait for another thread to do it
with self.update_lock:
# Invariant: no more than one thread can report
# that the instance is actively being updated
other_thread_is_updating = instance in instance_being_updated
if other_thread_is_updating:
instance_being_updated.add(instance)
# ONLY if this is the EXACT instance being updated
# will we block and wait for the computed result.
# Other instances won't have to wait
if other_thread_is_updating:
with self.cv:
while instance in instance_being_updated:
self.cv.wait()
return cache[self.attrname]
# Let this thread do the update in this thread
val = self.func(instance)
try:
cache[self.attrname] = val
except TypeError:
msg = (
f"The '__dict__' attribute on {type(instance).__name__!r} instance "
f"does not support item assignment for caching {self.attrname!r} property."
)
raise TypeError(msg) from None
with self.update_lock:
instance_being_updated.discard(instance)
self.cv.notify_all()
return val
# XXX What to do about waiting threads when an exception occurs?
# We don't want them to hang. Should they repeat the underlying
# function call to get their own exception to propagate upwards? |
I think it was a mistake to use lock in cached_property at first place. In most cases there is nothing wrong in evaluating the cached value more than once. lru_cache() does not use a lock for calling the wrapped function, and it works well. The only lock in the Python implementation is for updating internal structure, it is released when the wrapped function is called. The C implementation uses the GIL for this purpose. It is hard to get rid of the execution lock once it was introduced, but I think we should do it. Maybe deprecate cached_property and add a new decorator without a lock. Perhaps add separate decorators for automatically locking method execution, different decorators for different type of locking (global, per-method, per-instance, per-instance-and-method). Java has the synchronized keyword which allows to add implicit lock to a method. As it turned out, it was a bad idea, and it is no longer used in the modern code. |
It's likely some apps are relying on the locking feature. Addenda to my code sketch: It should use "(id(instance), instance)" rather than just "instance" as the key. |
It should use a dict with id(instance) as a key and instance as value. An instance can be non-hashable. |
Here's the latest effort: --------------------------------------------------------------- def __get__(self, instance, owner=None):
if instance is None:
return self
if self.attrname is None:
raise TypeError(
"Cannot use cached_property instance without calling __set_name__ on it.")
try:
cache = instance.__dict__
except AttributeError: # not all objects have __dict__ (e.g. class defines slots)
msg = (
f"No '__dict__' attribute on {type(instance).__name__!r} "
f"instance to cache {self.attrname!r} property."
)
raise TypeError(msg) from None
# Quickly and atomically determine which thread is reponsible
# for doing the update, so other threads can wait for that
# update to complete. If the update is already done, don't
# wait. If the updating thread is reentrant, don't wait.
key = id(self)
this_thread = get_ident()
with self.updater_lock:
val = cache.get(self.attrname, _NOT_FOUND)
if val is not _NOT_FOUND:
return val
wait = self.updater.setdefault(key, this_thread) != this_thread
# ONLY if this instance currently being updated, block and wait
# for the computed result. Other instances won't have to wait.
# If an exception occurred, stop waiting.
if wait:
with self.cv:
while cache.get(self.attrname, _NOT_FOUND) is _NOT_FOUND:
self.cv.wait()
val = cache[self.attrname]
if val is not _EXCEPTION_RAISED:
return val
# Now that the value is stored, threads waiting on the condition
# variable can be awakened and the updater dictionary can be
# cleaned up.
with self.updater_lock:
self.updater.pop(key, None)
cache[self.attrname] = _EXCEPTION_RAISED
self.cv.notify_all()
if val is _EXCEPTION_RAISED:
raise
return val |
In addition to _NOT_FOUND and _EXCEPTION_RAISED, you could have an additional sentinel value _CONCURRENTLY_COMPUTING. Then you don't need to maintain a separate self.updater mapping. |
Sorry everyone for the bad implementation here. The initial PR (based on the widely used implementations in e.g. Django and Pyramid) didn't have locking; I added locking during review at the request of some commenters in the original issue (based on the implementation in some other less-used third-party package), but I think (even apart from this issue) it was a mistake to add it in the first place. I agree with @serhiy-storchaka and https://mobile.twitter.com/kristjanvalur/status/1431027975298895883 that the locking is basically out-of-place here: Python data structures (e.g. dict) guarantee only (mostly via the GIL) that they won't get into invalid internal states and segfault the interpreter in the face of concurrency, they don't guarantee an absence of application-level data races. That's up to the application to determine and apply the locking that it needs at a higher level. I think the best of bad options here is to add either |
|
Is there any progress in this direction? |
Personally I'd vote for the kwarg approach. |
I think from functools import cached_property
from joblib import Parallel, delayed
class AAA:
@cached_property
def num(self): return 111
class BBB(AAA):
@cached_property
def num(self): return 222
def fn(arg): return arg
Parallel(n_jobs=2)(delayed(fn)(bbb) for bbb in [BBB(), BBB()]) Output:
|
PR #98123 was closed because the proposed deprecation approach is too painful for users. Unfortunately that still leaves us without a good solution. Here are some alternatives:
I feel (1) is the best solution, though it's sad to have to wait until 3.14. |
Why not:
|
I'm sure they do. Moreover, this will break code in mysterious and random ways that will leave users puzzled. So I'm strongly -1 on this. |
I suppose the advantage of adding a new class without the locking behaviour is that users will potentially only have to update one line of code in each file to opt out of the locking: -from functools import cached_property
+from functools import cached_property_no_lock as cached_property Adding the new class could maybe be combined with @pitrou's suggestion of adding the optional class cached_property_no_lock(cached_property):
def __init__(self, func):
super().__init__(func, lock=False) |
Thanks @JelleZijlstra for re-starting the conversation here. I'm quite tempted by option (1), because it's clearly what we would do given a time machine, and I haven't yet seen anyone, in any forum, mention a real use case in which they are using I think the "change only one line to opt out" advantage of I can submit a new PR with |
It also seems to create an odd library situation down the road, when new users have to choose between the more version compatible and intuitively named Seems counterintuitive with the idea of no lock being what most users want, and more importantly, expect. The concept of the lock, (and as implemented by third party libraries), has always seemed to be intended to be based on instances and not be global, if we can't rationalize a valid use case for this global lock, why should We will either have new users who don't need a lock accidentally using Choices that benefit seemingly nobody to keep a undocumented implementation detail broken for a theoretical user that likely does not exist would be detrimental to Imo, the current optimistic path is removing the lock from cached_property and documenting this. I wouldn't think it affects backwards compatibility if the lock was an undocumented implementation detail.
I would argue that the vast majority of users are also experiencing hard to debug thread lock issues due to this undocumented global class thread lock. Already this issue is referenced dozens of times across downstream libraries as a caution against switching to A change in undocumented implementation behavior should aim to fix bugs for the majority of users. Under the idea that any affected behavior is breaking BC there would theoretically be no bug fix possible for any feature. Either this issue should be changed under the assumption that the current behavior is not incorrect, or we should fix it. |
@eendebakpt made another suggestion over on my closed PR #98123 :
I think the main problem here is what it means to "detect if the lock is used." The only reasonable definition I can think of would be "is the lock ever held by another thread when we try to access it." I think this is detectable by using a non-blocking call to |
I haven't submitted the new PR for It seems like we have strong differences of opinion here among core developers; there are tradeoffs with any option we choose, and it seems like a largely subjective matter of weighing the tradeoffs. Would a Discourse thread/poll be a reasonable next step to gather more perspectives? Or even bringing the question to the SC? |
I started a thread at https://discuss.python.org/t/finding-a-path-forward-for-functools-cached-property/23757 |
Remove the undocumented locking capabilities of functools.cached_property.
Following extensive discussion in https://discuss.python.org/t/finding-a-path-forward-for-functools-cached-property/23757 that did not reach full consensus of core devs, I asked for a Steering Council ruling on how to handle this issue: python/steering-council#172 The Steering Council's decision was that the locking should be removed in Python 3.12, with a note in What's New, as done in #101890 Thanks everyone for your contributions to this issue! |
- was affecting any submit to executor for mutliple session objects - python/cpython#87634
…GH-101890) Remove the undocumented locking capabilities of functools.cached_property.
Note: these values reflect the state of the issue at the time it was migrated and might not reflect the current state.
Show more details
GitHub fields:
bugs.python.org fields:
Linked PRs
The text was updated successfully, but these errors were encountered: