As I understand it, asyncio makes it possible to interleave functionality and time. I.e. processing some code, which at times can't continue until "something else" happens. For example, sleeping for 1 second. Not only would it be nice to run some other code in the meantime, in embedded context this is where waiting for an external event happens, or putting the µC into low-power mode.
But there's a catch. With threads, we either get a very harsh "preemptive" environment (with race-conditions waiting at every corner), or a mass of hard-to-pre-allocate stacks if task switches only happen through specified yield calls. Enter coroutines, which are essentially non-preemptive threads, but with stack frames allocated on the heap (I don't know the implementation, I assume the compiler knows up front what size stack frame it will need), which is garbage-collected. So now, the call stack is no longer a linear memory vector, but a linked set of caller-callee segments. The key here is that they can "fork", i.e. segments can branch off in different directions, so that the completion of one call does not depend on everything it called itself. In other words: coroutines can be suspended, individually, and resumed later in a different order (note that I'm making all this up without knowing µPy's innards, the actual design may well be different).
That means there are lovely "awaitable" coroutines as lightweight tasks, and normal "callables" (functions, classes, objects) which can only run to completion once started. Unfortunately, never the twain shall - fully - meet ...
The problem: you can't use "await" in a callable function, i.e. one not defined as "async def", because the compiler either has to generate code for the usual (efficient) stack-based enter/exit program flow, or it generates code for heap-allocated coroutine functionality. And while a coroutine can call "normal: functions (eating up a bit of the linear stack again), they can only be suspended when those functions have returned, leaving the stack alone again.
Another way to look at this is: coroutines "live" on the garbage-collected heap. They can "borrow" the stack for performing as many calls as they want, but only once that stack use has stopped again (i.e. all those functions have returned), can a coroutine benefit from its key distinguishing feature: getting suspended, allowing another coroutine to resume.
The consequence is that once you're in normal "callable" code (i.e. on the stack), you have to keep going and return. The only way is run-to-completion. For libraries, any kind of modular code, this means that either the library is fully callable (and cannot be suspended), or it is made up of "async def" coroutines, all the way down to where an action occurs which wants to be suspended (say an I/O call). Which is no fun for a library designer - there is no way to hide "awaitability", it seems.
There is some relief ... asyncio also has tasks, which can be created in callable code. It sets up a coroutine to run soon, i.e. after the next suspend happens (which is by definition not now, since we're in callable context at this point). So yes, new work can be started at any time, but the code starting it has no way of stopping right there and waiting for a result: it's not a "rendesvouz" point. In Unix terms: you can "fork", but you cannot "wait". Not before returning anyway.
And a nasty tradeoff it is. The entire call chain from start of the asyncio event loop at the "top" of the application down to the code which does an "await" because it wants to let time pass without running code (or busy-polling), has to consist of "async def" code. If I want to use a library which has an await deep inside it, then my code has to be written using "async def" as well. And unfortunately, an "async def" function is not a normal function. It's an "awaitable", and must be executed in a different way.
So somewhere near the top, there's the launch of an asyncio event loop. It schedules coroutines and tasks. At the bottom, there's traditional run-to-completion "callable" code, however deeply nested, calling into as much traditional library code as needed. And at some point these two worlds meet.
My concern is that programming in an async fashion could be "toxic" (for lack of a better word). Before you know it, everything becomes an async-this and an await-that. Even for functionality which really doesn't have to deal with scheduling and the flow of time.
Here's an example, from an async version of the MQTT client library:
Code: Select all
async def ping(self): async with self._lock: await self._as_write(b"\xc0\0")
But what is really happening here? If I may ignore exception handling for now, isn't this simply an (asynchronous) sequence of operations?
- lock, wait until acquired
- send a few bytes, wait until sent (or perhaps queued for sending)
- unlock, wait until released
- resume execution of the caller
Code: Select all
def ping(self, cb): self.lock() self.write(b"\xc0\0", lambda x: self.unlock(); cb(x))
Code: Select all
def ping(self, cb): Seq(self.lock, (self.write, b"\xc0\0"), self.unlock, (-1, cb))
Could this point to a possible way out of "async def" creep? I don't know. I've not coded anything up yet. All I know is that I don't think I want to turn my own (highly- event- and message-oriented) application into a minefield of awaitable/callable mixups.
It would be nice if there were some elegant way out. I'm not sure there is, many people must have looked into this.
Perhaps this lengthy post will help find a practical approach to very async and very responsive, but also understandable, apps.
Thanks for reading this far.