-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
Fix pipelined rendering shutdown deadlock #24059
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
bryancostanich
wants to merge
2
commits into
bevyengine:main
Choose a base branch
from
bryancostanich:fix/pipelined-rendering-shutdown-deadlock
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+113
−3
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,5 @@ | ||
| use alloc::sync::Arc; | ||
|
|
||
| use async_channel::{Receiver, Sender}; | ||
|
|
||
| use bevy_app::{App, AppExit, AppLabel, Plugin, SubApp}; | ||
|
|
@@ -6,7 +8,7 @@ use bevy_ecs::{ | |
| schedule::MainThreadExecutor, | ||
| world::{Mut, World}, | ||
| }; | ||
| use bevy_tasks::ComputeTaskPool; | ||
| use bevy_tasks::{ComputeTaskPool, ThreadExecutor}; | ||
|
|
||
| use crate::RenderApp; | ||
|
|
||
|
|
@@ -23,18 +25,30 @@ pub struct RenderAppChannels { | |
| app_to_render_sender: Sender<SubApp>, | ||
| render_to_app_receiver: Receiver<SubApp>, | ||
| render_app_in_render_thread: bool, | ||
| /// Cloned from the main app's `MainThreadExecutor` resource. Pumped | ||
| /// in `Drop` while waiting for the render thread to return the | ||
| /// render app, so any main-thread tasks the render app's | ||
| /// `MultiThreadedExecutor` queued can complete instead of dead- | ||
| /// locking the shutdown path. | ||
| main_thread_executor: Arc<ThreadExecutor<'static>>, | ||
| } | ||
|
|
||
| impl RenderAppChannels { | ||
| /// Create a `RenderAppChannels` from a [`async_channel::Receiver`] and [`async_channel::Sender`] | ||
| /// Create a `RenderAppChannels` from a [`async_channel::Receiver`] and [`async_channel::Sender`]. | ||
| /// | ||
| /// `main_thread_executor` is the `MainThreadExecutor` shared with the render world; it is | ||
| /// pumped during shutdown so the render thread's in-flight `update()` can complete (its | ||
| /// `MultiThreadedExecutor` may have queued tasks that need to run on the main thread). | ||
| pub fn new( | ||
| app_to_render_sender: Sender<SubApp>, | ||
| render_to_app_receiver: Receiver<SubApp>, | ||
| main_thread_executor: Arc<ThreadExecutor<'static>>, | ||
| ) -> Self { | ||
| Self { | ||
| app_to_render_sender, | ||
| render_to_app_receiver, | ||
| render_app_in_render_thread: false, | ||
| main_thread_executor, | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -60,7 +74,21 @@ impl Drop for RenderAppChannels { | |
| // So on dropping the main world and ending the app, we block and wait for | ||
| // the render world to return to drop it. Which allows the non-send data | ||
| // drop methods to run on the correct thread. | ||
| self.render_to_app_receiver.recv_blocking().ok(); | ||
| // | ||
| // We use `scope_with_executor` and pump the `MainThreadExecutor` while waiting | ||
| // because the render thread's in-flight `update()` may have queued tasks that | ||
| // must run on the main thread (non-`Send` resources, `MainThreadExecutor`-routed | ||
| // work). A bare `recv_blocking` would park the main thread, the render thread | ||
| // would never see those tasks complete, and both threads would deadlock — see | ||
| // the regression test in `pipelined_rendering_shutdown_does_not_deadlock` and | ||
| // the issue this fixes. | ||
| ComputeTaskPool::get().scope_with_executor( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This can double panic since it's running arbitrary tasks. We will need to suppress the panic somehow if the scope panics. |
||
| true, | ||
| Some(&self.main_thread_executor), | ||
| |s| { | ||
| s.spawn(async { self.render_to_app_receiver.recv().await.ok() }); | ||
| }, | ||
| ); | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -138,12 +166,16 @@ impl Plugin for PipelinedRenderingPlugin { | |
| // clone main thread executor to render world | ||
| let executor = app.world().get_resource::<MainThreadExecutor>().unwrap(); | ||
| render_app.world_mut().insert_resource(executor.clone()); | ||
| // Stash an Arc clone for `RenderAppChannels::Drop` to pump while waiting for the | ||
| // render thread to return on shutdown. | ||
| let main_thread_executor = executor.0.clone(); | ||
|
|
||
| render_to_app_sender.send_blocking(render_app).unwrap(); | ||
|
|
||
| app.insert_resource(RenderAppChannels::new( | ||
| app_to_render_sender, | ||
| render_to_app_receiver, | ||
| main_thread_executor, | ||
| )); | ||
|
|
||
| std::thread::spawn(move || { | ||
|
|
@@ -179,6 +211,84 @@ impl Plugin for PipelinedRenderingPlugin { | |
| } | ||
| } | ||
|
|
||
| // `ThreadExecutor::spawn` (and the deadlock the test exercises) only exist | ||
| // in the `multi_threaded` build of `bevy_tasks`. Single-threaded builds use | ||
| // a `ThreadExecutor` stub with no `spawn` method, so this test is only | ||
| // meaningful — and only compiles — under that feature. | ||
| #[cfg(all(test, feature = "multi_threaded"))] | ||
| mod tests { | ||
| use super::*; | ||
| use bevy_app::SubApp; | ||
| use bevy_platform::future::block_on; | ||
| use bevy_platform::time::Instant; | ||
| use bevy_tasks::{ComputeTaskPool, TaskPool}; | ||
| use core::time::Duration; | ||
|
|
||
| /// Regression test for the shutdown deadlock fixed by pumping the | ||
| /// `MainThreadExecutor` inside `RenderAppChannels::Drop`. | ||
| /// | ||
| /// The "render thread" in this test mirrors the real one: it spawns a | ||
| /// task on the shared `MainThreadExecutor` and awaits it before sending | ||
| /// the `SubApp` back. Only the main thread can tick that executor | ||
| /// (because [`ThreadExecutor::ticker`] is pinned to the executor's | ||
| /// creation thread), so the task can only complete while the main | ||
| /// thread is pumping. A bare `recv_blocking` parks the main thread | ||
| /// without pumping, the render thread parks waiting for the task, and | ||
| /// neither side can make progress. Pre-fix this test hangs forever and | ||
| /// trips the timeout assertion at the bottom; post-fix it completes | ||
| /// quickly because `Drop` pumps via `scope_with_executor`. | ||
| #[test] | ||
| fn drop_pumps_main_thread_executor_to_avoid_shutdown_deadlock() { | ||
| ComputeTaskPool::get_or_init(TaskPool::new); | ||
|
|
||
| let main_thread_executor = MainThreadExecutor::new(); | ||
|
|
||
| let (app_to_render_sender, app_to_render_receiver) = async_channel::bounded::<SubApp>(1); | ||
| let (render_to_app_sender, render_to_app_receiver) = async_channel::bounded::<SubApp>(1); | ||
|
|
||
| let mut channels = RenderAppChannels::new( | ||
| app_to_render_sender, | ||
| render_to_app_receiver, | ||
| main_thread_executor.0.clone(), | ||
| ); | ||
|
|
||
| // Send a sentinel `SubApp` so `render_app_in_render_thread` is set | ||
| // and `Drop` actually takes the wait path under test. | ||
| channels.send_blocking(SubApp::new()); | ||
|
|
||
| // Spawn the "render thread". It mirrors the real render loop's | ||
| // failure mode: spawn a task on the main-thread executor and await | ||
| // it, which can only complete while the main thread is pumping | ||
| // that executor. | ||
| let render_main_thread_executor = main_thread_executor.0.clone(); | ||
| let render_thread = std::thread::spawn(move || { | ||
| let app = block_on(app_to_render_receiver.recv()) | ||
| .expect("app should be received from main thread"); | ||
| let task = (*render_main_thread_executor).spawn(async { 42_u32 }); | ||
| let result = block_on(task); | ||
| assert_eq!(result, 42); | ||
| render_to_app_sender | ||
| .send_blocking(app) | ||
| .expect("send back to main thread should succeed"); | ||
| }); | ||
|
|
||
| let start = Instant::now(); | ||
| drop(channels); | ||
| let elapsed = start.elapsed(); | ||
|
|
||
| render_thread | ||
| .join() | ||
| .expect("render thread should complete after drop pumps the executor"); | ||
|
|
||
| assert!( | ||
| elapsed < Duration::from_secs(10), | ||
| "RenderAppChannels::Drop took {elapsed:?} — the shutdown path likely \ | ||
| deadlocked because the main-thread executor was not pumped while \ | ||
| waiting for the render thread." | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| // This function waits for the rendering world to be received, | ||
| // runs extract, and then sends the rendering world back to the render thread. | ||
| fn renderer_extract(app_world: &mut World, _world: &mut World) { | ||
|
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can simplify/not need to bring Arc & ThreadExecutor in to this and can just directly use MainThreadExecutor.
I had just fixed this on one of my branches, in a nearly identical way -- see 4318f89
If you can just simplify the main_thread_executor part a little, this fix is solid.
For the test, it currently passes after the fix, but if the bug comes back it will hang rather than fail with the intended assertion. Can we structure it so the test thread waits on a bounded
recv_timeout, while the potentially-deadlockingdrop(channels)happens on another thread?Maybe we structure this as a bounded progress test instead of timing
drop(channels)after it returns?Something like:
MainThreadExecutor.drop(channels)on the synthetic main thread.donechannel withrecv_timeout.