developmentseed / developmentseed/obstore

Consider when to use `spawn_blocking` in relation to `block_on` and GIL-holding Python calls

Open
#453 3 comments 0 reactions 0 assignees View on GitHub
Dominant language
Python
Stars
810
Forks
42
Avg merge
1d 15h
Merged PRs (30d)
6

Description

I had my first truly successful experience with ChatGPT in debugging #450 / #451.

The core issue was that we needed to release the GIL before calling `block_on`.

But one thing I'm not really clear on is when we need to use `spawn_blocking`

Suggested from ChatGPT:

Yes — you absolutely should use tokio::task::spawn_blocking if you're inside a PyO3-exposed #[pyfunction] and want to:




  1. Call a Python async function (i.e., get a coroutine),




  2. Convert it into a Rust future (using pyo3-asyncio),




  3. await it using a shared runtime (e.g. with block_on),




  4. All while not blocking the GIL or the async runtime.






🧠 Why spawn_blocking is needed


When you're in a PyO3 #[pyfunction], you're usually:




  • In a synchronous context, meaning you can't .await.




  • Already holding the GIL, so calling block_on directly would block the thread while holding the GIL — 💀 deadlock risk!




  • The async Python function (coroutine) might try to acquire the GIL (e.g. return a result), which leads to the deadlock.




Thus, you need to:


Release the GIL using py.allow_threads(...)

Run blocking code using tokio::task::spawn_blocking(...)

Use a shared Tokio runtime, not per-call Runtime::new()




✅ Safe Pattern in #[pyfunction]:


#[pyfunction]

fn call_py_async_blocking(py: Python) -> PyResult<i32> {
// Release the GIL to prevent deadlock
let result = py.allow_threads(|| {
// Use spawn_blocking to avoid blocking the async runtime
tokio::task::spawn_blocking(move || {
// Use a shared runtime
pyo3_asyncio::tokio::get_runtime().block_on(async {
// GIL needed to create coroutine
let coro = Python::with_gil(|py| {
let module = py.import("mymodule")?;
let coroutine = module.call_method1("py_add", (10, 20))?;
pyo3_asyncio::tokio::into_future(coroutine)
})?;

// Await the coroutine
let result = coro.await?;

// Extract value from PyAny
Python::with_gil(|py| result.extract::<i32>(py))
})
})
});

// Handle any async errors
result.unwrap()
}




🔁 Summary

Step | Reason
-- | --
py.allow_threads(...) | Releases the GIL to avoid deadlocks
spawn_blocking(...) | Avoids blocking the async runtime
Runtime::block_on(...) | Runs async code from sync context
Python::with_gil(...) | Needed to create coroutine and extract results safely



🔧 Pro Tips




  • Use once_cell or lazy_static to create a shared Runtime if pyo3_asyncio::tokio::get_runtime() isn't set up.




  • Never block_on without allow_threads and spawn_blocking — it will work sometimes then crash in production under load 🧨






Let me know if you want me to generate a working minimal example (Rust + Python module) with everything wired up — I can do that too.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start by reviewing issues #450 and #451, then trace the uses of py.allow_threads, tokio::task::spawn_blocking, block_on, and pyo3_asyncio::tokio::get_runtime mentioned here. Done means reaching a project-specific decision, backed by the relevant call sites and tests, about when each mechanism is appropriate.

Written by the indexing model from the issue text.

Assessment

Tech stack
python, rust
Domain
backend
Issue type
Refactor
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Needs clarification
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.