classification
Title: functools.cached_property incorrectly locks the entire descriptor on class instead of per-instance locking
Type: resource usage Stage: patch review
Components: Library (Lib) Versions: Python 3.10, Python 3.9, Python 3.8
process
Status: open Resolution:
Dependencies: Superseder:
Assigned To: Nosy List: carljm, graingert, jab, ncoghlan, pitrou, pydanny, rhettinger, serhiy.storchaka, tim.peters, youtux, ztane
Priority: normal Keywords: patch

Created on 2021-03-11 06:37 by ztane, last changed 2021-09-07 16:52 by pitrou.

Pull Requests
URL Status Linked Edit
PR 27609 open rhettinger, 2021-08-04 22:49
Messages (11)
msg388480 - (view) Author: Antti Haapala (ztane) * Date: 2021-03-11 06:37
The locking on functools.cached_property (https://github.com/python/cpython/blob/87f649a409da9d99682e78a55a83fc43225a8729/Lib/functools.py#L934) 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):
        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 [`reify`](https://docs.pylonsproject.org/projects/pyramid/en/latest/_modules/pyramid/decorator.html#reify) decorator in favour of using `cached_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.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.
msg388499 - (view) Author: Antti Haapala (ztane) * Date: 2021-03-11 11:29
Django was going to replace their cached_property by the standard library one https://code.djangoproject.com/ticket/30949
msg388592 - (view) Author: Antti Haapala (ztane) * Date: 2021-03-13 04:58
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".
msg398290 - (view) Author: Thomas Grainger (graingert) * Date: 2021-07-27 10:16
how about deprecating the functools.cached_property?
msg398291 - (view) Author: Thomas Grainger (graingert) * Date: 2021-07-27 10:28
> 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?)

there's an issue for that too: https://bugs.python.org/issue44140
msg398680 - (view) Author: Raymond Hettinger (rhettinger) * (Python committer) Date: 2021-08-01 03:36
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?
msg398686 - (view) Author: Serhiy Storchaka (serhiy.storchaka) * (Python committer) Date: 2021-08-01 06:39
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.
msg398719 - (view) Author: Raymond Hettinger (rhettinger) * (Python committer) Date: 2021-08-01 18:06
> It is hard to get rid of the execution lock once 
> it was introduced, but I think we should do it.

It's likely some apps are relying on the locking feature.
So if we can fix it, we should do prefer that over more
disruptive changes.

Addenda to my code sketch:  It should use "(id(instance), instance)" rather than just "instance" as the key.
msg398837 - (view) Author: Serhiy Storchaka (serhiy.storchaka) * (Python committer) Date: 2021-08-03 16:05
> 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.
msg398861 - (view) Author: Raymond Hettinger (rhettinger) * (Python committer) Date: 2021-08-04 05:19
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

        # Call the underlying function to compute the value.
        try:
            val = self.func(instance)
        except Exception:
            val = _EXCEPTION_RAISED

        # Attempt to store the value
        try:
            cache[self.attrname] = val
        except TypeError:
            # Note: we have no way to communicate this exception to
            # threads waiting on the condition variable.  However, the
            # inability to store an attribute is a programming problem
            # rather than a runtime problem -- this exception would
            # likely occur early in testing rather than being runtime
            # event triggered by specific data.
            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

        # 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
msg401308 - (view) Author: Antoine Pitrou (pitrou) * (Python committer) Date: 2021-09-07 16:52
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.
History
Date User Action Args
2021-09-07 16:52:45pitrousetnosy: + pitrou
messages: + msg401308
2021-08-26 22:27:30youtuxsetnosy: + youtux
2021-08-04 22:49:34rhettingersetkeywords: + patch
stage: patch review
pull_requests: + pull_request26104
2021-08-04 05:19:26rhettingersetmessages: + msg398861
2021-08-03 16:05:14serhiy.storchakasetmessages: + msg398837
2021-08-01 22:25:09jabsetnosy: + jab
2021-08-01 18:06:38rhettingersetmessages: + msg398719
2021-08-01 14:40:38rhettingersetnosy: + ncoghlan, carljm, pydanny
2021-08-01 06:39:17serhiy.storchakasetmessages: + msg398686
2021-08-01 03:36:05rhettingersetnosy: + tim.peters, serhiy.storchaka
messages: + msg398680
2021-07-27 10:28:56graingertsetmessages: + msg398291
2021-07-27 10:16:34graingertsetnosy: + graingert
messages: + msg398290
2021-03-17 06:33:50ztanesettitle: functools.cached_property locking is plain wrong. -> functools.cached_property incorrectly locks the entire descriptor on class instead of per-instance locking
2021-03-13 04:58:17ztanesetmessages: + msg388592
2021-03-11 11:29:18ztanesetmessages: + msg388499
2021-03-11 06:59:10xtreaksetnosy: + rhettinger
2021-03-11 06:37:47ztanecreate