Repository navigation
Proposal: replace asyncio with anyio #166
Description
Yesterday I added the pull request #165 to start a discussion on supporting the trio ecosystem, preferably using anyio. @Cito invited me to create an issue as a place for discussing the implications of such a step.
I hope to get some feedback from you wether you think supporting trio in graphql-core is a good idea. My reasoning and approach for the pull request is outlined below
The basics
What is structured concurrency and trio?
This is best answered by the trio project, I can recommend reading their documentation, especially their design principles. There are also a few blog posts that shed light on the reasoning behind trio, e.g. structured concurrency and cancellation.
Basically, trio implements an event loop for asynchronous code execution, just like the asyncio standard library. However, there are some important differences. The following two have probably the most impact:
1.) You cannot start async tasks whenever you want. All tasks have a parent, if you want to start multiple coroutines, you need a Nursery. The nursery guarantees that all tasks are done when exiting its scope. This makes it much easier to reason about async code: If you await an async function, there will be no background tasks running when the function returns. Only exception: passing in a nursery explicitely to start the tasks. Then the tasks may run until the end of the nursery scope.
2.) Level-based cancellation. Cancellation is done via scopes. Once a scope is cancelled, all its children scopes are cancelled as well. It also stays cancelled: After cancellation, any checkpoint (see trio docs for details, basically any call into trio like trio.sleep) raises Cancelled. This makes it easier to get cancellation right. Asyncio only ever sends one CancelledError. It is often caught with good intentions but not raised again in some circumstances.
These two changes alone make it much easier to write correct asynchronous code.
What is anyio?
The anyio project Allows to use the same structured concurrency and cancellation behavior as trio (trio's Nursery is a TaskGroup, etc.). However, it can run on two different backends: trio and standard asyncio.
This makes anyio ideal for two use cases:
1.) Iteratively replacing asyncio code with anyio code. At any point the code should still work, even if only partially replaced.
2.) Writing libraries that support trio and asyncio
Why support trio in graphql-core?
If you ever had to create or work in a large scale asynchronous Python application, you will probably have spent hours fixing tasks that should have closed but are instead still blocking some resource or preventing the event loop shutdown. Mostly in your own code, sometimes in third-party libraries. The trio (and anyio) way of tackling asynchronous code can help tremendously when using asynchronous code at a larger scale.
The graphql-core library is well-tested and takes great care to handle asynchronous tasks and cancellation correctly. Therefore, this change will have no benefit for the graphql-core codebase, it will not make it simpler or easier to understand. However, it allows users to make the choice between asyncio and trio, opening up to the growing trio ecosystem. This could also potentially increase usage of the graphql-core library. Both are worthy goals in my opinion.
How to do it?
If I have you convinced that supporting trio is not a bad idea in itself, the question comes down to: How to do that in graphql-core without too many downsides?
Why Anyio?
If you want to avoid additional libraries, you can potentially support trio by adding a lot of if-else blocks to your code, using either an asyncio or a trio implementation. This has a few downsides:
- It is difficult to ensure that both implementations behave the same when changing the event loop implementation.
- Code duplication in these blocks
- You are probably going to reinvent the anyio library, at least partially.
Another option would be trio-asyncio, which runs two event loops and contains tools to stitch it together. However, its documentation already states it is intended for applications that need to use libraries from trio and asyncio. It seems more like a stopgap solution if you really need to combine two libraries that have conflicting requirements.
That is why I used the anyio library in the pull request. It makes it easier to write code that works with asyncio and trio. Anyio is also a promise to users of the graphql-core library: The library is built using structured concurrency, you can be sure that tasks are cleaned up correctly. Of course there is one major downside: graphql-core now requires the anyio library.
@Cito already expressed concern about adding an additional library:
- Support may be dropped in the future.
- It might introduce new problems
Regarding support I was sceptical to include anyio in my own code as well. However, looking at my dependency tree I found that I was inadvertently using it: Starlette can run on asyncio and trio, using anyio (Kludex/starlette#1157). As Starlette is rather widespread I believe there is sufficient interest to keep anyio going. Maybe you know additional libraries that rely on anyio? If enough libraries rely on it, it is unlikely to simply disappear.
Worst case scenario: anyio support is dropped and their is no replacement. In this case you could decide to drop trio support again, at the cost of angry users, who want to have a graphql server with their trio event loop.
Introducing new bugs when using anyio is of course possible. As the anyio API is very close to trio, I expect it to work rather well with trio. The trio backend in anyio is often only a thin wrapper around the corresponding trio object (see https://github.com/agronholm/anyio/blob/master/src/anyio/_backends/_trio.py).
However, emulating the trio behavior in asyncio code is more difficult. Especially since the cancellation works differently. There might very well cases when things do not work as expected when including it here.
The only way to get some ease of mind would be sufficient tests. While my pull request passes the graphql-core tests, I did not specifically test for incompatibilities. You can help out here. If you are using graphql-core in your project and would like to see trio support: take my pull request and test it. See if it introduces any problems with existing code. If this issue gathers enough interest I might be motivated to write additional tests explicitely searching for changed behavior and incompatibilities.
Performance considerations
Using anyio instead of asyncio can potentially cost performance, as it is an additional wrapper on top of asyncio (or trio). The question are: How much? Is it worth it?
The Starlette pull request (Kludex/starlette#1157) found a 4.5% drop in their performance score, but this has hardly any meaning for a different library.
graphql-core itself has only one async benchmark. I included benchmarks in the pull request, I could not see a performance impact.
But standard deviation was rather large, and a single benchmark is hardly enough here. The best way forward would be additional benchmarks. Again, if this issue gathers enough interest I might be motivated to put some more work in.
The fate of SimplePubSub?
Edit: SimplePubSub is not part of the official API, we can just change it keep the tests similar to their GraphQL.js counterpart, see comment
This discussion is only relevant if we actually want to go through with it and adopt anyio. But since it cost most of the time when porting graphql-core to anyio, I will include it here.
Basically, I did not port SimplePubSub to anyio. Its API is not compatible. Instead of changing its API I added a warning about its reliance on asyncio and replaced its usage in the test code with a completely new implementation called MemoryObjectBroadcastStream. Its interface is in line with the anyio streams and works well in the test suite.
It would also be possible to change the SimplePubSub API or come up with something completely different. SimplePubSub is not advertised much in the documentation and its description is rather sparse. I would be interested to hear if this class is actually used much outside of graphql-core and in which way.
Exception groups
Again, only a relevant discussion if anyio is actually used. Generally, trio supports a MultiError (ExceptionGroup in anyio), which does exactly as advertised: If a Nursery (TaskGroup) has multiple tasks ending with an exception it collects them and raises all of them. These are difficult to handle and would break the graphql-core API if propagated to the caller. Handling these cleanly in trio ultimately lead to the new except* language feature in Python 3.11 (see https://peps.python.org/pep-0654/). That is, handling them in Python versions <3.11 is annoying.
Asyncio uses a different approach in its gather function: Raise the first exception.
To avoid unnecessary surprises for users of the graphql-core library I opted for handling all exception groups internally in the pull request. Only one of the errors is actually raised to the caller. Of course, we might loose some information here, just as we do with asyncio.gather. Any opinions on this matter?
Python version
Using anyio would bump the minimum Python version from 3.6.0 to 3.6.2. @Cito plans to drop support for 3.6 anyway, so this does not seem much of an issue. But it would probably be a good idea to delay the inclusion of anyio until Python 3.6 support is dropped.
Activity
Cito commentedon Apr 3, 2022
Thanks a lot @valgarf for your effort.
I will need to dig into this, but two remarks already:
I would also be glad if other users of GraphQL-core could chime in and comment on this proposal.
valgarf commentedon Apr 4, 2022
@Cito Thanks for the feedback. That solves the SimplePubSub problem: We simply change its API and keep the tests close to GraphQL.js. I should also check my other test changes if they deviate too much from GraphQL.js. I added a few things to keep coverage at 100%.
agronholm commentedon Sep 16, 2025
@valgarf AnyIO author/maintainer here. Are you interested in pushing this effort further? I've been sending PRs to async projects to migrate them to structured concurrency, and my approach has been to send piecemeal PRs that retain compatibility as long as possible. I sent #242 as the first step in that process. But if you'd like to take the reins here, I don't want to duplicate your work unless I have to.
agronholm commentedon Sep 17, 2025
A major blocker here is that
ExecutionContextlaunches unsupervised background tasks. There needs to be anasync withsomewhere so that structured concurrency can be honored and background task exceptions can be propagated upstream.agronholm commentedon Sep 17, 2025
I think I have a potential solution that doesn't require the changing of the API. I can just check if there are tasks to be launched, and then force a coroutine to be returned from a method that is typed as
AwaitableOrValue[...]. This coroutine could then create a task group and run all the background tasks together.valgarf commentedon Nov 1, 2025
@agronholm In principle I am still interested. This effort was just abandoned since it did not gather much interest. It seems to have picked up somewhat. I like your approach, it does not require others to read through huge PRs. When I have time I will create the next PR for this, but it might be a few weeks.
I would really like your input on handling exception groups. The
gather_with_cancelfunction in the current codebase returns the first exception, this is different from the usual task group behavior. Any opinion on whether or not to keep it that way? Do you have experience from other projects on how disruptive the change toExceptionGroupis?About the unsupervised tasks: I think your proposal is close to what I did in the original PR. There were not many really unsupervised tasks. The
gather_with_cancelcan easily be fulfilled by a task group. The executor did (and still does) return coroutines, so using your approach should be straightforward.agronholm commentedon Nov 1, 2025
I'm already working on a PR of my own. I've only paused work on it as another project needed my attention. I will resume my work next week. IIRC I was just hunting down test failures at this point.
valgarf commentedon Nov 1, 2025
@agronholm Perfect, happy to hear it. I will leave you to it then. Thank you for spreading structured concurrency in the ecosystem, I will be very happy when it is widely adopted.
agronholm commentedon Dec 1, 2025
@Cito I took a break on this as another work project needed my attention, but I'm resuming this now. One thing that became obvious to me today is that the tests (
raises_on_exceptionandcancels_on_exception) won't work with AnyIO, especially the latter.cancels_on_exceptionwon't work because it relies on unstructured concurrency – that is, tasks spawned bygather()still running even after it raises an exception. This is diametrically opposed to the idea of SC and will not work, period. As forraises_on_exception, it seems to rely on timing –event.wait()not yielding control back to the event loop if the event has been set.I will need to refactor any code that relies on unstructured concurrency, as exceptions from orphaned tasks can easily go unnoticed and will mask bigger problems. Outside of tests, the only use of
gather()is inIncrementalPublisher._return_stream_iterators()(which, by the way, seems to drop any exceptions!). It doesn't look like it relies on these orphaned tasks, but confirmation would be nice. And I'd like to know if it's really OK for it to drop any exceptions on the floor.agronholm commentedon Dec 5, 2025
@Cito I've reached a point where I need your feedback.
experimental_execute_incrementally()builds an incremental response where orphaned tasks add incremental results to a list. In order to make this compatible with structured concurrency, there are a couple options that I can think of right now:async withwhen calling this function so that a task group can run in the background (a breaking change)IncrementalPublisher._subscribe()(waters down the "incremental" aspect)What would you prefer?
Cito commentedon Dec 5, 2025
@agronholm Sorry, I don't have a quick answer and the next 2 weeks I will be very busy. Made a reminder to look into that later when it's calmer again.
Cito commentedon Dec 28, 2025
@agronholm Sorry for the delay. I prefer the first solution. Would it be possible to make the taks group an optional param of the
experimental_execute_incrementallyfunction and fall back to non-SC without it?agronholm commentedon Dec 28, 2025
That would make it incompatible with Trio which forces structural concurrency. Besides, my brain is already stretched to the limit as it is, trying to make this work without too many disruptions.
Cito commentedon Dec 28, 2025
Yes, the idea is that the Trio would be only supported when a task group is passed. Or maybe you can add some construct/wrapper for users who don't care about Trio and task groups to make it easier for them.
Another thing I would like to mention: The code currently corresponds to the JS version 17.0.0-a5, but there was still some refactoring of the defer/stream related code in a6 and a7, which I will need to port in the next weeks (not sure exactly how long I will need). So if you need to touch that code, maybe it would be a good idea to wait until that is all ported?