When you are separating a game engine into threads, then there are two main concerns:
- Each worker-thread should have roughly the same amount of work to do
- Avoid communication and data sharing between threads as much as possible, because those cause slowdowns at best and impossible to reproduce bugs at worst (race conditions and deadlocks).
For that reason it is usually not a good idea to separate threads by system. Different systems will have vastly different CPU load, and it's often hard to anticipate which ones will be the biggest resource hogs. So there is a pretty good chance that one CPU core gets to do all the work while the others idle most of the time. That's not how you make efficient use of modern multi-core CPUs. Also, threads often have a lot of shared data. Network and input are pushing events for the game mechanics and the game mechanics are pusing events for the audio, ui and network, while the rendering loop tries to visualize a world that is at the same time being changed by the game mechanics. Lots of shared data structures, which means lots of opportunities for race conditions and deadlocks.
A better approach to utilizing multiple cores is to parallelize on the task level. Look for computationally expensive tasks in your game and break them into "jobs". A "job" is an operation that runs once on a well-defined piece of data. You then distribute those jobs onto worker-threads and have certain sync-points where you wait for all running jobs to finish.
For example, let's say you have a terrain erosion mechanic in your game that is very CPU-intensive. How do you benefit from parallelization in this case?
- Separate the world into chunks.
- Create jobs to calculate the erosion for each of those chunks.
- Send those jobs to the worker threads
- Wait at a sync-point for the erosion-jobs to finish
- Write the changed data to the global world-state.
All of that happens each game tick.
Yes, you can run jobs in parallel that do vastly different things. This can improve performance by reducing sync-points and improving core utilization. But when you do that, you need to make sure that those parallel tasks don't share any data that gets changed at the same time.
Nevertheless, there are some systems that aren't really CPU-intense but still require their own threads due to real-time requirements. Those are often audio, input and networking. Although there are middlewares and libraries which can abstract those away. If you can use those, you should. But if you want to do them yourself (you are creating an own game engine in 2023, so you got to be really committed to the do-it-yourself mindset), then the usual way to communicate between those threads and the main game loop are event queues. When a network or input event occurs, that event gets pushed to a queue. At a controlled point of the main game loop, the main thread empties that queue. That queue needs to be thread-safe to avoid data corruption in case that an event gets pushed while the main thread empties it.
Do not react to such events immediately when they occur, because you don't know what the main thread is doing. It is very well possible that it currently reads data that gets written by the event handler, resulting in impossible to reproduce bugs.