Skip to content

functools.cached_property incorrectly locks the entire descriptor on class instead of per-instance locking #87634

Description

@ztane
mannequin
BPO 43468
Nosy @tim-one, @rhettinger, @ncoghlan, @pitrou, @carljm, @jab, @serhiy-storchaka, @ztane, @graingert, @youtux
PRs
  • bpo-43468: Per instance locking for functools.cached_property  #27609
  • 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 = None
    closed_at = None
    created_at = <Date 2021-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 = <Date 2021-09-07.16:52:45.061>
    user = 'https://github.com/ztane'

    bugs.python.org fields:

    activity = <Date 2021-09-07.16:52:45.061>
    actor = 'pitrou'
    assignee = 'none'
    closed = False
    closed_date = None
    closer = None
    components = ['Library (Lib)']
    creation = <Date 2021-03-11.06:37:47.884>
    creator = 'ztane'
    dependencies = []
    files = []
    hgrepos = []
    issue_num = 43468
    keywords = ['patch']
    message_count = 11.0
    messages = ['388480', '388499', '388592', '398290', '398291', '398680', '398686', '398719', '398837', '398861', '401308']
    nosy_count = 11.0
    nosy_names = ['tim.peters', 'rhettinger', 'ncoghlan', 'pitrou', 'carljm', 'pydanny', 'jab', 'serhiy.storchaka', 'ztane', 'graingert', 'youtux']
    pr_nums = ['27609']
    priority = 'normal'
    resolution = None
    stage = 'patch review'
    status = 'open'
    superseder = None
    type = 'resource usage'
    url = 'https://bugs.python.org/issue43468'
    versions = ['Python 3.8', 'Python 3.9', 'Python 3.10']

    Linked PRs

    Activity

    1. ztane commented on Mar 11, 2021

      ztanemannequin
      MannequinAuthor

      The locking on functools.cached_property (

      class 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.

      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 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.

    2. added
      stdlibStandard Library Python modules in the Lib/ directory
      performancePerformance or resource usage
      on Mar 11, 2021
    3. ztane commented on Mar 11, 2021

      ztanemannequin
      MannequinAuthor

      Django was going to replace their cached_property by the standard library one https://code.djangoproject.com/ticket/30949

    4. ztane commented on Mar 13, 2021

      ztanemannequin
      MannequinAuthor

      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".

    5. 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
    6. 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
    7. 20 remaining items

    8. JelleZijlstra commented on Dec 16, 2022

      @JelleZijlstra
      Member

      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:

      1. 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.
      2. 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.
      3. "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.

    9. pitrou commented on Dec 16, 2022

      @pitrou
      Member

      Why not:

      1. Add an optional lock argument, but without deprecating the current form.
    10. pitrou commented on Dec 16, 2022

      @pitrou
      Member
      1. 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.

    11. AlexWaygood commented on Dec 16, 2022

      @AlexWaygood
      Member

      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:

      class cached_property_no_lock(cached_property):
          def __init__(self, func):
              super().__init__(func, lock=False)
    12. carljm commented on Dec 16, 2022

      @carljm
      Member

      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.

    13. ionite34 commented on Dec 23, 2022

      @ionite34
      Contributor

      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.

    14. carljm commented on Feb 9, 2023

      @carljm
      Member

      @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.

    15. carljm commented on Feb 9, 2023

      @carljm
      Member

      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?

    16. carljm commented on Feb 11, 2023

      @carljm
      Member
    17. added a commit that references this issue on Feb 13, 2023
    18. added a commit that references this issue on Feb 23, 2023
    19. carljm commented on Mar 14, 2023

      @carljm
      Member

      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!

    20. added a commit that references this issue on Sep 10, 2024
    Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

    Metadata

    Metadata

    Assignees

    No one assigned

      Labels

      3.12only security fixesperformancePerformance or resource usagestdlibStandard Library Python modules in the Lib/ directorytype-featureA feature request or enhancement

      Projects

      No projects

        Milestone

        No milestone

        Relationships

        None yet

        Development

        No branches or pull requests

        Issue actions