Almost a year ago we had a push at Element to convert the remaining instances of Twisted’s inlineCallbacks to use native async/await syntax from Python [1]. Eventually this work got covered by issue #7988 (which is the original basis for this blogpost).
Note that Twisted itself gained some support for async functions in 16.4.
inlineCallbacks are very similar to async/await, they use a generator internally to wait for a task to complete and (modern [2]) versions of Twisted let you directly return values. There are some real benefits to switching though:
In fact the Twisted documentation (as of v21.2.0) even suggest some of the above:
Unless your code supports Python 2 (and therefore needs compatibility with older versions of Twisted), writing coroutines with the functionality described in “Coroutines with async/await” is preferred over inlineCallbacks. Coroutines are supported by dedicated Python syntax, are compatible with asyncio, and provide higher performance.
As an example, consider this function from the Twisted documentation:
from twisted.internet.defer import Deferred, inlineCallbacks
@inlineCallbacks
def makeRequest(method: str, url: str):
# ... do some HTTP stuff ...
return response
@inlineCallbacks
def getUsers():
responseBody = yield makeRequest("GET", "/users")
return json.loads(responseBody)
def main(reactor):
return getUsers()
This could be rewritten to a bit more of modern:
async def makeRequest(method: str, url: str) -> str:
# ... do some HTTP stuff ...
return response
async def getUsers() -> dict:
responseBody = await makeRequest("GET", "/users")
return json.loads(responseBody)
def main(reactor):
return defer.ensureDeferred(getUsers())
Not too big of a difference, but definitely a bit nicer. In particular, note:
The result of calling an async function is an Awaitable, the result of calling an inlineCallbacks function is a Deferred. async functions use await internally to wait for another Awaitable, inlineCallbacks use yield internally to wait for another Deferred.
This results in the following rules:
To convert a single function this turns out to be pretty simple:
What | Twised | asyncio |
---|---|---|
Function definition | @defer.inlineCallbacks decorator | async |
Wait for result | yield | await |
The difficult comes when you have a large codebase that you want to convert from defer.inlineCallbacks to async/await. Below is how I approached this for the Synapse code:
Since you can await a Deferred the easiest way to do this is to start at the outer layers and work inward. By doing this you end up with async functions which call into code which return a Deferred, but this is fine.
For Synapse we converted things via:
In order to avoid doing an entire layer at once it is ideal to start with the modules which are called into the least (and preferably only via a higher layer). If there are other callers which have not yet been converted, the call-site is modified to wrap the returned Awaitable with defer.ensureDeferred. Additionally, this is used whenever a Twisted API expects a Deferred.
The REST layer in Synapse is built on twisted.web and needed some extra magic, see _AsyncResource and sub-classes, in particular it:
The Synapse code had many places which were undecorated functions which called return a Deferred via calling something else. While doing this conversion we updated these functions to be async and then internally await the called function, for clarity. (Originally this was done for performance, but the overhead should be minimal when using async/await.)
This also involved updating the tests to match the type as well (i.e. if a function was made async and we mock that function somewhere, the mock should also be async).
While doing this we also fixed up some of the type hints of return values since mypy will actually check them once you remove the defer.inlineCallbacks decorator.
As part of this I threw together an “Are We Async Yet?” site. It is pretty basic, but tracks the amount of code using defer.inlineCallbacks vs. async. As a side-effect you can see how the code has grown over time (with a few instances of major shrinking). [4]
And last, but not least, I definitely did not convert all of Synapse myself! It was done incrementally by the entire team over years! My coworkers mostly laid the groundwork and I did much of the mechanical changes. And…we’re still not quite done, although the remaining places heavily interact with Twisted APIs or manually generate a Deferred and use addCallback (so they’re not straightforward to convert).
[1] | Added in Python 3.5 via PEP 492. |
[2] | Newer than version 15.0 according to the Twisted documentation. |
[3] | The documentation for async/await suggests using Deferred.fromCoroutine instead, but that is new in Twisted v21.2.0. |
[4] | You can find the code on GitHub. |