Expand description

Rust Bindings to the Python Asyncio Event Loop

Motivation

This crate aims to provide a convenient interface to manage the interop between Python and Rust’s async/await models. It supports conversions between Rust and Python futures and manages the event loops for both languages. Python’s threading model and GIL can make this interop a bit trickier than one might expect, so there are a few caveats that users should be aware of.

Why Two Event Loops

Currently, we don’t have a way to run Rust futures directly on Python’s event loop. Likewise, Python’s coroutines cannot be directly spawned on a Rust event loop. The two coroutine models require some additional assistance from their event loops, so in all likelihood they will need a new unique event loop that addresses the needs of both languages if the coroutines are to be run on the same loop.

It’s not immediately clear that this would provide worthwhile performance wins either, so in the interest of getting something simple out there to facilitate these conversions, this crate handles the communication between separate Python and Rust event loops.

Python’s Event Loop and the Main Thread

Python is very picky about the threads used by the asyncio executor. In particular, it needs to have control over the main thread in order to handle signals like CTRL-C correctly. This means that Cargo’s default test harness will no longer work since it doesn’t provide a method of overriding the main function to add our event loop initialization and finalization.

Event Loop References and ContextVars

One problem that arises when interacting with Python’s asyncio library is that the functions we use to get a reference to the Python event loop can only be called in certain contexts. Since PyO3 Asyncio needs to interact with Python’s event loop during conversions, the context of these conversions can matter a lot.

Likewise, Python’s contextvars library can require some special treatment. Python functions and coroutines can rely on the context of outer coroutines to function correctly, so this library needs to be able to preserve contextvars during conversions.

The core conversions we’ve mentioned so far in the README should insulate you from these concerns in most cases. For the edge cases where they don’t, this section should provide you with the information you need to solve these problems.

The Main Dilemma

Python programs can have many independent event loop instances throughout the lifetime of the application (asyncio.run for example creates its own event loop each time it’s called for instance), and they can even run concurrent with other event loops. For this reason, the most correct method of obtaining a reference to the Python event loop is via asyncio.get_running_loop.

asyncio.get_running_loop returns the event loop associated with the current OS thread. It can be used inside Python coroutines to spawn concurrent tasks, interact with timers, or in our case signal between Rust and Python. This is all well and good when we are operating on a Python thread, but since Rust threads are not associated with a Python event loop, asyncio.get_running_loop will fail when called on a Rust runtime.

contextvars operates in a similar way, though the current context is not always associated with the current OS thread. Different contexts can be associated with different coroutines even if they run on the same OS thread.

The Solution

A really straightforward way of dealing with this problem is to pass references to the associated Python event loop and context for every conversion. That’s why we have a structure called TaskLocals and a set of conversions that accept it.

TaskLocals stores the current event loop, and allows the user to copy the current Python context if necessary. The following conversions will use these references to perform the necessary conversions and restore Python context when needed:

  • pyo3_asyncio::into_future_with_locals - Convert a Python awaitable into a Rust future.
  • pyo3_asyncio::<runtime>::future_into_py_with_locals - Convert a Rust future into a Python awaitable.
  • pyo3_asyncio::<runtime>::local_future_into_py_with_locals - Convert a !Send Rust future into a Python awaitable.

One clear disadvantage to this approach is that the Rust application has to explicitly track these references. In native libraries, we can’t make any assumptions about the underlying event loop, so the only reliable way to make sure our conversions work properly is to store these references at the callsite to use later on.

use pyo3::{wrap_pyfunction, prelude::*};

