Executing Asynchronous Calls from C¶
Let’s say we wanted to replicate the following in C:
async def trampoline(coro: collections.abc.Coroutine) -> int:
return await coro
This is simply a function that we pass a coroutine to, and it will await it for us. It’s not particularly useful, but it’s just for learning purposes.
We already know that trampoline()
will evaluate to a magic coroutine object
that supports await
, via the __await__
dunder,
and needs to yield from its coroutines. So, if we wanted
to break trampoline()
down into a synchronous Python function, it would look
something like this:
class _trampoline_coroutine:
def __init__(self, coro: collections.abc.Coroutine) -> None:
self.coro = coro
def __await__(self) -> collections.abc.Generator:
yield
yield from self.coro.__await__()
def trampoline(coro: collections.abc.Coroutine) -> collections.abc.Coroutine:
return _trampoline_coroutine(coro)
But, this is using yield from
; there’s no yield from
in C, so how do we
actually await things, or more importantly, use their return value? This is
where things get tricky.
Adding Awaits to a PyAwaitable Object¶
There’s one big function for “adding” coroutines to a PyAwaitable object:
PyAwaitable_AddAwait()
. By “add”, we mean that the asynchronous
call won’t happen right then and there. Instead, the PyAwaitable will
store it, and then when something comes to call the __await__
on the PyAwaitable object, it will mimick a yield from
on that coroutine.
PyAwaitable_AddAwait()
takes four arguments:
The PyAwaitable object.
The coroutine to store. (Not an async def function, but the result of calling one without
await
.)A callback.
An error callback.
Let’s focus on the first two for now, and just pass NULL
for the other
two in the meantime. We can implement trampoline()
from our earlier
example pretty easily:
static PyObject *
trampoline(PyObject *self, PyObject *coro) // METH_O
{
PyObject *awaitable = PyAwaitable_New();
if (awaitable == NULL) {
return NULL;
}
if (PyAwaitable_AddAwait(awaitable, coro, NULL, NULL) < 0) {
Py_DECREF(awaitable);
return NULL;
}
return awaitable;
}
To your eyes, the yield from
and all of that mess is completely
hidden; you give PyAwaitable your coroutine, and it handles the rest!
trampoline()
now acts like our pure-Python function from earlier:
>>> from _yourmod import trampoline
>>> import asyncio
>>> await trampoline(asyncio.sleep(2)) # Sleeps for 2 seconds
Yay! We called an asynchronous function from C!
Simpler PyAwaitable_AddAwait
Calls¶
But, what if we wanted to call the async def
function from the C API?
With our current knowledge, that would look like this:
static PyObject *
trampoline(PyObject *self, PyObject *func) // METH_O
{
PyObject *awaitable = PyAwaitable_New();
if (awaitable == NULL) {
return NULL;
}
PyObject *coro = PyObject_CallNoArgs(func);
if (coro == NULL) {
Py_DECREF(awaitable);
return NULL;
}
if (PyAwaitable_AddAwait(awaitable, coro, NULL, NULL) < 0) {
Py_DECREF(awaitable);
Py_DECREF(coro);
return NULL;
}
Py_DECREF(coro);
return awaitable;
}
Ouch, that’s a lot of boilerplate. Luckily, PyAwaitable provides a convenience
function for this case: PyAwaitable_AddExpr()
. This function is very
similar to PyAwaitable_AddAwait()
, but it has two additional semantics
for the passed coroutine:
If the coroutine is
NULL
, it returns-1
without setting an exception.If the coroutine is non-
NULL
, it passes it toPyAwaitable_AddAwait()
and then decrements its reference count (“stealing a reference”).
These properties make it possible to directly use the result of a C API
function without extra boilerplate, because errors will be propagated when
it fails (when the coroutine is NULL
) and the reference count will be
decremented, preventing leaks.
So, with that in mind, we can rewrite our example as the following:
static PyObject *
trampoline(PyObject *self, PyObject *func) // METH_O
{
PyObject *awaitable = PyAwaitable_New();
if (awaitable == NULL) {
return NULL;
}
if (PyAwaitable_AddExpr(awaitable, PyObject_CallNoArgs(func), NULL, NULL) < 0) {
Py_DECREF(awaitable);
return NULL;
}
return awaitable;
}
Getting the Return Value in a Callback¶
In many cases, it’s desirable to use the return value of a coroutine. For example, let’s say we wanted to get the result of the following asynchronous function:
async def silly() -> int:
await asyncio.sleep(2) # Simulate doing some I/O work
return 42
The details of how coroutines return values aren’t relevant, but we do know that a coroutine isn’t actually “awaited” until after we’ve already returned our PyAwaitable object from C. That means we have to use a callback to get the return value of the coroutine.
Specifically, we can pass a function pointer to the third parameter of
PyAwaitable_AddAwait()
. A callback function takes two
PyObject *
parameters:
A reference to the PyAwaitable object that called it.
A reference to the return value of the coroutine.
A callback must return 0
to indicate success, or -1
with an exception set to indicate failure.
Now, we can use the result of silly()
in C:
static int
callback(PyObject *awaitable, PyObject *value)
{
if (PyAwaitable_SetResult(awaitable, value) < 0) {
return -1;
}
return 0;
}
static PyObject *
call_silly(PyObject *self, PyObject *silly)
{
PyObject *awaitable = PyAwaitable_New();
if (awaitable == NULL) {
return NULL;
}
if (PyAwaitable_AddExpr(awaitable, PyObject_CallNoArgs(silly), callback, NULL) < 0) {
Py_DECREF(awaitable);
Py_DECREF(coro);
return NULL;
}
Py_DECREF(coro);
return awaitable;
}
This can be used from Python as such:
>>> from _yourmod import call_silly
>>> await call_silly(silly) # Sleeps for 2 seconds
silly() returned: 42
Handling Errors with Callbacks¶
Coroutines can raise exceptions, during execution. For example, imagine we wanted to use a function that makes a network request:
import asyncio
async def make_request() -> str:
async with asyncio.timeout(5):
await asyncio.sleep(10) # Simulate some I/O
return "..."
The above will raise TimeoutError
, but not on simply calling
make_request()
; it will only raise once it’s actually started executing
in an await
, and as we already know that coroutines don’t execute
at the PyAwaitable_AddAwait()
callsite, we can’t simply check for
errors there. So, similar to return value callbacks, PyAwaitable provides error
callbacks, which–you guessed it–is the fourth argument to PyAwaitable_AddAwait
.
An error callback has the same signature as a return value callback, but instead of taking a reference to a return value, it takes a reference to an exception object that was caught and raised by either the coroutine or the coroutine’s callback.
Note
Error callbacks are not called with an exception “set”
(so PyErr_Occurred()
returns NULL
), so it’s safe to call
most of Python’s C API without worrying about those kinds of failures.
An error callback’s return value can do a number of different things to the state of the PyAwaitable object’s exception. Namely:
Returning
0
will consider the error successfully caught, so the PyAwaitable object will clear the exception and continue executing the rest of its coroutine.Returning
-1
indicates that the error should be repropagated. The PyAwaitable object will officially “set” the Python exception (viaPyErr_SetRaisedException()
), raise the error to the event loop and stop itself from executing any future coroutines.Returning
-2
indicates that a new error occurred while handling the other one; the original exception is _not_ restored, and an exception set by the error callback is used instead and propagated to the event loop.
Note
Return value callbacks are not called if an exception occurred while executing the coroutine.
To try and give a real-world example of all three of these, let’s implement the following function in C:
async def is_api_reachable(make_request: Callable[[], collections.abc.Coroutine]) -> bool:
try:
await make_request()
return True
except TimeoutError:
return False
Note
asyncio.TimeoutError
is an alias of the built-in
TimeoutError
exception.
We have to do several things here:
Call
make_request()
to get the coroutine object toawait
.Add an error callback for that coroutine.
In a return value callback, set the return value to
True
(or really,Py_True
), because that means the operation didn’t time out.In an error callback, check if the exception is an instance of
TimeoutError
, and set the return value toFalse
if it is.If it’s something other than
TimeoutError
, let it propagate.
In C, all that would be implemented like this:
static int
return_true(PyObject *awaitable, PyObject *unused)
{
return PyAwaitable_SetResult(awaitable, Py_True);
}
static int
return_false(PyObject *awaitable, PyObject *exc)
{
if (PyErr_GivenExceptionMatches(exc, PyExc_TimeoutError)) {
if (PyAwaitable_SetResult(exc, Py_False) < 0) {
// New exception occurred; give it to the event loop.
return -2;
}
return 0;
} else {
// This isn't a TimeoutError!
return -1;
}
}
static PyObject *
is_api_reachable(PyObject *self, PyObject *make_request)
{
PyObject *awaitable = PyAwaitable_New();
if (awaitable == NULL) {
return NULL;
}
if (PyAwaitable_AddExpr(awaitable, PyObject_CallNoArgs(coro), return_true, return_false)) {
Py_DECREF(awaitable);
return NULL;
}
return awaitable;
}
Propagation of Errors in Return Value Callbacks¶
By default, returning -1
from a return value callback will implicitly
call the error callback if one is set. But, this isn’t always desirable;
sometimes, you want to let errors in callbacks bubble up instead of
getting handled by some default error handling mechanism you’ve installed.
You can force the PyAwaitable object to propagate the exception by returning
-2
from a return value callback. If -2
is returned, the exception set
by the callback will always be raised back to whoever awaited the PyAwaitable
object.
For example, if we installed some global exception logger inside of the error
callback, but don’t want that to grab things like a MemoryError
inside
of the return callback, we would return -2
:
static int
error_handler(PyObject *awaitable, PyObject *error)
{
// Simply print the error and continue execution
PyErr_SetRaisedException(Py_NewRef(error));
PyErr_Print();
return 0;
}
static int
handle_value(PyObject *awaitable, PyObject *something)
{
PyObject *message = PyUnicode_FromString("LOG: Got value");
if (message == NULL) {
// Skip the error callback
return -2;
}
if (magically_log_value(message, something) < 0) {
// Skip the error callback
return -2;
}
return 0;
}