Skip to content

Add a new internal thread safe handle for asyncio  #128340

Closed

Description

@kumaraditya303

loop.call_soon_threadsafe 1 is a thread safe API to schedule callbacks from other threads however the handle returned by it i.e. asyncio.Handle 2 isn't thread safe like its cancel 3 method. This is an issue even with the GIL because thread switching is preemptive and is a bigger issue for free-threading builds.

Proposal

Add a new _ThreadSafeHandle which inherits from asyncio.Handle but internally uses a mutex to avoid multiple threads cancelling and running the callback at the same time for callbacks scheduled by loop.call_soon_threadsafe API, other APIs such as loop.call_soon will continue to use the old asyncio.Handle.

Linked PRs

Footnotes

  1. https://docs.python.org/3.13/library/asyncio-eventloop.html#asyncio.loop.call_soon_threadsafe ↩

  2. https://docs.python.org/3.13/library/asyncio-eventloop.html#asyncio.Handle ↩

  3. https://docs.python.org/3.13/library/asyncio-eventloop.html#asyncio.Handle.cancel ↩

Activity

  1. graingert commented on Dec 30, 2024

    @graingert
    Contributor

    do you have a use-case for cancelling a call_soon_threadsafe from another thread?

  2. graingert commented on Dec 30, 2024

    @graingert
    Contributor

    It also looks like you don't need a mutex, you could just combine the "self._cancelled" and "self._callback" attributes into one attribute where a None callback causes the handle to be considered cancelled.

  3. kumaraditya303 commented on Dec 30, 2024

    @kumaraditya303
    ContributorAuthor

    do you have a use-case for cancelling a call_soon_threadsafe from another thread?

    It's not about use case, this API has been published and this should work in both free-threading and normal builds.

  4. graingert commented on Dec 30, 2024

    @graingert
    Contributor

    Ah I see, I'll make a PR that makes this threadsafe without a mutex

  5. kumaraditya303 commented on Dec 30, 2024

    @kumaraditya303
    ContributorAuthor

    It also looks like you don't need a mutex, you could just combine the "self._cancelled" and "self._callback" attributes into one attribute where a None callback causes the handle to be considered cancelled.

    If you combine the two, won't there be race when event loop would check if callback is None and another thread setting it to None?

    What about the following sequence without mutex:

    • Event loop checks the handle if cancelled handle._callback == None is False
    • The thread calling loop.call_soon_threadsafe cancels it handle._callback == None is True
    • Event loop tries to run the callback but is None and thus crashes
  6. added a commit that references this issue on Dec 30, 2024
  7. graingert commented on Dec 30, 2024

    @graingert
    Contributor
    • Event loop checks the handle if cancelled handle._callback == None is False

      • The thread calling loop.call_soon_threadsafe cancels it handle._callback == None is True

      • Event loop tries to run the callback but is None and thus crashes

    this won't happen because the eventloop calls handle.run() so we can check cancellation here.
    If there's a race condition some extra debug code (the 100ms debug callback timer) but won't crash

  8. kumaraditya303 commented on Dec 30, 2024

    @kumaraditya303
    ContributorAuthor

    I saw your PR, it adds quite a lot of code for the common case when thread safety is not required, specifically you are adding unnecessary comparison and tuple packing/unpacking to the hot path of handle._run.

    FWIW the handle API is very performance sensitive as all the callbacks are called thorough it, I worry this could cause a significant performance loss for no real benefit.

  9. graingert commented on Dec 30, 2024

    @graingert
    Contributor

    You'll need the mutex in the hot path anyway, although only for call_soon_threadsafe it will change the code quite drastically anyway. Probably it's worth benchmarking the approaches

    I can replace the tuple unpacking for index lookups instead, and see if that's faster

  10. asvetlov commented on Dec 30, 2024

    @asvetlov
    Contributor

    I honestly prefer the @kumaraditya303 idea:

    1. Leave Handle as is
    2. Modify call_soon_threadsafe to return ThreadSafeHandle which is protected by a mutex explicitly
  11. graingert commented on Dec 31, 2024

    @graingert
    Contributor

    how about we:

    1. Leave Handle as is
    2. modify call_soon_threadsafe to return ThreadSafeHandle, which uses atomic python operations for thread safety.

    I don't think it's possible to use a mutex with call_soon_threadsafe, otherwise you risk cancel() not working when it's re-entrant eg due to calling handle.cancel() in a signal handler

  12. asvetlov commented on Dec 31, 2024

    @asvetlov
    Contributor

    otherwise you risk cancel() not working when it's re-entrant eg due to calling handle.cancel() in a signal handler

    Doesn't threading.RLock exist for reentrant calls? Did I miss something?

  13. graingert commented on Dec 31, 2024

    @graingert
    Contributor

    threading.RLock will allow the code to be re-entrant, but then the critical section can be occupied by two contexts

  14. kumaraditya303 commented on Dec 31, 2024

    @kumaraditya303
    ContributorAuthor

    I don't think it's possible to use a mutex with call_soon_threadsafe, otherwise you risk cancel() not working when it's re-entrant eg due to calling handle.cancel() in a signal handler

    In general it's not safe to call any asyncio API directly from signal handlers, you should call your code using loop.call_soon_threadsafe from signal handlers. For example see #122938

  15. graingert commented on Dec 31, 2024

    @graingert
    Contributor

    yes, but we're talking about call_soon_threadsafe

  16. kumaraditya303 commented on Dec 31, 2024

    @kumaraditya303
    ContributorAuthor

    yes, but we're talking about call_soon_threadsafe

    If you want to cancel a handle, why not call loop.call_soon_threadsafe(handle.cancel) from signal handler?

  17. added a commit that references this issue on Jan 6, 2025
  18. kumaraditya303 commented on Jan 6, 2025

    @kumaraditya303
    ContributorAuthor

    Added thread safe handle in #128369 with tests, I'll reopen if there are more thread safety issues I find with this but this is done for now.

  19. moved this from Todo to Done in asyncioon Jan 6, 2025
  20. added a commit that references this issue on Jan 8, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    • Status
      Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions