Skip to content

Commit ca89b5c

Browse files
author
Andrew J Westlake
committed
Added a section about Event Loop references and thread-awareness to the README
1 parent 27ad604 commit ca89b5c

File tree

1 file changed

+196
-8
lines changed

1 file changed

+196
-8
lines changed

README.md

Lines changed: 196 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ Here we initialize the runtime, import Python's `asyncio` library and run the gi
3030
```toml
3131
# Cargo.toml dependencies
3232
[dependencies]
33-
pyo3 = { version = "0.13" }
34-
pyo3-asyncio = { version = "0.13", features = ["attributes", "async-std-runtime"] }
33+
pyo3 = { version = "0.14" }
34+
pyo3-asyncio = { version = "0.14", features = ["attributes", "async-std-runtime"] }
3535
async-std = "1.9"
3636
```
3737

@@ -60,8 +60,8 @@ attribute.
6060
```toml
6161
# Cargo.toml dependencies
6262
[dependencies]
63-
pyo3 = { version = "0.13" }
64-
pyo3-asyncio = { version = "0.13", features = ["attributes", "tokio-runtime"] }
63+
pyo3 = { version = "0.14" }
64+
pyo3-asyncio = { version = "0.14", features = ["attributes", "tokio-runtime"] }
6565
tokio = "1.4"
6666
```
6767

