You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
functools.cached_property incorrectly locks the entire descriptor on class instead of per-instance locking #87634
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:
assignee=Noneclosed_at=Nonecreated_at=<Date2021-03-11.06:37:47.884>labels= ['3.8', 'library', '3.9', '3.10', 'performance']
title='functools.cached_property incorrectly locks the entire descriptor on class instead of per-instance locking'updated_at=<Date2021-09-07.16:52:45.061>user='https://github.com/ztane'
) 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.
Here's the code of __get__ in cached_property:
def__get__(self, instance, owner=None):
ifinstanceisNone:
returnselfifself.attrnameisNone:
raiseTypeError(
"Cannot use cached_property instance without calling __set_name__ on it.")
try:
cache=instance.__dict__exceptAttributeError: # 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."
)
raiseTypeError(msg) fromNoneval=cache.get(self.attrname, _NOT_FOUND)
ifvalis_NOT_FOUND:
withself.lock:
# check if another thread filled cache while we awaited lockval=cache.get(self.attrname, _NOT_FOUND)
ifvalis_NOT_FOUND:
val=self.func(instance)
try:
cache[self.attrname] =valexceptTypeError:
msg= (
f"The '__dict__' attribute on {type(instance).__name__!r} instance "f"does not support item assignment for caching {self.attrname!r} property."
)
raiseTypeError(msg) fromNonereturnval
I noticed this because I was recommending that Pyramid web framework deprecate its much simpler reify decorator in favour of using cached_property, and then noticed why it won't do.
Here is the test case for cached_property:
fromfunctoolsimportcached_propertyfromthreadingimportThreadfromrandomimportrandintimporttimeclassSpam:
@cached_propertydefham(self):
print(f'Calculating amount of ham in {self}')
time.sleep(10)
returnrandint(0, 100)
defbacon():
spam=Spam()
print(f'The amount of ham in {spam} is {spam.ham}')
start=time.time()
threads= []
for_inrange(3):
t=Thread(target=bacon)
threads.append(t)
t.start()
fortinthreads:
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.02147102355957
The runtime is 30 seconds; for pyramid.decorator.reify the 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.010624170303345
reify in Pyramid is used heavily to add properties to incoming HTTP request objects - using functools.cached_property instead 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.
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-descriptor WeakKeyDictionary to 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_unsafe keyword 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
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.
"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 Mori
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 lock argument to the existing class without deprecating the current form. That would mean the new class could just be a very simple wrapper:
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 cached_property and depending on the locking. Whereas I have seen many "significant" users (e.g. the Django project) avoid the stdlib cached_property entirely 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_lock is significant, and I'm not sure there is any need to provide both cached_property_no_lock and cached_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_lock and no deprecation.
Reacted by Alex Waygood, Ryan Williams and Riccardo Mori
I think the "change only one line to opt out" advantage of cached_property_no_lock is significant
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 cached_property which comes at the cost of an undocumented global thread lock, or a newer cached_property_no_lock that 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_property still stay in the stdlib after a theoretical cached_property_no_lock?
We will either have new users who don't need a lock accidentally using cached_propery when they should be using cached_propery_no_lock, or users needing an instance lock accidentally using cached_property when 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_property and 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.
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_lock yet, 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?
Activity
ztane commentedon Mar 11, 2021
The locking on functools.cached_property (
cpython/Lib/functools.py
Line 934 in 87f649a
Here's the code of __get__ in cached_property:
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:
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.02147102355957
The 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.010624170303345
reifyin 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.ztane commentedon Mar 11, 2021
Django was going to replace their cached_property by the standard library one https://code.djangoproject.com/ticket/30949
ztane commentedon Mar 13, 2021
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".[-]functools.cached_property locking is plain wrong.[/-][+]functools.cached_property incorrectly locks the entire descriptor on class instead of per-instance locking[/+][-]functools.cached_property locking is plain wrong.[/-][+]functools.cached_property incorrectly locks the entire descriptor on class instead of per-instance locking[/+]20 remaining items
JelleZijlstra commentedon Dec 16, 2022
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.
pitrou commentedon Dec 16, 2022
Why not:
lockargument, but without deprecating the current form.pitrou commentedon Dec 16, 2022
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.
AlexWaygood commentedon Dec 16, 2022
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:
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:carljm commentedon Dec 16, 2022
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
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.ionite34 commentedon Dec 23, 2022
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
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.
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.
carljm commentedon Feb 9, 2023
@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
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.carljm commentedon Feb 9, 2023
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?
carljm commentedon Feb 11, 2023
I started a thread at https://discuss.python.org/t/finding-a-path-forward-for-functools-cached-property/23757
pythongh-87634: remove locking from functools.cached_property
gh-87634: remove locking from functools.cached_property (GH-101890)
carljm commentedon Mar 14, 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!
Fix use of `@functools.cached_property` for concurrency
pythongh-87634: remove locking from functools.cached_property (python…