So, I'm making an application that runs with a traditional game loop - update() then draw(), ~60fps.
I need a way to handle tasks which are triggered from the update() function, but which need to execute in the background without blocking the execution of update().
I'm thinking that at the beginning of update(), the program will handle any events by polling an mpsc::Receiver. These will include window events etc, but perhaps also custom messages which come from background threads signifying that one of these background tasks has been completed - not dissimilar from Iced's API.
Could I perhaps just have a thread pool of a few threads waiting for tasks, which are dispatched by sending 'impl Fn's through a channel, and when they complete, they send the result back through the main event channel?
Am I reinventing the wheel here? Does anybody know of any crates that implement this?
That use case sounds like an actor system. There are crates that can help create actors, but I personally find it trivial to build one out of channels and threads. The crates usually provide some niceties like supervisors, if you need the robustness.
Alice Rhyl has written a great introduction to the idea, using Tokio as the platform (but the concept applies to system threads as well): Actors with Tokio – Alice Ryhl
In my Minecraft clone I've implemented similar things to this. Overall it's gotten a lot of complexity to achieve lots of little optimizations and solve lots of little edge cases over the last couple years I've been working on this, so it's difficult to summarize all in one place
The game loop exists in the main thread in the winit event loop directly--it receives winit events directly (I preprocess them a little bit and translate them into handler function calls, but you don't have to).
At one point I experiemented with offloading the game loop to a different thread than the main thread with winit's event loop and tried sending window events over a channel (see my winit-main crate), but I ran into problem both with synchronizing the window event loop and with bugs that can occur trying to render from a different thread than the main thread / window thread.
For doing expensive tasks asynchronously, I have both:
A handle to a tokio runtime that I can spawn tasks onto, which is very nice for doing my network IO.
A custom made threadpool for more CPU-heavy tasks which supports multiple different priority levels of task queue--something tokio isn't designed for.
When a task finishes, it sends the result of it back to the main loop via winit user events. If you pair that with making sure that your main thread waits for the next tick not by sleeping but by telling winit to wait until then, that has the desirable property that it will wake your main loop back up when an event is received even if it's in a sleeping state.
As an optimization, instead of actually sending all these user events through winit's user event system directly, I put them into a concurrent queue, and then send a () user event to winit which signals to the main loop that it should poll the queue because it might contain new events. I use an atomic boolean to only send a () once until the main loop polls it again--if you go that route, be mindful of race conditions.
This also plays nicer with edge cases where the result of a job can become obsolete due to a change in game state since the job was enqueued.
Another thing: if I have the main loop poll for and process the results of jobs until there are no more available results, this can cause the game to freeze up if a ton of jobs finish all at once and it's relatively expensive for the game to process their results. So when such an event comes in, I tell it to check what the current time is before each attempt to poll an event from the queue and if it's already time to render the next tick it gives up on polling the events for now and leaves them for until after it's rendered another frame. However--there's an important caveat to that: if you do that naively, you could make it so that, if the scene is hard to render and thus renders at less than your target FPS no matter what you do, you could starve the main loop of time to ever process these jobs results at all. So I make sure to give it a minimum time of, say, 25% of the target duration between frame renders to process evens every frame, to achieve a nice balance even when it's under stress from both rendering and job result processing at the same time.