Show full content
Let’s learn how asyncio works in Python by comparing it to Promises in Javascript.
To be clear, this is for my own benefit: I learned about Promises long before I knew any theory about coroutines, like any self-respecting frontend engineer in the 2010’s. The lightbulb moment for me was this code snippet:
const sleep = ms => new Promise((res, rej)=>setTimeout(res, ms))
A promise triggers its handler — possibly including I/O or timeouts or library code — and the handler is passed “hooks” it can call to mark the promise as “ok” (resolved) or “failed” (rejected.) So, in the example above, the handler sets the timer, which calls the “succeeded” hook when the timeout finishes. Any callback hell can be papered over into a Promise, with a decidedly nicer async/await API.
When we write:
await sleep(1000)
… execution in the current control flow suspends until the promise is resolved or rejected with a return value or exception, and then we resume execution. Javascript has always been event-loop driven, so queueing up bits of control flow is very intuitive.
Anyway, I am not here to explain Promises. I’m not claiming that they are easy to understand, just that they happen to be something that I understand, and that most frontend engineers understand, by necessity.
No, I’m here to write what I am sure is the 10,000th article explaining Python’s asyncio in terms of Promises. I am writing it, as I said, for my own benefit. Anyway:
All explanations of asyncio reference three primitives: coroutines, tasks, and futures, running on an event loop. I think this is eminently unreasonable – surely God intended us to learn only one abstraction! — but, nevertheless, you and I can no longer avoid learning what they mean. The bad news is that all three concepts are kind of like promises in slightly different overlapping ways.
The good news is
- each of these ideas is somewhat simpler than a Promise and
- Coroutines, tasks, and futures collectively compose into something that is broadly the same as a Promise.
My goal is to explain that mapping.
The Part That Lines Up NicelyAt a high level, asyncio’s scheduling is handled by an event loop. Much like in a JavaScript runtime, the event loop has a queue of jobs to do. Each job runs on the current thread until it completes or yields control back to the scheduler because it hit I/O, await, etc. We are doing single-threaded cooperative multitasking like it’s 1963! Frontend engineers are familiar with the pain caused when one long-running task causes the UX to hang up, and we have learned tricks like
await new Promise(resolve => setTimeout(resolve, 0));
to give the event loop a chance to run other tasks that might have queued up. Well, that’s precisely the kind of event loop we’re dealing with in Python. Now, because Python is a fancy language that also supports multithreading, it’s possible to have multiple different event loops running in parallel, but that is an abomination that should not be tolerated, in the opinion of this former frontend engineer. Let’s just pretend that there is one event loop. 
Now then, something we haven’t made explicit is “what is an independent unit of control flow?” In JavaScript, each <script> block or UX event handler runs independently. Let’s call such units of control flow a Job, for lack of a better term. Each Job will run until completion or failure. If there is an exception, it will bubble up to the root of that Job, but will not, for example, terminate the entire JavaScript runtime. Once a Job starts running, it will hog the current thread until it’s done or yields control flow back to the event loop. If that happens, the Job suspends until the event loop resumes it again. Control flow for a given job executes in a serial. Control flow for separate jobs can interleave.
A Promise creates a new Job, with independent scheduling and error boundaries. Demonstration:

Look at that! Our top-level control flow has no idea that the Promise raises an exception; we continue on to log “bar”. However, we can await the Promise if we like:

Now the promise’s execution is part of our serial control flow, the error bubbles up, and execution aborts before we can log “bar”. [Note that if a Promise does not throw an error, then the initial handler does run immediately as part of the parent control flow.]
The corresponding concept in asyncio is a task. The corresponding demonstration looks something like:

Which outputs:

This has the same semantics as our first example: the task is its own “job” with its own control flow/walled-off exceptions. And, as you might expect, if we alter it just slightly to…

… then the error in foo does propagate to our top-level control flow, and we never print “bar”.
In short, the idea of “create a Promise object” is a lot like “create a task.” Both push an independent “job” onto the event loop. Both return a handle to the job that you can pass around and/or await.
Python Shows You How the Sausage Is MadeA Promise object has a fairly limited API. We can attach additional .then() and .catch() handlers, but we can’t really inspect or manipulate the internals. As the saying goes:

How do you synchronously determine the current state of a native Promise? You don’t. And honestly, maybe that’s a good thing, because if Javascript DID expose the internal state, the types and semantics would be kind of complex, right?
[Python has entered the chat] Hey, can I show you something?
The truth is, there’s a lot of machinery needed to create an abstraction like a Promise or an asyncio Task, and if you were to expose that machinery, it would look kind of like coroutines and futures. So, let’s take a look!
First, we should point out that there are two separate bits of magic going on in async/await in JavaScript.
The first bit of magic is: A promise lets us asynchronously trigger handlers when it resolves. This was standardized in JS in 2015.
The second bit of magic is: When you are in an async function and call await promise, control flow in the calling function is suspended. The runtime waits for the promise to resolve and then schedules execution to resume. This was standardized in 2017.
What do we call this second bit of magic? The official name for functions that can cooperatively multitask with the event loop is a coroutine. Async functions are coroutines.
Well, Python has coroutines, too! There are some minor differences in calling convention::
async def foo():
return 123
foo() # this doesn’t actually execute foo; it creates a coroutine object
await foo() # this suspends the current function, executes foo, and resumes
In Javascript, if you call an async function without await, the result is more like asyncio.create_task(): an independent chain of control flow is triggered.
However, you can see this is purely a difference of syntax semantics. async functions are, in both cases, coroutines.
Now that we’ve touched on coroutines, let’s look at Python Futures.
One way to think about a Promise is as a pub/sub service:
- Create a handle: handle = new Promise()
- subscribe to the handle: await handle / handle.then().catch() …
- publish event: (reserved for internal promise handler)
The Promise API makes it easy to subscribe, but doesn’t expose a mechanism for publishing events. However, we can hack this together without too much trouble! Behold:

What have we got here? Well, we have created a new kind of Promise-like object that adds one API and removes another.
- What we’ve added: external callers can manually resolve our Future object via fut.signalSuccess(). We achieve this by intercepting the “hook” while constructing the promise inside Future.
- What we’ve removed: you can’t actually pass a handler to Future(). Unlike a Promise, this function doesn’t schedule any code for execution. It’s just a way to listen for future accept/reject events.
Interestingly, just as you can implement a Future from a Promise, you can implement a Promise from a Future:

So, Promises and Futures are duals: just two ways to look at the same underlying abstraction.
Python decided to give us Futures instead of Promises. (No big deal, we can just implement promises from futures!) A Future doesn’t execute code, but it does let coroutines subscribe to the resolve/reject state of the future. Tasks implement the API of futures so we can await and inspect the entire chain of asynchronous work kicked off by the task.
To recap:
- A Javascript Promise is very similar to a Python Task: Each is an independent “job” scheduled on the event loop. Execution of coroutines within a job is serial, but coroutines from different jobs can interleave on the event loop. Errors bubble up to the top of a Task/Promise execution but then stop (unless some other coroutine is await’ing the failed task/promise).
- A coroutine is the standard term for a function designed to yield/resume to the event loop. In JS we call it an async function. A Task/Promise will often consist of a tree of coroutines.
- A Future is one way to implement the pub/sub signalling we know and love from JS Promises. Tasks are a kind of Future, since it’s interesting to signal them and await them.
On a final note, I’ll say that asyncio also implements many interesting features that the JS runtime doesn’t, like task cancellation and special kinds of locks. However, that is a topic for another day.
. It’s a great story!