PEP 852: Asynchronous functions in the C API

Nearly four years after the original discussion, I am pleased to present PEP 852, which adds a proper interface for using async/await in the C API. Here’s an example:

static int
return_result(PyObject *asyncobj, PyObject *result)
{
    PyAsync_SetResult(asyncobj, result);
    return 1;
}

/* Equivalent to: async def await_from_c(func): return await func() */
static PyObject *
await_from_c(PyObject *self, PyObject *func) /* METH_O */
{
    PyObject *asyncobj = PyAsync_New();
    if (asyncobj == NULL) {
        return NULL;
    }
    if (PyAsync_AddExpr(asyncobj, PyObject_CallNoArgs(func), return_result, NULL) < 0) {
        PyAsync_Cancel(asyncobj);
        Py_DECREF(asyncobj);
        return NULL;
    }
    return asyncobj;
}

This proposal is largely based on my PyAwaitable library, though with a number of tweaks.

Here’s the full text:

9 Likes

I had a quick look at the C API and I like it! It’s well designed and seems convenient to use!

A PyAsync object is recognized by asyncio.iscoroutine() and inspect.isawaitable(), and can be passed to asyncio.create_task().

Sorry, I didn’t follow async frameworks recently. With these properties, can PyAsync be used with event loops other than asyncio such as Twisted or Tornado? Or is it designed only for asyncio? The PEP mostly refers to asyncio (which is expected since it’s part of the stdlib).

int PyAsync_SetResult(PyObject *asyncobj, PyObject *result)
It cannot fail and returns 0.

Either the function can fail and return -1 on error, or it cannot fail and its return type should be void. What do you prefer? :slight_smile: IMO it should be possible to fail if the first argument is not an async object (set TypeError exception and return -1 in this case). It may also set a SystemError if result is NULL (maybe only in debug mode?).

Is it really needed to add PyAsync_AddExpr()? It seems to be a convenient helper function. It’s less obvious to me what “Expr” refers to. With PyAsync_AddAwait name, I can get that it referes to an awaitable object. Or maybe it’s just a naming issue and PyAsync_AddAwaitSteal() would be more explicit?

PyAsync_Cancel() cannot fail. So if something bad happens, the exception is logged with sys.unraisablehook? It might be more future proof to leave the door open to report an exception to the caller. Even if it cannot fail in the current implementation.

PyAsync_SetArbValue / PyAsync_GetArbValue

Bikeshedding: you might call them PyAsync_SetData / PyAsync_GetData

2 Likes

A PyAsync is just an object that implements collections.abc.Coroutine, so yes, it can be used with other event loops; see this section in the PEP.

That seems like a reasonable change. I put the int result there for future-proofing, but currently it can’t fail. But, we might as well add the debug check to make it useful.

AddExpr is really helpful in practice, because often you have to call a method in order to get a coroutine object, which with AddAwait requires an extra error handling branch. AddExpr lets you avoid that.

I would be fine with PyAsync_AddAwaitSteal if you prefer that.

I see PyAsync_Cancel as similar to a deallocation routine, because it only calls destructors (by decrefing the coroutines queued with AddAwait), so realistically, it shouldn’t ever set exceptions. I think leaving failures as unraisable exceptions here is fine.

Good idea, I like this more than the current spelling.

1 Like

I’m curious: why don’t PyAsync_Callback and PyAsync_Error take a arbitrary user data parameter (presumably a void* or uintptr_t)?

In other words, I would expect something like:

typedef int (*PyAsync_Callback)(PyObject *asyncobj,
                                PyObject *result, uintptr_t data);
typedef int (*PyAsync_Error)(PyObject *asyncobj,
                             PyObject *exception, uintptr_t data);
int PyAsync_AddAwait(
    PyObject *asyncobj, PyObject *awaitable,
    PyAsync_Callback result_cb, uintptr_t result_cb_data,
    PyAsync_Error error_cb, uintptr_t error_cb_data);

(disclaimer: I have no use case for these APIs in particular; I just know that C APIs that take callbacks but do not allow passing arbitrary params with them are a major PITA; and, yes, I saw the PyAsync_SetArbValue, but it seems much less convenient as it sets a single pointer for the entire async object, instead of per-callback)

1 Like