#[pyfunction]
fn sleep(py: Python) -> PyResult<&PyAny> {
    // Construct the task locals structure with the current running loop and context
    let locals = pyo3_asyncio::TaskLocals::with_running_loop(py)?.copy_context(py)?;

    // Convert the async move { } block to a Python awaitable
    pyo3_asyncio::tokio::future_into_py_with_locals(py, locals.clone(), async move {
        let py_sleep = Python::with_gil(|py| {
            // Sometimes we need to call other async Python functions within
            // this future. In order for this to work, we need to track the
            // event loop from earlier.
            pyo3_asyncio::into_future_with_locals(
                &locals,
                py.import("asyncio")?.call_method1("sleep", (1,))?
            )
        })?;

        py_sleep.await?;

        Ok(())
    })
}

#[pymodule]
fn my_mod(py: Python, m: &PyModule) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(sleep, m)?)?;
    Ok(())
}

A naive solution to this tracking problem would be to cache a global reference to the asyncio event loop that all PyO3 Asyncio conversions can use. In fact this is what we did in PyO3 Asyncio v0.13. This works well for applications, but it soon became clear that this is not so ideal for libraries. Libraries usually have no direct control over how the event loop is managed, they’re just expected to work with any event loop at any point in the application. This problem is compounded further when multiple event loops are used in the application since the global reference will only point to one.

Another disadvantage to this explicit approach that is less obvious is that we can no longer call our #[pyfunction] fn sleep on a Rust runtime since asyncio.get_running_loop only works on Python threads! It’s clear that we need a slightly more flexible approach.

In order to detect the Python event loop at the callsite, we need something like asyncio.get_running_loop and contextvars.copy_context that works for both Python and Rust. In Python, asyncio.get_running_loop uses thread-local data to retrieve the event loop associated with the current thread. What we need in Rust is something that can retrieve the Python event loop and contextvars associated with the current Rust task.

Enter pyo3_asyncio::<runtime>::get_current_locals. This function first checks task-local data for the TaskLocals, then falls back on asyncio.get_running_loop and contextvars.copy_context if no task locals are found. This way both bases are covered.

Now, all we need is a way to store the TaskLocals for the Rust future. Since this is a runtime-specific feature, you can find the following functions in each runtime module:

  • pyo3_asyncio::<runtime>::scope - Store the task-local data when executing the given Future.
  • pyo3_asyncio::<runtime>::scope_local - Store the task-local data when executing the given !Send Future.

With these new functions, we can make our previous example more correct:

use pyo3::prelude::*;

#[pyfunction]
fn sleep(py: Python) -> PyResult<&PyAny> {
    // get the current event loop through task-local data
    // OR `asyncio.get_running_loop` and `contextvars.copy_context`
    let locals = pyo3_asyncio::tokio::get_current_locals(py)?;

    pyo3_asyncio::tokio::future_into_py_with_locals(
        py,
        locals.clone(),
        // Store the current locals in task-local data
        pyo3_asyncio::tokio::scope(locals.clone(), async move {
            let py_sleep = Python::with_gil(|py| {
                pyo3_asyncio::into_future_with_locals(
                    // Now we can get the current locals through task-local data
                    &pyo3_asyncio::tokio::get_current_locals(py)?,
                    py.import("asyncio")?.call_method1("sleep", (1,))?
                )
            })?;

            py_sleep.await?;

            Ok(Python::with_gil(|py| py.None()))
        })
    )
}

#[pyfunction]
fn wrap_sleep(py: Python) -> PyResult<&PyAny> {
    // get the current event loop through task-local data
    // OR `asyncio.get_running_loop` and `contextvars.copy_context`
    let locals = pyo3_asyncio::tokio::get_current_locals(py)?;

    pyo3_asyncio::tokio::future_into_py_with_locals(
        py,
        locals.clone(),
        // Store the current locals in task-local data
        pyo3_asyncio::tokio::scope(locals.clone(), async move {
            let py_sleep = Python::with_gil(|py| {
                pyo3_asyncio::into_future_with_locals(
                    &pyo3_asyncio::tokio::get_current_locals(py)?,
                    // We can also call sleep within a Rust task since the
                    // locals are stored in task local data
                    sleep(py)?
                )
            })?;

            py_sleep.await?;

            Ok(Python::with_gil(|py| py.None()))
        })
    )
}

