Skip to content

Lazy properties are not threadsafe #11221

Description

@bmerry

Description

The @lazyproperty decorator and @classproperty decorator with lazy=True have race conditions when accessing the cache, which can lead to sporadic errors when using astropy from multithreaded code e.g. dask. One such case is with the Unit parser.

The Python 3.8 cached_property (mentioned in #9036) is thread-safe, and looking at the implementation it seems to use a double-checked locking pattern so the approach shouldn't hurt performance. Let me know if you'd like me to make a PR.

Expected behavior

I expect to be able to parse unit strings safely from multi-threaded code.

Actual behavior

Running the script below sometimes leads to this exception (I run the script in a loop in the shell and it triggers within a second):

Traceback (most recent call last):
  File "/home/bmerry/src/astropy/astropy/units/format/generic.py", line 562, in _do_parse
    return cls._parse_unit(s, detailed_exception=False)
  File "/home/bmerry/src/astropy/astropy/units/format/generic.py", line 485, in _parse_unit
    raise ValueError()
ValueError

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/bmerry/src/astropy/astropy/units/format/generic.py", line 565, in _do_parse
    return cls._parser.parse(s, lexer=cls._lexer, debug=debug)
  File "/home/bmerry/src/astropy/astropy/extern/ply/yacc.py", line 333, in parse
    return self.parseopt_notrack(input, lexer, debug, tracking, tokenfunc)
  File "/home/bmerry/src/astropy/astropy/extern/ply/yacc.py", line 1201, in parseopt_notrack
    tok = call_errorfunc(self.errorfunc, errtoken, self)
  File "/home/bmerry/src/astropy/astropy/extern/ply/yacc.py", line 192, in call_errorfunc
    r = errorfunc(token)
  File "/home/bmerry/src/astropy/astropy/units/format/generic.py", line 443, in p_error
    raise ValueError()
ValueError

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/bmerry/src/astropy/astropy/units/core.py", line 1850, in __call__
    return f.parse(s)
  File "/home/bmerry/src/astropy/astropy/units/format/generic.py", line 547, in parse
    result = cls._do_parse(s, debug=debug)
  File "/home/bmerry/src/astropy/astropy/units/format/generic.py", line 570, in _do_parse
    raise ValueError(f"Syntax error parsing unit '{s}'")
ValueError: Syntax error parsing unit 'm / (s)'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "./astropy-constants-threading.py", line 29, in <module>
    main()
  File "./astropy-constants-threading.py", line 26, in main
    raise excs[0]
  File "./astropy-constants-threading.py", line 11, in thread_func
    3 * u.Unit('m / (s)') + 1 * (u.km / u.s)
  File "/home/bmerry/src/astropy/astropy/units/core.py", line 1870, in __call__
    raise ValueError(msg)
ValueError: 'm / (s)' did not parse as unit: Syntax error parsing unit 'm / (s)' If this is meant to be a custom unit, define it with 'u.def_unit'. To have it recognized inside a file reader or other code, enable it with 'u.add_enabled_units'. For details, see https://docs.astropy.org/en/latest/units/combining_and_defining.html

I'm not totally sure that this is the cause (or is the only cause), but astropy.units.format.generic.Generic uses lazy class properties for the lexer and parser, and if several threads try to access the property at the same time, they could each try to build their own copy. I don't know about the lexer, but the comments at the top of astropy/extern/ply/yacc.py suggest that building parsers in parallel is a Bad Idea.

Steps to Reproduce

Run this code in bash with while ./astropy-constants-threading.py ; do :; done until it errors.

#!/usr/bin/env python3

import sys
import threading

import astropy.units as u


def thread_func(excs):
    try:
        u.Unit('m / (s)')
    except Exception as exc:
        excs.append(exc)


def main():
    sys.setswitchinterval(1e-6)
    excs = []
    threads = [threading.Thread(target=thread_func, args=(excs,))
               for i in range(32)]
    for thread in threads:
        thread.start()
    for thread in threads:
        thread.join()
    if excs:
        raise excs[0]


main()

System Details

Linux-5.4.0-58-generic-x86_64-with-glibc2.29
Python 3.8.5 (default, Jul 28 2020, 12:59:40) 
[GCC 9.3.0]
Numpy 1.19.1
astropy 4.3.dev439+g4b242e6f5
Scipy 1.5.2
Matplotlib 3.3.0

Activity

  1. pllim commented on Jan 7, 2021

    @pllim
    Member

    Re: cached_property -- I am not sure when we're dropping support for Python 3.7 though.

    As for units, perhaps @mhvk can advise?

  2. mhvk commented on Jan 7, 2021

    @mhvk
    Contributor

    Thanks for reporting! I think @embray is most relevant here since he wrote the code... But I'm obviously interested in getting the parser/lexer to work more reliably too!

    Just for reference, the cached property implementation is at https://github.com/python/cpython/blob/49c150f1f16398a4e77e051244f27adc5ac7b47b/Lib/functools.py#L933-L980
    A difference with lazyproperty is that it does not allow having a setter.

    I guess there is also a more general question to what extent we aim to be thread-safe, as I think so far we have not tried much (balance with readability and speed for unthreaded). Though certainly for widely used things such as lazyproperty, we might as we well try to do it right, and similarly for the unit parsing/lexing. And I would love astropy to interact well with dask...

  3. bmerry commented on Jan 8, 2021

    @bmerry
    ContributorAuthor

    Re: cached_property -- I am not sure when we're dropping support for Python 3.7 though.

    Sorry, I was rambling a bit and didn't make a clear point. My point here is that cached_property is proof that it's possible to make this thread-safe without sacrificing performance for the already-initialised case - although I haven't considered setters. Are setters a performance-critical path, or are they expected to be used just occasionally?

    I will make PRs for this and #11220.

  4. added a commit that references this issue on Jan 8, 2021
    aa544e0
  5. bmerry commented on Jan 8, 2021

    @bmerry
    ContributorAuthor

    I've made a PR. Unfortunately that turns out not to fix the specific Unit bug I mentioned, so I'll create a separate issue for that.

  6. bmerry commented on Jan 8, 2021

    @bmerry
    ContributorAuthor

    I guess there is also a more general question to what extent we aim to be thread-safe, as I think so far we have not tried much (balance with readability and speed for unthreaded).

    The context of these thread safety bugs is that these are all issues that have arisen when trying to incorporate astropy into some code for the Square Kilometre Array.

  7. mhvk commented on Jan 8, 2021

    @mhvk
    Contributor

    @bmerry, thanks! I don't think there is much worry about performance for the setters. And, yes, helping to get SKA working is definitely a good reason!

  8. added 2 commits that reference this issue on Feb 21, 2021
    7b6784b
    d9feeb8
  9. added a commit that references this issue on Feb 28, 2021
    92bdf4d
  10. added a commit that references this issue on Mar 9, 2021
    fb9c3e5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions