Why does python's async execution model so strictly separate synchronous from asynchronous functions?

I'm very new to async programming and I'm struggling to understand the design decisions behind python's implementation of async. What confuses me most is why asynchronous functions (defined with async def) return a coroutine that needs to be awaited instead of just returning the result of that coroutine. In the world of synchronous programming, you could rely on the fact that calling a function would return its result. But now that async functions are a thing, you always have to know whether a function is synchronous or asynchronous because calling an asynchronous function does something fundamentally different than calling a synchronous function. I don't see a reason why this strict separation of sync/async functions is necessary. Why can't calling a function mean "I want to get the result of this function"? Why can't we write code that doesn't care whether the functions it's calling are synchronous or asynchronous?

​

If I were to design an async execution model, I'd change two things:

  1. Get rid of the await keyword and implicitly await all asynchronous functions when they're called. In order to run multiple asynchronous functions simultaneously, we would use futures.
  2. Get rid of async def, async with and async for.

Here's an example of how this would work:

def wait_and_print():
    time.sleep(1)
    print("success")

def i_dont_care_about_async():
    # both of these should wait 1 second and then print "success"
    wait_and_print()
    wait_and_print()

def i_want_to_be_async():
    # this should wait 1 second and then print "success" twice
    future1 = Future(wait_and_print)
    future2 = Future(wait_and_print)
    asyncio.run(asyncio.gather(future1, future2))

As you can see, I defined a normal (synchronous, if you will) function wait_and_return. Calling this function normally makes it block for 1 second before returning a value. But if we want to, we can use a Future to run multiple instances of this function simultaneously. This isn't possible in python - in python I would have to implement an asynchronous copy of wait_and_return with async def and asyncio.sleep.

Of course, all of this is only possible if an implicit event loop is running in the background and time.sleep yields execution to that event loop. Surely this would be much more convenient than python's current async model? Is there any reason why async wasn't implemented like this? Is there some kind of problem with this model, or maybe even something that makes this impossible to implement in practice?

54 comments · 81% upvoted · other discussions

comments

jnwatson

I just ported a large project (50K LOC) completely to async. I changed thousands of lines of code.

First, I love the async model and everything about it.

However, I really shouldn't have had to do all that work. It isn't just adding a bunch of async and await everywhere. It is converting every for loop into an async for loop. It is converting every context manager and list comprehension into an async one. It is converting yield from back into regular loops. It is rearchitecting things if you have a generator expression. Essentially there are two syntaxes now, one for async functions and one for non-async, and they haven't transitioned all the syntax from non-async to async yet.

Essentially, when you mark a function as async, you're telling the interpreter that someone, maybe 7 layers deep, might wait on a OS object. You could have 99% of your code be non-async, but because of that one dude (probably waiting on a socket), thousands of lines of code have to change. This viral nature of async is bad. I don't mind marking the functions, I do mind converting code from one syntax to another simply because it makes the interpreter slightly easier to implement.

If an async function calls another async function, the await should be implicit. There should be some other keyword to *NOT* await on a called coroutine. In this way, you wouldn't have to rewrite every function.

dunkler_wanderer

Relevant article/rant: What Color is Your Function?

Zomunieo

The alternative asyncio package trio seems to avoid the viral infection problem. It adds a method that allows ordinary function to call an async function, so instead of changing ever layer of an existing code base you at most have to change the point that does I/O.

devxpy

method that allows ordinary function to call an async function

Please more background on how this is implemented?

Zomunieo

As I understand it trio provides its own implementation of an asyncio event loop which is designed in a way that makes this possible. The case it solves an async function that calls into sync code that then needs to do something asynchronous. Say you pass a callback function into a library and the callback is async. So trio could walk up the call stack and find some async context to figure out which task needs to be suspended.

devxpy

Wait, are you talking about trio.run()? Because asyncio.run() also exists now. What's really the difference between the two?

Another serious question - Why is this so useful? I have little async experience (mostly just asyncio), but isn't the primary concern with async the other way round?

As in you accidentally call a "sync" function from an "async" code, and then that "sync" code ends up hogging your event loop. Or of course, the unfortunate case where an "async" version doesn't exist for the task you're trying to achieve.

I realize that trio is relatively easier to use and debug than asyncio, but don't see any technical improvements it brings, over the other.

Could you please provide some more background?

Zomunieo

You've got more detailed questions than I have answers for. My contact with trio is pretty superficial.

devxpy

Goddamnit. Where do I find people who have these answers!

Zomunieo

You could try the python-trio gitter.im chatroom or asking the author.

devxpy

Thanks, but I meant it like a angry statement referring to the plethora of un-answered questions in computer science (for me atleast) xD

caleb

This doesn't sound right. You should only need async iterators and context managers if the special methods invoked by them are required to be awaitables. For example, it's fine for the "body" of a sync context manager to use await.

Majorbling

Have you read PEP 492? It goes over some of the design motivations and things, and has a quick explanation of their reasoning for including an async keyword

https://www.python.org/dev/peps/pep-0492/#id86

Personally I quite like the way it is with the keywords, it's more akin to 'lifting' functions into a sort of async form like one would do in Haskell

Rawing7 [OP]

The only reason I could find there is

async/await is not a new concept in programming languages. [...] This is a huge benefit, as some users already have experience with async/await, and because it makes working with many languages in one project easier

Which seems like a very weak reason considering that we could've had much more convenient asynchrony and avoided introducing 2 new keywords as well as asynchronous copies of many, many dundermethods.

Majorbling

What's the difference between Future(x) and await x in your mind exactly?

The async keyword is less necessary as it can be automatically inferred, but see 'Importance of "async" keyword' in PEP 492 for their rationale for adding that

Or maybe I'm misunderstanding what you're asking?

Rawing7 [OP]

I understand their reasoning for adding an async keyword - it makes sense in a world where synchronous functions are completely different from asynchronous functions. As long as await exists, it makes sense for async to exist as well. The async model I'm proposing would get rid of both.

Regarding Future vs await: A Future represents a result that doesn't exist yet, but will be available in the future after it's been computed. And await is the thing that waits for that result to become available. In a programming language where calling a function automatically blocks until its result is available, it would make sense to use Futures to run multiple functions in parallel.

Majorbling

These are entirely different models of asynchonous computation in major ways then

The intent, as far as I can tell from PEP 492, is to add first class syntax for the coroutine style of asynchronous computation specifically and not something more akin to Promises/Futures

Promises and futures are actually already in the Python standard since 3.4 or so (PEP 3156) via the asyncio module, is that more what you're looking for?

One can actually await a Future or a Task in the same way as the standard coroutine, so which you choose depends on what type of async programming you're intending to use

Rawing7 [OP]

PEP 3156 is probably more relevant than PEP 492, thanks. (Because I want to know why the concept of awaiting is explicit, not why we got an await keyword as a replacement for yield from.) It's an awfully long read though...

anacrolix

I followed the debate with great interest around 2010 to 2012, played with yield from, and had my own version of gevent. I switched to Go due to constant issues in Python with trying to use implicitly asynchronous wrappers without language support. Now the async keyword stuff is everywhere and it just looks like function colouring hell. It's like the Python 3 split again but now at the library level.

zergling_Lester

Get rid of the await keyword and implicitly await all asynchronous functions when they're called. In order to run multiple asynchronous functions simultaneously, we would use futures.

You see, the async-await model (which I don't like very much) has a bunch of almost deliberately conflated selling points:

  1. It's more efficient than using OS threads for massively concurrent IO bound tasks.

  2. It provides a synchronization model that makes it almost entirely unnecessary to use locks.

  3. And it grows naturally from earlier asyncio implementations across several languages that didn't need any language support at all.

What you propose destroys the second property. Not that it's a very good property, it directly conflicts the first property because it doesn't scale to multicore (not that it's important for Python because GIL), it doesn't scale in software development sense because you have these two types of functions with "async" infecting everything up the call stack.

But it's necessary because then you know that any code between two awaits is an implicit critical section and you don't have to take a lock whenever you mutate shared state, and we don't have anything better in Python anyways, and so the people who use async-await come to rely on that stuff.

If you don't want that property then you might want to take a look at gevent for example. It doesn't do any async/await stuff in the first place, instead replacing most usual blocking operations with versions that return to and back from the event loop invisibly for the caller.

Rawing7 [OP]

Thanks for the response, but I don't understand why making the await implicit makes locking necessary. Making await implicit doesn't mean that functions could be halted at any time, so those "implicit critical sections" between two awaits would still exist. As far as I can tell, making await implicit shouldn't have an effect on the order of execution of your code.

zergling_Lester

Making await implicit doesn't mean that functions could be halted at any time

It means that a function can be preempted whenever you call another function (including overloaded operators), so pretty much "at any time".

if debtor.amount >= amount:
   log('doing transaction') # could be an implicit `await`
   debtor.amount -= amount
   creditor.amount += amount
Rawing7 [OP]

Well, it's true that your function may be halted when you call another function - but that's also true with explicit awaits. The fact of the matter is that you have to know whether the function you're calling is synchronous or asynchronous. That is true regardless of whether the await is explicit or implicit. The advantage of the implicit await is that you don't have to know as long as there are no race conditions in your code. With explicit awaits you don't have the option of not giving a shit.

zergling_Lester

The fact of the matter is that you have to know whether the function you're calling is synchronous or asynchronous. That is true regardless of whether the await is explicit or implicit.

If await is explicit then you get a more or less obvious error (compile time even in better languages) instead of hard to track race conditions.

With explicit awaits you don't have the option of not giving a shit.

It's impossible to reliably communicate that you give a shit to someone reading and modifying your code, nor to someone maintaining the functions you call so they don't make them async.

As I said, check out gevent, it does pretty much what you want and with a benefit of not performing the anal touring maneuver of implementing implicit asyncio on top of explicit. Though God may have mercy on your soul if you decide that you don't have to use synchronization primitives because you always know which functions might do IO.

Rawing7 [OP]

I'm not so sure that a missing await will always cause an obvious error in python, but more importantly, race conditions aren't a byproduct of a missing await. Adding that missing await won't make the race conditions disappear. But it's probably true that explicit awaits would make the race condition easier to track down.

I do think it's possible to clearly communicate your giving a shit about race conditions to other people though. A with account_balance_lock: should be enough to make that abundantly obvious.

I'll take a look at gevent though, thanks.

zergling_Lester

I do think it's possible to clearly communicate your giving a shit about race conditions to other people though. A with account_balance_lock: should be enough to make that abundantly obvious.

Yes, and having mandatory awaits is an alternative to that, that, believe it or not, is actually much nicer in a range of application sizes.

Because locks suck, the relationship between the data and the lock(s) that protect it are not explicit in the code, while with awaits you know that all data accesses between awaits are absolutely safe.

Note that I don't like async/await very much and prefer to use message queues/channels, along with enlightened users of gevent.

__deerlord__

making await implicit

Someone hasnt read PEP20

Rawing7 [OP]

A lot of things are implicit in python. Like variable declarations. Referring to PEP20 isn't an argument, and blindly making everything explicit would be stupid.

zardeh

Variable declarations aren't implicit, they just don't exist.

It's like saying that types declarations are implicit. No, they just aren't there.

Rawing7 [OP]

That's debatable. In other languages, variable declarations usually contain more information about the variable - like the type, whether it's static, stuff like that. In python, the only thing a variable declaration does is create key/value pair in the corresponding scope's dictionary (globals/locals/__dict__), which means that the only information python has about that variable is its name and value. So the question is whether creating a name in a dict counts as a variable declaration. I would say it does.

zardeh

Those are assignments, not declarations.

Rawing7 [OP]

So... what would have to be different for you to consider them declarations?

zardeh

The ability to declare a name without assigning it a value. Common examples of this are forward-declarations in C++, or a pattern like

int x;
x = 4;

In javascript as an example, var x keeps the value of x as undefined instead of null or something, but can be used to change the scope in which a variable is accessible.

Because of how scoping in python works, this just doesn't make sense. The closest thing python has is the nonlocal or global keyword, but the context in which you use those is different enough that it doesn't quite match.

zergling_Lester

Eh, that's a valid alternative approach. Just, like, an actually significantly different alternative approach, not an improvement on this one.

__deerlord__

Whoops replied to the wrong comment. But yea, implicit should be avoided where possible.

[deleted]

[deleted]

Rawing7 [OP]

How does separating synchronous functions from asynchronous functions help you not ruin your asynchronous program? If you're executing code that blocks, then everyone else has to wait. That's unavoidable. How is that a reason to make everything explicit and error prone?

[deleted]

[deleted]

Rawing7 [OP]

So how does knowing that a function is synchronous help you not ruin your asynchronous program? You still have to call the function, and that will block.

The reason why an explicit await keyword can cause errors is because you can forget to use it. Every single time you call a function, you have to know whether that function is synchronous or asynchronous. If calling an asynchronous function would implicitly await it and return its result, that would be much more convenient and less likely to cause stupid bugs. And the same goes for async def, async with and async for.

w2qw

Python is a dynamic language so that would have to be an overhead to every function call. Additionally you'd then need some syntax for calling an asynchronous function without completely evaluating it.

If you want something that's just transparent why not try gevent?

Rawing7 [OP]

I'll take a look at gevent, but I was mostly wondering if there was something wrong with my understanding of async and why it was implemented this way.

chazzeromus

The reason why an explicit await keyword can cause errors is because you can forget to use it.

I love the async model in python and take it as one of the reasons to be better explicitly, but I have this gripe too where I wish python told me that I'm creating a co-routine object without storing its reference somewhere. Mypy doesn't have anything to detect this either (afaik), and it's a simple check that could help a lot.

In other languages like in javascript, async functions preempt as soon as they're called, but in python they have to be somehow preempted through an async syntax or placed somewhere to start at a later time. And it's bitten me a few times in python where I call an async function and forget to do something with the coroutine object.

ofedorov

In multithreaded programs the code executed in a thread is periodically implicitly interrupted and the execution is turned to another thread.

Instead, await keyword allows interrupting the execution explicitly, within single thread. The case there is that in async program a long-running blocking code prevents handling any events by the event loop, so that the entire program is halted.

Consider an example: a user sends a request to our program, but right now we're so busy that can receive it. So the user just gets "The server did not respond" error or so. Instead we should periodically interrupt our heavy calculations to check if here is any incoming request.

That’s why in async program any long-running blocking code should:

a) be moved to a separate thread or a process, to not block current thread; b) be split to relatively small steps with explicit await between them, to return the control to the event loop.

jnwatson

Just because a function is synchronous doesn't mean it blocks. Also it is perfectly legal to call `time.sleep(10000)` in an async function. In other words, its blocking behavior is orthogonal to whether a function is marked `async`.

Adding required syntax that doesn't actually enforce the behavior you're looking for can be better implemented with code comments.

13steinj

The implicit event loop isn't what the issue OP complains about, and explicit event loops are a bane in the few languages that implement them, including Python.

[deleted]

[deleted]

13steinj

I read the first paragraph, but then you added the explicit event loop nonsense out of place making zero sense.

Explicit event loops are extremely complicated to work with. Whereas in lamguages like C#, JS, the event loop / microtask queue is implicit, you can call asynchronous functions from the current parent thread and interweave them with synchronous functions right off the bat.

It just works, batteries go in simply, rather than you having to wire up capacitors in series. Now in C# if you really want to you can dig in to the internals if you choose to use capacitors in series rather than double a batteries, but that's a very rare need to do so.

Even asyncio admitted it's mistake and implemented a run function to simplify needing to set up the event loop, but it is extremely inefficient because it closes and reopens a loop each time it is called.

Furthermore the default event loop in C#/JS is smart-- it exists on a separate thread, allowing synchronous code to be asynchronous from asynchronous code, which fundamentally makes sense-- synchronous code is a series of instructions that depend on each other, and may at some point depend on asynchronous code running in the background, but not directly dependent on it. While you can do this in Python it requires a lot of set up.

I'm in the process of writing a thin wrapper around asyncio that solves these two issues, but it's dependent on a Task primitive which doesn't exist in asyncio.Task (which by the way asyncio tasks aren't actual tasks as one thinks about them in other languages, which is just strange), so it's taking longer than I hoped to get up and running due to work being a priority.


Asyncio was meant to be a high level api for asyncore and asynchat. But it failed miserably in that regard. Instead it added a whole bunch of async things, but is just incrementally higher than asyncore/asynchat

zardeh

What? Everything I've read said that asyncio is a low level API for developers to implement modules like trio or curio on top of.

Rereading the docs, it provides both, but the stuff you mention (event loops mostly) are explicitly referred to as low level.

13steinj

Originally, and I mean truly originally, asyncio was introduced as goimg to be higher level async operations rather than that of asyncore and asynchat.

Asynchronous functions in and of themselves in many languages are considered a high level concept.

But then at some point the design decisions of asyncio forced event loops to be explicit and thus the entire idea of asynchronous functions and even more on top of that is all low level.

Trio does not run on top of asyncio at all-- that's how bad it's gotten. An asyncio bridge is provided in a separate library altogether. Curio has a bridge to the asyncio layer but otherwise has no problem running completely without it.

That's why asyncio is seen as a bit of a joke in the majority of Python community and people use trio instead.

graingert

JS still needs async/await in exactly the same way

pohmelie

Hm, you accept functions and generators, but do not coroutines and functions. That's weird.

Rawing7 [OP]

I don't follow. What does wanting to make await implicit have to do with generators?

pohmelie

Generators can't be called like functions, you need to evaluate/collect over, or even send data into generator on every step. Until 3.5 with async/await sugar implemented you could use generators in asyncio. They have absolutely identical nature. So if you use yield from, then nothing bad with await. If you don't like generators, then your article is bad formulated.

Rawing7 [OP]

The difference is that generator functions are just a convenient way to return an iterator. When you use a yield in a function, you always intend for that function to return an iterator. There's nothing else that function could return. But that's not the case for async functions. I don't want my function to return a coroutine. I want my function to return a result. 100% of the people who call my function want to execute that function. But less than 100% of those people are interested in getting a coroutine. Getting the result would be way more useful than getting a coroutine. So why can't calling a function just do the obvious thing and give you the result?

pohmelie

As I said above, sometimes you send data to generator on each step with send. More to say, strictly, generator is not iterator. And if you want final generator result you can't just call it like function, you must evaluate it. If sync and async functions will have no difference in calling style, then this will be a hell for developer to find blocking code. Why you accept yield from, but decline await? They are exactly equal.

[deleted]

I'd say it's a combination of lack of familiarity with good implementations on the part of the core developers (people who decide what goes into language, and they aren't very bright, especially when it comes to knowing stuff about other languages). A fashion (that's been already mentioned here). And a fear of change (when you don't understand how your code works, you are afraid of changing it; Python interpreter is a 90% duct tape and 10% code that makes sense, trying to induce a major change into it is a fool's errand).