Decorator to facilitate sync and async calls to one function

When writing function libraries for my colleagues, I often need to provide both async and sync methods for different use cases.

I usually implement the functionality in an async method myfunction_async() and add a sync wrapper myfunction() that calls the async function using asyncio.run().
This means creating a lot of boilerplate code and copying all the docstrings.

Now I came up with a decorator that can be added to an async function and makes it work for both use cases:

import asyncio
import functools
from collections.abc import Coroutine
from typing import Callable, TypeVar, Union, Awaitable

T = TypeVar("T")


def async_or_sync(func: Callable[..., Coroutine[None, None, T]]) -> Callable[..., Union[T, Awaitable[T]]]:
    """Decorator to enable calling the same function synchronously or asynchronously.

    It determines the context in which the function is called (sync or async) and runs it appropriately.
    In an async context, the wrapped function is awaited, whereas in a sync context, it is executed using
    `asyncio.run`.

    :param func: The function to be wrapped. Must be async (`async def myfunction(...)`).
    :return: The return value of the wrapped function, modified to handle either synchronous or asynchronous execution.
    """

    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        if asyncio.iscoroutinefunction(func):
            try:
                # Run asynchronously if there is an active event loop
                asyncio.get_running_loop()
                return func(*args, **kwargs)
            except RuntimeError:
                # Run synchronously
                return asyncio.run(func(*args, **kwargs))
        else:
            raise TypeError("The decorated function must be asynchronous.")

    return wrapper


if __name__ == '__main__':
    # Simple test case to show the intended use

    @async_or_sync
    async def my_test_function(name: str):
        await asyncio.sleep(1)
        print(f"Hello, {name}")
        return name


    async def async_test_caller():
        x = await my_test_function("Alice")


    def sync_test_caller():
        x = my_test_function("Bob")


    asyncio.run(async_test_caller())
    sync_test_caller()

This looks like a great idea to me - but is it really?

What drawbacks of this approach did I miss?

Are there any issues with the implementation, that should be improved?

I’d love to get feedback on this from people with a deeper understanding of python decorators and asyncio than myself!

1 Like

One of the main issues with attempting to dynamically switch between the two is that asyncio.run is really slow compared to a regular synchronous function call:

$ python3 -m timeit -s "def f(): pass" "f()"
5000000 loops, best of 5: 41 nsec per loop
$ python3 -m timeit -s "from asyncio import run" -s "async def cr(): pass" "run(cr())"
5000 loops, best of 5: 69.4 usec per loop

Note the difference in units: nanoseconds for a regular synchronous function call, microseconds to start an event loop, run a coroutine, and then shut the event loop down again.

In the other direction, the overhead of using asyncio.to_thread to run synchronous APIs in an async context is lower than that of using asyncio.run in a synchronous context (since the event loop sets up and manages a thread executor that lives as long as the event loop does), but it’s still not negligible (one order of magnitude for a do-nothing function rather than the 3 orders of magnitude we saw with asyncio.run):

$ python3 -m timeit -s "from asyncio import run, to_thread" -s "def f(): pass" "run(to_thread(f))"
500 loops, best of 5: 650 usec per loop

Thus library authors that want to offer native support for both sync and async usage often gritting their teeth and duplicating the API surfaces in order to offer the best possible performance for both usage models.

1 Like

I came at this from the other direction: async code needs to have async
calls all the way down for potentially blocking functions, otherwise it
can stall the event loop.

I’ve got a little package cs.naysync
which is supposed to help shim sync and async things together. There’s a
link to the code from the PyPI page.

In particular it has some decorators @afunc and @agen for decorating
synchronous existing synchronous functions or generators, and some other
features. It does do some inspection and broadly accepts sync or async
arguments, which is handy in some code. (I apologise for the appalling
function signatures in the decorator docs, a doc generation shortcoming
of my own making.)

Like yours, it isn’t magic and will do some heavy weight things (since
pretty much the only thing you can do with a sync function is wrap it in
asyncio.to_thread(), though several functions have an optional
fast=False parameter whose truthiness attests that some argument won’t
block and should just be called directly).

From the module intro:

One of the difficulties in adapting non-async code for use in
an async world is that anything asynchronous needs to be turtles
all the way down: a single blocking synchronous call anywhere
in the call stack blocks the async event loop.

This module presently provides:

  • @afunc: a decorator to make a synchronous function asynchronous
  • @agen: a decorator to make a synchronous generator asynchronous
  • amap(func,iterable): asynchronous mapping of func over an iterable
  • aqget(q): asynchronous function to get an item from a queue.Queue or similar
  • aqiter(q): asynchronous generator to yield items from a queue.Queue or similar
  • async_iter(iterable): return an asynchronous iterator of an iterable
  • IterableAsyncQueue: an iterable flavour of asyncio.Queue with no get methods
  • AsyncPipeLine: a pipeline of functions connected together with IterableAsyncQueues

@ncoghlan Thank you very much for this detailed insight! That’s exactly the kind of information I was hoping for :slight_smile:

Is my assumption correct, that we are only talking about an overhead for the call itself and the
code inside the function will run at (approximately) the same speed?

This would mean that my decorator adds around 100 µs to each call (very coarse estimate - it depends on the actual hardware anyway).

If this is a problem or not depends on the kind of function that is wrapped. In my case I am using it for calls to a REST API that is not very performant itself. We are measuring those calls in hundreds of milliseconds or even seconds. Adding less than a millisecond to this is of no concern to us.

This might be totally different for other use cases though.

I’m happy to hear that I am not the only one who duplicated a lot of methods - even if I wasn’t thinking about performance at the time :wink: .

1 Like

This package looks way more complex than my little decorator :wink: .

Looking at the code, it seems to cover some interesting aspects, which I haven’t thought about yet. Definitely something to revisit if I ever end up in a corner where I need “more interesting concepts”.

For the time being, I am happy to use my small solution. But I have no idea if this will still be enough tomorrow…

Yeah, if the underlying operation is measured in milliseconds (or more) then the tens-of-microseconds of overhead the sync branch adds won’t be a relevant concern.

1 Like

I belatedly realised that this comparison isn’t necessarily right, as the absolute numbers are more significant here than the ratios. The ratios will change as the called function does more meaningful work (and hence takes longer to run), but the absolute overhead should remain relatively consistent.

However, I’m also not actually sure the to_thread example is correctly using the same event loop across all the iterations. The fact it is taking hundreds of microseconds per iteration seems way too slow for merely dispatching a function to an existing thread executor, but plausible for setting one up and tearing it down again.