Revisiting a C API for asynchronous functions

Hello everyone!

Similar topics of mine have gone through discussion previously in January and last year. I would recommend reading these two to fully understand this discussion.

Over the two weeks or so, I’ve gone back and forth with @encukou on developing a PEP on this topic. My reference implementation only uses the public API, and would be a better fit as a library rather than an addition to CPython. For this reason, my previous draft PEP was scrapped in favor of turning it into a library. The details of my previous implementation are long and complicated, but in short, PyAwaitableObject * is an object that stores coroutines, then uses __await__ and __next__ to yield them to the event loop. For exact information on how this works, see my scrapped PEP.

I’ve come up with a new design that proposes a METH_ASYNC flag (inspired by this discussion) but it certainly requires some discussion before a PEP could be written. Ideally, if an API like this was to be added to CPython, my reference implementation would exist as a backport library to allow others to pick it up immediately (similar to what asyncio did).

The redesigned API looks like this:

static PyObject *
hello(PyObject *self, PyObject *awaitable, PyObject *args)
{
  PyObject *coro;
  if (!PyArg_ParseTuple(args, "O", &coro))
    return NULL;
  
  if (PyAwaitable_AWAIT(awaitable, coro) < 0)
    return NULL;

  Py_RETURN_NONE; // equivalent to PyAwaitable_SetResult(awaitable, Py_None)
}

static PyMethodDef HelloMethods[] = {
  {"hello", hello, METH_VARARGS | METH_ASYNC, NULL},
  {NULL, NULL, 0, NULL}
}

The two key upgrades in the new design are:

  • The user does not have to make any call to PyAwaitable_New, nor worry about Py_DECREFing the awaitable on an error check. This simple change eliminates a lot of boilerplate of Py_DECREF calls.
  • The user may return any PyObject * instead of just the awaitable, which may be more clear for setting return values.

I have written a prototype for this here. This can go a step further and use PyObject * return values in callbacks as well (this is not included in the prototype implementation):

static PyObject *
hello_callback(PyObject *awaitable, PyObject *result)
{
    // Assume that result is an int
    long value = PyLong_AsLong(result);
    if ((value == -1) && PyErr_Occurred())
        return NULL;

    if (value == 24)
        return PyLong_FromLong(42);

    PyAwaitable_RETURN_IGNORE; // This indicates that the return value should not be updated, and will remain as None
}

static PyObject *
hello(PyObject *self, PyObject *awaitable, PyObject *args)
{
  PyObject *coro;
  if (!PyArg_ParseTuple(args, "O", &coro))
    return NULL;
  
  if (PyAwaitable_AddAwait(awaitable, coro, hello_callback, NULL) < 0)
    return NULL;

  Py_RETURN_NONE; // equivalent to PyAwaitable_SetResult(awaitable, Py_None)
}

static PyMethodDef HelloMethods[] = {
  {"hello", hello, METH_VARARGS | METH_ASYNC, NULL},
  {NULL, NULL, 0, NULL}
}

A few notes:

  • My prototype implementation is simply a thin wrapper on top of my previous reference implementation, but this would not be the case in an actual CPython version.
  • The previous implementation, as stated before, uses a new PyAwaitableObject *, but perhaps a new object is not needed? Maybe the PyObject *awaitable in the above example could refer to a PyCoroObject *, but that would obviously require some modifications to the coroutine implementation.
  • I chose PyAwaitable_ as the prefix previously as that was the object’s name. In this new design, perhaps this prefix could change (such as maybe something like PyAsync_). Alternatively, if the coroutine object just gets reused, the PyCoro_ prefix should be chosen instead.

Nonetheless, I’m not totally sure whether METH_ASYNC would be needed in the first place if my library exists, but that’s what this discussion is for :smiley:

I don’t quite see the benefit over creating and returning the awaitable object from a normal function, something like this (using names from the scrapped PEP):

static PyObject *
hello(PyObject *self, PyObject *coro)
{
  PyObject *awaitable = PyAwaitable_New();
  if (!awaitable) {
    return NULL;
  }
  if (PyAwaitable_AWAIT(awaitable, coro) < 0) { 
    Py_DECREF(awaitable);
    return NULL;
  }
  if (PyAwaitable_SetResult(awaitable, Py_NONE) < 0) { 
    Py_DECREF(awaitable);
    return NULL;
  }
  return awaitable;
}

static PyMethodDef HelloMethods[] = {
  {"hello", hello, METH_O, NULL},
  {NULL, NULL, 0, NULL}
}

Eliminating a few lines of boilerplate in C code typically isn’t worth adding a new feature. Even if it was in this case, it’s better to do it after the underlying design (with a “verbose” API) gets some real-world use.