Skip to content
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

Global Task Pools #54

Merged
merged 1 commit into from
May 30, 2022
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions rfcs/54-global-task-pools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Feature Name: `global-task-pools`

## Summary
Globally initialize the newtyped TaskPools (ComputeTaskPool,
AsyncComputeTaskPool, IoTaskPool) as statics and provide static accessors to
them.

## Motivation
Bevy's TaskPools are currently passed around as ref-counted shared references,
and accessed primarily as ECS resources. This presents an ergonomics challenge
when integrating third-party crates without direct access to the ECS systems or
World. An example of this [Backroll's use of the TaskPools][backroll]
where the references must be passed and clone repeatedly.

This also presents an ergonomics issue for existing Bevy APIs, like
`par_for_each(_mut)`. All internally parallel systems must remember to add
a `Res<ComputeTaskPool>` parameter. This also presents a (rather small) footgun
for new users as to which task pool resource they should use, and choosing the
wrong one will give the wrong performance characteristics due to improper thread
allocation, or higher thread contention from the threads already in the wrong
pool.

Multitenant projects (processes with multiple Bevy apps running concurrently)
currently spin up new task pools for every top-level App in the process. The
number of threads per CPU core scales with the number of Apps in the process,
which may cause OS level thrashing from the context switches. This is most
commonly seen in server environments where processes may spin up and spin down
Apps in response to players starting and ending multiplayer matches.

## User-facing explanation
Instead of being publicly constructable, the newtyped TaskPools will become
static singletons, initialized on their first access.

Users can get a static reference to a task pool via the `get` associated
function (i.e. `ComputeTaskPool::get`), which will initialize the TaskPool with
the default configuration if it hasn't already been initialized.

For more explicit control over initialization, a corresponding `init` associated
function will take a task pool configuration and initialize the task pool, or
panic if the pool is already initialized. By convention, libraries, including
Bevy itself, should just use `get` and defer configuration to the final end
user's configuration.

## Implementation strategy
Prototype implementation: https://github.com/bevyengine/bevy/pull/2250

This implementation is rather simple and can be broken down into the following
smaller tasks:

- The public construction of the newtyped TaskPools should be disabled/removed.
- New static variables holding [`OnceCell`][once-cell] wrappers around each
task pool will be added.
- Move the default task pool initialization configuration from `bevy_core` into
`bevy_tasks`.
- Implement `get` and `init` functions as described above for each type.
- Replace `Res<*TaskPool>` accesses with `*TaskPool::get` calls.
- Optional: Remove the internal `Arc`s as the task pools to remove one level of
indirection.
- Optional: Remove the `TaskPool` parameters from `par_for_each(_mut)` on Query

## Drawbacks
Reconfiguring the task pools is now impossible under this initial design.
Calling `init` a second time will panic. This could be remedied by
moving reconfiguration into the TaskPool itself. The current way of
reconfiguring the task pools is to construct a new task pool and replace the
resource. However, this does not properly clean up the TaskPools if they
internally are self-referential: a long-lived, or indefinitely looping scheduled
task that contains a reference to the same task pool will keep the TaskPool
alive until all tasks terminate. Moving this reconfiguration as a first class
operation on TaskPool may be required to support these use cases.

Multitenant processes now share the same threads for all apps. Highly active or
long running apps may starve out others. Whether or not this is preferable over
OS-level thread thrashing is likely use case dependent.

## Rationale and alternatives
This is a significant improvement in terms of ergonomics over the status quo.
This API design removes the need to have a task pool parameter on systems,
`Query`, and `QueryState` types. It also removes integration friction for third
party crates that aren't directly integrating with the ECS state.

### Alternative: One Global Task Pool, Enum Tasks
This alternative proposes to remove the newtyped task pools entirely and just
have one global task pool instead. To get the same scheduling guarantee, tasks
would internally state which of the three task types it is. This would allow the
executors to independently schedule and prioritize tasks as they currently are,
but without forcing the user to make that decision, or at least allow users to
choose a common default.

This is still technically possible, but would require much more effort to
implement.

## Prior art
Other async executors also provide global versions of their executors:

- [tokio][tokio]'s runtime is global by default
- [async-global-executor][async-global-executor]

Other language's async runtimes also largely use globals as well:

- Python's [asyncio event loop][asyncio-event-loop] is thread-local. Fetched
via `asyncio.get_event_loop()`.
- C#'s [managed-thread-pool][csharp-pool] is used for it's async runtime
- Go spins up a 1:1 threadpool that all of it's async goroutines run on,
scheduling is global and requires no reference.

Other game engines have a built in global threadpool used for their form of
multithreading:

- Unity has dedicated threads for rendering, audio, and a dedicated worker
thread pool for running multihreaded jobs.
- Unreal: ???
- Godot: ???

[backroll]: https://github.com/HouraiTeahouse/backroll-rs/blob/219f4b6fda27250a7c4f7928a381418faebd5544/backroll/src/backend/p2p.rs
[once-cell]: https://docs.rs/once_cell/latest/once_cell/sync/struct.OnceCell.html
[asyncio-event-loop]: https://docs.python.org/3/library/asyncio-eventloop.html
[csharp-pool]: https://docs.microsoft.com/en-us/dotnet/standard/threading/the-managed-thread-pool