@@ -84,7 +84,7 @@ async fn main() -> PyResult<()> {
8484
}
8585
```
8686

87-
More details on the usage of this library can be found in the [API docs](https://awestlake87.github.io/pyo3-asyncio/master/doc).
87+
More details on the usage of this library can be found in the [API docs](https://awestlake87.github.io/pyo3-asyncio/master/doc) and the primer below.
8888

8989
### PyO3 Native Rust Modules
9090

@@ -104,15 +104,15 @@ For `async-std`:
104104
```toml
105105
[dependencies]
106106
pyo3 = { version = "0.13", features = ["extension-module"] }
107-
pyo3-asyncio = { version = "0.13", features = ["async-std-runtime"] }
107+
pyo3-asyncio = { version = "0.14", features = ["async-std-runtime"] }
108108
async-std = "1.9"
109109
```
110110

111111
For `tokio`:
112112
```toml
113113
[dependencies]
114114
pyo3 = { version = "0.13", features = ["extension-module"] }
115-
pyo3-asyncio = { version = "0.13", features = ["tokio-runtime"] }
115+
pyo3-asyncio = { version = "0.14", features = ["tokio-runtime"] }
116116
tokio = "1.4"
117117
```
118118

@@ -158,7 +158,6 @@ fn rust_sleep(py: Python) -> PyResult<&PyAny> {
158158
#[pymodule]
159159
fn my_async_module(py: Python, m: &PyModule) -> PyResult<()> {
160160
m.add_function(wrap_pyfunction!(rust_sleep, m)?)?;
161-
162161
Ok(())
163162
}
164163

@@ -362,6 +361,195 @@ async fn main() -> PyResult<()> {
362361
}
363362
```
364363
364+
### Event Loop References and Thread-awareness
365+
366+
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 requires references to the event loop when performing conversions between Rust and Python, this is unfortunately something that you need to worry about.
367+
368+
#### The Main Dilemma
369+
370+
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. The most correct method of obtaining a reference to the Python event loop is via `asyncio.get_running_loop`.
371+
372+
`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 what happens when we want to perform a PyO3 Asyncio conversion on a _Rust_ thread? Since the Rust thread is not associated with a Python event loop, `asyncio.get_running_loop` will fail.
373+
374+
#### The Solution
375+
376+
A really straightforward way of dealing with this problem is to pass a reference to the associated Python event loop for every conversion. That's why in PyO3 Asyncio, we introduced a new set of conversion functions that do just that:
377+
378+
- `pyo3_asyncio::into_future_with_loop` - Convert a Python awaitable into a Rust future with the given asyncio event loop.
379+
- `pyo3_asyncio::<runtime>::future_into_py_with_loop` - Convert a Rust future into a Python awaitable with the given asyncio event loop.
380+
- `pyo3_asyncio::<runtime>::local_future_into_py_with_loop` - Convert a `!Send` Rust future into a Python awaitable with the given asyncio event loop.
381+
382+
One clear disadvantage to this approach (besides the verbose naming) is that the Rust application has to explicitly track its references to the Python event loop. 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 a reference to the current event loop to use later on.
383+
384+
```rust
385+
use pyo3::prelude::*;
386+
387+
#[pyfunction]
388+
fn sleep(py: Python) -> PyResult<&PyAny> {
389+
let current_loop = pyo3_asyncio::get_running_loop(py)?;
390+
let loop_ref = PyObject::from(current_loop);
391+
392+
// Convert the async move { } block to a Python awaitable
393+
pyo3_asyncio::tokio::future_into_py_with_loop(current_loop, async move {
394+
let py_sleep = Python::with_gil(|py| {
395+
// Sometimes we need to call other async Python functions within
396+
// this future. In order for this to work, we need to track the
397+
// event loop from earlier.
398+
pyo3_asyncio::into_future_with_loop(
399+
loop_ref.as_ref(py),
400+
py.import("asyncio")?.call_method1("sleep", (1,))?
401+
)
402+
})?;
403+
404+
py_sleep.await?;
405+
406+
Ok(Python::with_gil(|py| py.None()))
407+
})
408+
}
409+
410+
#[pymodule]
411+
fn my_mod(py: Python, m: &PyModule) -> PyResult<()> {
412+
m.add_function(wrap_pyfunction!(sleep, m)?)?;
413+
Ok(())
414+
}
415+
```
416+
417+
> 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.
418+
419+
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.
420+
421+
In order to detect the Python event loop at the callsite, we need something like `asyncio.get_running_loop` 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 associated with the current _task_.
422+
423+
Enter `pyo3_asyncio::<runtime>::get_current_loop`. This function first checks task-local data for a Python event loop, then falls back on `asyncio.get_running_loop` if no task-local event loop is found. This way both bases are convered.
424+
425+
Now, all we need is a way to store the event loop in task-local data. Since this is a runtime-specific feature, you can find the following functions in each runtime module:
426+
427+
- `pyo3_asyncio::<runtime>::scope` - Store the event loop in task-local data when executing the given Future.
428+
- `pyo3_asyncio::<runtime>::scope_local` - Store the event loop in task-local data when executing the given `!Send` Future.
429+
430+
With these new functions, we can make our previous example more correct:
431+
432+
```rust no_run
433+
use pyo3::prelude::*;
434+
435+
#[pyfunction]
436+
fn sleep(py: Python) -> PyResult<&PyAny> {
437+
// get the current event loop through task-local data
438+
// OR `asyncio.get_running_loop`
439+
let current_loop = pyo3_asyncio::tokio::get_current_loop(py)?;
440+
441+
pyo3_asyncio::tokio::future_into_py_with_loop(
442+
current_loop,
443+
pyo3_asyncio::tokio::scope(current_loop.into(), async move {
444+
let py_sleep = Python::with_gil(|py| {
445+
pyo3_asyncio::into_future_with_loop(
446+
// Now we can get the current loop through task-local data
447+
pyo3_asyncio::tokio::get_current_loop(py)?,
448+
py.import("asyncio")?.call_method1("sleep", (1,))?
449+
)
450+
})?;
451+
452+
py_sleep.await?;
453+
454+
Ok(Python::with_gil(|py| py.None()))
455+
})
456+
)
457+
}
458+
459+
#[pyfunction]
460+
fn wrap_sleep(py: Python) -> PyResult<&PyAny> {
461+
// get the current event loop through task-local data
462+
// OR `asyncio.get_running_loop`
463+
let current_loop = pyo3_asyncio::tokio::get_current_loop(py)?;
464+
465+
pyo3_asyncio::tokio::future_into_py_with_loop(
466+
current_loop,
467+
pyo3_asyncio::tokio::scope(current_loop.into(), async move {
468+
let py_sleep = Python::with_gil(|py| {
469+
pyo3_asyncio::into_future_with_loop(
470+
pyo3_asyncio::tokio::get_current_loop(py)?,
471+
// We can also call sleep within a Rust task since the
472+
// event loop is stored in task local data
473+
sleep(py)?
474+
)
475+
})?;
476+
477+
py_sleep.await?;
478+
479+
Ok(Python::with_gil(|py| py.None()))
480+
})
481+
)
482+
}
483+
484+
#[pymodule]
485+
fn my_mod(py: Python, m: &PyModule) -> PyResult<()> {
486+
m.add_function(wrap_pyfunction!(sleep, m)?)?;
487+
m.add_function(wrap_pyfunction!(wrap_sleep, m)?)?;
488+
Ok(())
489+
}
490+
```
491+
492+
Even though this is more correct, it's clearly not more ergonomic. That's why we introduced a new set of functions with this functionality baked in:
493+
494+
- `pyo3_asyncio::<runtime>::into_future` - Convert a Python awaitable into a Rust future (using `pyo3_asyncio::<runtime>::get_current_loop`)
495+
- `pyo3_asyncio::<runtime>::future_into_py` - Convert a Rust future into a Python awaitable (using `pyo3_asyncio::<runtime>::get_current_loop` and `pyo3_asyncio::<runtime>::scope` to set the task-local event loop for the given Rust future)
496+
- `pyo3_asyncio::<runtime>::local_future_into_py` - Convert a `!Send` Rust future into a Python awaitable (using `pyo3_asyncio::<runtime>::get_current_loop` and `pyo3_asyncio::<runtime>::scope_local` to set the task-local event loop for the given Rust future).
497+
498+
__These are the functions that we recommend using__. With these functions, the previous example can be written like so:
499+
500+
```rust
501+
use pyo3::prelude::*;
502+
503+
#[pyfunction]
504+
fn sleep(py: Python) -> PyResult<&PyAny> {
505+
pyo3_asyncio::tokio::future_into_py(py, async move {
506+
let py_sleep = Python::with_gil(|py| {
507+
pyo3_asyncio::tokio::into_future(
508+
py.import("asyncio")?.call_method1("sleep", (1,))?
509+
)
510+
})?;
511+
512+
py_sleep.await?;
513+
514+
Ok(Python::with_gil(|py| py.None()))
515+
})
516+
}
517+
518+
#[pyfunction]
519+
fn wrap_sleep(py: Python) -> PyResult<&PyAny> {
520+
pyo3_asyncio::tokio::future_into_py(py, async move {
521+
let py_sleep = Python::with_gil(|py| {
522+
pyo3_asyncio::tokio::into_future(sleep(py)?)
523+
})?;
524+
525+
py_sleep.await?;
526+
527+
Ok(Python::with_gil(|py| py.None()))
528+
})
529+
}
530+
531+
#[pymodule]
532+
fn my_mod(py: Python, m: &PyModule) -> PyResult<()> {
533+
m.add_function(wrap_pyfunction!(sleep, m)?)?;
534+
m.add_function(wrap_pyfunction!(wrap_sleep, m)?)?;
535+
Ok(())
536+
}
537+
```
538+
539+
### A Note for `v0.13` Users
540+
541+
Hey guys, I realize that these are pretty major changes for `v0.14`, and I apologize in advance for having to modify the public API so much. I hope
542+
the explanation above gives some much needed context and justification for all the breaking changes.
543+
544+
Part of the reason why it's taken so long to push out a `v0.14` release is because I wanted to make sure we got this release right. There were a lot of issues with the `v0.13` release that I hadn't anticipated, and it's thanks to your feedback and patience that we've worked through these issues to get a more correct, more flexible version out there!
545+
546+
This new release should address most the core issues that users have reported in the `v0.13` release, so I think we can expect more stability going forward.
547+
548+
Also, a special thanks to @ShadowJonathan for helping with the design and review
549+
of these changes!
550+
551+
- @awestlake87
552+
365553
## PyO3 Asyncio in Cargo Tests
366554

367555
The default Cargo Test harness does not currently allow test crates to provide their own `main`

0 commit comments

Comments
 (0)