Repository navigation
functools.cached_property incorrectly locks the entire descriptor on class instead of per-instance locking #87634
Description
Activity
The locking on functools.cached_property (
) as it was written is completely undesirable for I/O bound values, parallel processing. Instead of protecting the calculation of cached property to the same instance in two threads, it completely blocks parallel calculations of cached values to *distinct instances* of the same class.Line 934 in 87f649a
class cached_property: 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
reifydecorator in favour of usingcached_property, and then noticed why it won't do.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 amount of ham in <main.Spam object at 0x7fa50bcaa220> is 97
Calculating amount of ham in <main.Spam object at 0x7fa50bcaa4f0>
The amount of ham in <main.Spam object at 0x7fa50bcaa4f0> is 8
Calculating amount of ham in <main.Spam object at 0x7fa50bcaa7c0>
The amount of ham in <main.Spam object at 0x7fa50bcaa7c0> is 53
Total running time was 30.02147102355957The runtime is 30 seconds; for
pyramid.decorator.reifythe runtime would be 10 seconds:Calculating amount of ham in <main.Spam object at 0x7fc4d8272430>
Calculating amount of ham in <main.Spam object at 0x7fc4d82726d0>
Calculating amount of ham in <main.Spam object at 0x7fc4d8272970>
The amount of ham in <main.Spam object at 0x7fc4d82726d0> is 94
The amount of ham in <main.Spam object at 0x7fc4d8272970> is 29
The amount of ham in <main.Spam object at 0x7fc4d8272430> is 93
Total running time was 10.010624170303345reifyin Pyramid is used heavily to add properties to incoming HTTP request objects - usingfunctools.cached_propertyinstead would mean that each independent request thread blocks others because most of them would always get the value for the same lazy property using the the same descriptor instance and locking the same lock.- added3.8 (EOL)end of lifeend of life3.9 (EOL)end of lifeend of life3.10 (EOL)end of lifeend of lifestdlibStandard Library Python modules in the Lib/ directoryStandard Library Python modules in the Lib/ directoryperformancePerformance or resource usagePerformance or resource usage
on Mar 11, 2021 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
__dict__, guarded by the per-descriptor lock; using a per-descriptorWeakKeyDictionaryto map the instance to locks (which would of course not work - is there any way to map unhashable instances weakly?)So far best ideas that I have heard from others or discovered myself are along the lines of "remove locking altogether" (breaks compatibility); "add
thread_unsafekeyword argument" with documentation saying that this is what you want to use if you're actually running threads; "implement Java-style object monitors and synchronized methods in CPython and use those instead"; or "create yet another method".- changed the title
[-]functools.cached_property locking is plain wrong.[/-][+]functools.cached_property incorrectly locks the entire descriptor on class instead of per-instance locking[/+]on Mar 17, 2021 - changed the title
[-]functools.cached_property locking is plain wrong.[/-][+]functools.cached_property incorrectly locks the entire descriptor on class instead of per-instance locking[/+]on Mar 17, 2021 20 remaining items
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:
- Just rip out the locking code without a deprecation in code. This would fix the bug and improve performance for most users, but it would break users who rely on the current per-class locking. Do any such users exist? I'm not convinced they do, but our general policy is to assume yes. Perhaps we can justify a BC-breaking change here if we clearly document the behavior change and call it out widely in documentation. Concretely, we could do a docs-only deprecation in 3.12 and 3.13 and then remove the locking code in 3.14.
- Create a new property that is like cached_property but without locking. But this has most of the same problems as gh-87634: deprecate cached_property locking, add lock kwarg #98123 had, and it's hard to think of a good name.
- "Fix" the locking code to be per-instance instead of per-class. But as @carljm calls out in gh-87634: deprecate cached_property locking, add lock kwarg #98123 (comment), this isn't without problems either. The locking code would be complex and pickling may be affected. I would add that this change may itself cause a BC break: hypothetical code that relies on the current behavior might well be protecting some global state instead of per-instance state.
I feel (1) is the best solution, though it's sad to have to wait until 3.14.
Reacted by Carl Meyer, Ryan Williams and Riccardo MoriWhy not:
- Add an optional
lockargument, but without deprecating the current form.
- Add an optional
- Do any such users exist? I'm not convinced they do, but our general policy is to assume yes.
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
lockargument to the existing class without deprecating the current form. That would mean the new class could just be a very simple wrapper:class cached_property_no_lock(cached_property): def __init__(self, func): super().__init__(func, lock=False)
Reacted by Carl Meyer and Ryan WilliamsThanks @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
cached_propertyand depending on the locking. Whereas I have seen many "significant" users (e.g. the Django project) avoid the stdlibcached_propertyentirely because of this bug, and instead continue maintaining their own external lockless version. That said, I think @pitrou is right that there's a reasonable chance that someone, somewhere, even if unaware, is depending on it, and the behavior change when it goes away could be quite hard to debug, so it's probably not a good option :/I think the "change only one line to opt out" advantage of
cached_property_no_lockis significant, and I'm not sure there is any need to provide bothcached_property_no_lockandcached_property(lock=False). The latter is slightly more verbose, more punctuation, and slightly harder to alias to a shorter name (can't be done in the import.) Also there is inherent hard-to-read complexity in the implementation of decorators with optional arguments. So I think "new name" is better than "optional argument."I can submit a new PR with
cached_property_no_lockand no deprecation.Reacted by Alex Waygood, Ryan Williams and Riccardo MoriI think the "change only one line to opt out" advantage of
cached_property_no_lockis significantIt 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
cached_propertywhich comes at the cost of an undocumented global thread lock, or a newercached_property_no_lockthat should be the default choice.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
cached_propertystill stay in the stdlib after a theoreticalcached_property_no_lock?We will either have new users who don't need a lock accidentally using
cached_properywhen they should be usingcached_propery_no_lock, or users needing an instance lock accidentally usingcached_propertywhen they really need an instance-based thread lock, which the stdlib wouldn't offer.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
functools's coherence as a library and to CPython.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.
and the behavior change when it goes away could be quite hard to debug
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
functools.cached_propertyand instead to rely on a third party library.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.
Reacted by Ryan Williams@eendebakpt made another suggestion over on my closed PR #98123 :
What about the following variation? Add a kwarg with values False, True and None.
Value True has the same behaviour as in this PR
Value False has the same behaviour as in this PR
Value None has the same behaviour as True, but detects whether the lock is actually used. Only if the lock is used, a deprecation warning is issued. (not sure whether this is technically possible)
The default value is None for several python releases, after which the default value is changed to False .In this way most of the users (who do not use threading, so no use of the locking) will have the old behaviour for a couple of releases, and then switch to the desired implementation. Only users that make use of the locking will get a deprecation warning, but one that is easy to fix (e.g. update to lock=True).
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
acquire; if it fails, we issue the deprecation warning followed by a blocking call. It is, however, a bit strange to issue a deprecation warning based on a behavior of the system that may be non-deterministic.I haven't submitted the new PR for
cached_property_no_lockyet, mostly because I'm not very happy with that solution either, for the reasons @ionite34 describes, and I still wonder if "just remove the locking, with several versions' doc warning" isn't practically the best option for the vast majority of Python users.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
- added a commit that references this issue
on Feb 13, 2023 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!
Reacted by Alex Waygood, Ryan Williams, Riccardo Mori and Anton Agestam- added a commit that references this issue
on Oct 5, 2023
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