developmentseed / developmentseed/obstore
Consider when to use `spawn_blocking` in relation to `block_on` and GIL-holding Python calls
- 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:
-
Call a Python async function (i.e., get a coroutine),
-
Convert it into a Rust future (using
pyo3-asyncio), -
awaitit using a shared runtime (e.g. withblock_on), -
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_ondirectly 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_cellorlazy_staticto create a sharedRuntimeifpyo3_asyncio::tokio::get_runtime()isn't set up. -
Never
block_onwithoutallow_threadsandspawn_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