#[pymodule]
fn my_mod(py: Python, m: &PyModule) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(sleep, m)?)?;
    m.add_function(wrap_pyfunction!(wrap_sleep, m)?)?;
    Ok(())
}

Even though this is more correct, it’s clearly not more ergonomic. That’s why we introduced a set of functions with this functionality baked in:

  • pyo3_asyncio::<runtime>::into_future

    Convert a Python awaitable into a Rust future (using pyo3_asyncio::<runtime>::get_current_locals)

  • pyo3_asyncio::<runtime>::future_into_py

    Convert a Rust future into a Python awaitable (using pyo3_asyncio::<runtime>::get_current_locals and pyo3_asyncio::<runtime>::scope to set the task-local event loop for the given Rust future)

  • pyo3_asyncio::<runtime>::local_future_into_py

    Convert a !Send Rust future into a Python awaitable (using pyo3_asyncio::<runtime>::get_current_locals and pyo3_asyncio::<runtime>::scope_local to set the task-local event loop for the given Rust future).

These are the functions that we recommend using. With these functions, the previous example can be rewritten to be more compact:

use pyo3::prelude::*;

#[pyfunction]
fn sleep(py: Python) -> PyResult<&PyAny> {
    pyo3_asyncio::tokio::future_into_py(py, async move {
        let py_sleep = Python::with_gil(|py| {
            pyo3_asyncio::tokio::into_future(
                py.import("asyncio")?.call_method1("sleep", (1,))?
            )
        })?;

        py_sleep.await?;

        Ok(Python::with_gil(|py| py.None()))
    })
}

#[pyfunction]
fn wrap_sleep(py: Python) -> PyResult<&PyAny> {
    pyo3_asyncio::tokio::future_into_py(py, async move {
        let py_sleep = Python::with_gil(|py| {
            pyo3_asyncio::tokio::into_future(sleep(py)?)
        })?;

        py_sleep.await?;

        Ok(Python::with_gil(|py| py.None()))
    })
}

#[pymodule]
fn my_mod(py: Python, m: &PyModule) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(sleep, m)?)?;
    m.add_function(wrap_pyfunction!(wrap_sleep, m)?)?;
    Ok(())
}

A special thanks to @ShadowJonathan for helping with the design and review of these changes!

Rust’s Event Loop

Currently only the Async-Std and Tokio runtimes are supported by this crate. If you need support for another runtime, feel free to make a request on GitHub (or attempt to add support yourself with the generic module)!

In the future, we may implement first class support for more Rust runtimes. Contributions are welcome as well!

Features

Items marked with attributes are only available when the attributes Cargo feature is enabled:

[dependencies.pyo3-asyncio]
version = "0.17"
features = ["attributes"]

Items marked with async-std-runtime are only available when the async-std-runtime Cargo feature is enabled:

[dependencies.pyo3-asyncio]
version = "0.17"
features = ["async-std-runtime"]

Items marked with tokio-runtime are only available when the tokio-runtime Cargo feature is enabled:

[dependencies.pyo3-asyncio]
version = "0.17"
features = ["tokio-runtime"]

Items marked with testing are only available when the testing Cargo feature is enabled:

[dependencies.pyo3-asyncio]
version = "0.17"
features = ["testing"]

Re-exports

pub use inventory;

Modules

async-std-runtime PyO3 Asyncio functions specific to the async-std runtime

Errors and exceptions related to PyO3 Asyncio

Generic implementations of PyO3 Asyncio utilities that can be used for any Rust runtime

testing Utilities for writing PyO3 Asyncio tests

tokio-runtime PyO3 Asyncio functions specific to the tokio runtime

Structs

Task-local data to store for Python conversions.

Functions

Get a reference to the Python Event Loop from Rust

Convert a Python awaitable into a Rust Future