The idea is that you’ll actually want a single pointer across the entire async object. Remember, the callbacks are basically a formality because C doesn’t have native async/await. The “data” pointer is typically going to be a struct initialized in the original function (the one that calls PyAsync_New) to make up for the lack of closures, and then all the callbacks simply reach for it when they need the data. If any of the callbacks want to populate that struct with more data, they can just do that – this is perfectly safe because the developer knows the order in which every callback will be executed.

We could make it per-callback, but it leads to longer signatures and can (maybe?) lead to reference leaks if the callback data is a Python object pointer.

That depends on the use case. My first question when seeing this API was “how would I wrap this in a convenience C++ API”, and I would probably want each individual callback to be a std::function… in which case each callback has its own closure.

Individual closures may also make things more composable. If there is a single closure inside the async object, it introduces coupling between the various components (the async object and the various callbacks have to share specific knowledge, such as a custom structure definition).

(of course the single closure can be used to manually track each callback’s closure data; it’s just more work for the user of this API)

But why is that such a bad thing? The callbacks will need to share a lot of information anyway, because a callback isn’t meant to be reusable across any arbitrary PyAsync object.

I’m really hesitant to do per-callback closures because it would make the proposed API much more verbose. For what it’s worth, a C++ wrapper should still be fairly easy to make when using a single data pointer. Something like this should work:

// No error handling

using Callback = std::function<void(PyObject *asyncobj, PyObject *result)>;
using Queue = std::deque<Callback>;

static int trampoline(PyObject *asyncobj, PyObject *result) {
    auto *queue = static_cast<Queue *>(PyAsync_GetArbValue(asyncobj));
    Callback callback = std::move(queue->front());
    queue->pop_front();
    callback(asyncobj, result);
    return 1;
}

static void await(PyObject *asyncobj, PyObject *awaitable, Callback callback) {
    auto *queue = static_cast<Queue *>(PyAsync_GetArbValue(asyncobj));
    if (queue == nullptr) {
        queue = new Queue; // never freed, for brevity
        PyAsync_SetArbValue(asyncobj, queue);
    }
    queue->push_back(std::move(callback));
    PyAsync_AddAwait(asyncobj, awaitable, trampoline, nullptr);
}

/* async def both(a, b): print(await a, "first"); print(await b, "second") */
static PyObject *
both(PyObject *self, PyObject *args)
{
    PyObject *a, *b;
    if (!PyArg_ParseTuple(args, "OO", &a, &b)) {
        return nullptr;
    }
    PyObject *asyncobj = PyAsync_New();
    await(asyncobj, a, [](PyObject *, PyObject *result) {
        PySys_FormatStdout("%R first\n", result);
    });
    await(asyncobj, b, [](PyObject *, PyObject *result) {
        PySys_FormatStdout("%R second\n", result);
    });
    return asyncobj;
}

The only thing a per-callback data pointer would do here is avoid the need for an extra queue, so it wouldn’t save all that much. (You could argue that the data pointer approach requires more memory, but keep in mind that adding per-callback data pointers will increase the size of every callback entry by 2 * sizeof(void *), so I’m not sure it would actually be better in practice.)

1 Like

I suppose this needs to be slightly more complicated for the handling of the error callback. You need two similar trampolines and your queue elements are pairs of std::function, presumably?

Also, how is the queue lifetime supposed to be handled in that example? Does the async object offer a finalization callback?

It does not, so that might be worth adding. When using PyAwaitable in pure C, I didn’t bother adding one because I would just deal with deallocation in the last callback, but I think it would make sense for C++ wrappers.

Would adding a simple freefunc parameter for the deallocator be enough?

How does a callback know it’s the last one being invoked? Especially if an exception occurs between two callbacks?

I think so, yes, though it might make sense for it to be called from tp_clear as well.

1 Like

Also cc @da-woods @scoder , as I suspect Cython would be interested in this API.

1 Like

It was just up to the developer to know. A PyAsync object isn’t going to schedule new coroutines on its own, so the developer would just write a callback with the free() calls and schedule that one last.

I think Stefan knows more about the async code in Cython than me, but

For example, mypyc still does not support all asynchronous features, because they are forced to build coroutine types from scratch, which is difficult to do correctly.

Cython’s does have the coroutine types built from scratch (with a pretty large amount of copy-paste from the CPython implementations). I think we’d probably continue to use those on the basis that they’re already there, and they have some extra stuff so they’re introspectable more like the Python types.

I don’t know if we’d have done things differently if this had been available when async functions were first added to Cython.

1 Like