Repository navigation
Job System
SparkEngine provides a lightweight, thread-safe job system built on a fixed-size thread pool. The Spark::JobSystem class lives in SparkEngine/Source/Utils/JobSystem.h and is the primary mechanism for parallelizing CPU-bound work across the engine.
The job system is a singleton that manages a pool of worker threads. Workers pull jobs from a shared FIFO queue and execute them. Any callable (lambdas, function pointers, std::bind results) can be submitted as a job, and callers receive a std::future for retrieving results or detecting completion.
Key characteristics:
- Fixed-size thread pool created once at engine startup
-
std::mutex+std::condition_variablefor queue synchronization -
std::packaged_task+std::futurefor result propagation -
Automatic batch sizing in
ParallelForbased on worker count - Inline fallback when the pool has no workers or the workload is too small
- Non-copyable singleton with RAII shutdown in the destructor
+--------------------------+
| Main Thread |
| |
| Submit() / ParallelFor |
+--------|-----------|-----+
| |
v v
+------------------------+
| Job Queue |
| (mutex + cond_var) |
+--|------|------|-------+
| | |
+----v+ +---v-+ +--v---+
| W0 | | W1 | | W2 | ... (worker threads)
+-----+ +-----+ +------+
SparkEngine/Source/Utils/JobSystem.h
Namespace: Spark
The job system must be initialized before any subsystem submits work. SparkEngine handles this automatically during engine startup via EngineSetup::InitializeJobSystem(), which is called after core subsystem registration and before any system that uses parallel processing (AI, perception, parallel system executor).
The engine bootstrap system registers the JobSystem as a named subsystem with dependency tracking. It is initialized before the AI subsystem and any other subsystem that declares a dependency on it:
// From EngineSetup.h — the engine does this for you
bootstrap.Register({"JobSystem",
[]() {
auto& js = Spark::JobSystem::Get();
if (!js.IsInitialized())
js.Initialize(0);
return true;
},
[]() {
auto& js = Spark::JobSystem::Get();
js.Shutdown();
},
{}});
// AI declares a dependency on JobSystem
bootstrap.Register({"AI", []() { return true; }, []() {}, {"Timer", "Physics", "JobSystem"}});If you need to control the thread count explicitly (for example, in a tool or test harness), call Initialize directly:
auto& jobs = Spark::JobSystem::Get();
jobs.Initialize(4); // 4 worker threads
// ... use the job system ...
jobs.Shutdown(); // blocks until all pending jobs drain, then joins threadsPassing 0 (the default) creates std::thread::hardware_concurrency() - 1 workers, reserving one core for the main thread. Calling Initialize on an already-initialized instance is a no-op.
Shutdown() sets the stop flag, notifies all waiting workers, and blocks until every worker thread has completed its current job and exited. Any jobs still in the queue at the time of shutdown are executed before threads exit. The destructor calls Shutdown() automatically, so the system is safe even if you forget to shut down explicitly.
| Method | Description |
|---|---|
static JobSystem& Get() |
Returns the singleton instance (Meyers singleton, thread-safe) |
| Method | Description |
|---|---|
void Initialize(uint32_t numThreads = 0) |
Create the worker thread pool. 0 means hardware_concurrency - 1. No-op if already initialized. |
void Shutdown() |
Signal stop, drain the queue, join all threads. Blocks until complete. No-op if not initialized. |
bool IsInitialized() const |
Returns true if the thread pool is running |
| Method | Description |
|---|---|
auto Submit(F&& f, Args&&... args) -> std::future<R> |
Submit any callable. Returns a future holding the result type R = std::invoke_result_t<F, Args...>. |
void ParallelFor(int begin, int end, Func&& body, int minBatchSize = 1) |
Split [begin, end) into chunks, submit each as a job, block until all complete. |
void WaitForAll() |
Submit a barrier job and block until the queue is drained up to that point. |
| Method | Description |
|---|---|
uint32_t GetWorkerCount() const |
Number of worker threads in the pool |
size_t GetPendingJobCount() const |
Current number of jobs waiting in the queue (snapshot) |
When you do not need the result and just want work to happen asynchronously, submit a lambda and discard the future:
auto& jobs = Spark::JobSystem::Get();
// The future is discarded — the job runs in the background
jobs.Submit([]
{
RegenerateLightProbes();
});Caution: If the fire-and-forget job captures references or pointers, you must ensure the referenced data outlives the job. Use WaitForAll() or Shutdown() before destroying shared state.
Submit a job that returns a value and retrieve it later via the future:
auto& jobs = Spark::JobSystem::Get();
// Submit a computation that returns a value
auto future = jobs.Submit([]() -> float
{
return ComputePathCost(startPos, endPos);
});
// Do other work on the main thread while the job runs...
UpdateUI();
// Block until the result is ready
float pathCost = future.get();You can also submit jobs with arguments:
auto future = jobs.Submit([](int a, int b) { return a + b; }, 10, 20);
int result = future.get(); // 30ParallelFor is the most common pattern for data-parallel workloads. It splits a range into batches, submits each batch as a separate job, and blocks until all batches complete:
auto& jobs = Spark::JobSystem::Get();
std::vector<Entity> entities = GetVisibleEntities();
std::vector<float> distances(entities.size());
jobs.ParallelFor(0, static_cast<int>(entities.size()),
[&](int i)
{
distances[i] = ComputeDistanceToCamera(entities[i]);
});
// All distances are computed by this pointThe minBatchSize parameter (default 1) controls the minimum number of items per batch. For very cheap per-item work, increase this to reduce job submission overhead:
// Each item is trivial — use a larger batch to amortize scheduling cost
jobs.ParallelFor(0, 100000,
[&](int i) { output[i] = input[i] * 2.0f; },
256); // at least 256 items per batchThe actual batch size is computed as max(minBatchSize, totalItems / (numWorkers + 1)), which distributes work evenly across all workers plus the submitting thread.
If the job system has no workers (not initialized, or initialized with 0 workers) or the total range is smaller than minBatchSize, ParallelFor executes the loop inline on the calling thread. This means code using ParallelFor works correctly even when the job system is disabled or in single-threaded test environments.
For heterogeneous parallel work where different jobs have different return types or logic:
auto& jobs = Spark::JobSystem::Get();
auto meshFuture = jobs.Submit([] { return LoadMesh("soldier.fbx"); });
auto texFuture = jobs.Submit([] { return LoadTexture("soldier_diffuse.dds"); });
auto navFuture = jobs.Submit([] { return BakeNavMeshRegion(region); });
// All three load in parallel on different worker threads
auto mesh = meshFuture.get();
auto texture = texFuture.get();
auto navData = navFuture.get();The AI system uses ParallelFor to tick behavior trees and update steering for all live agents in parallel. Each agent's perception, behavior tree tick, and movement computation are independent, making this an ideal parallelization target:
// From AISystem.cpp
auto& jobSystem = Spark::JobSystem::Get();
jobSystem.ParallelFor(
0, agentCount,
[&](int i)
{
auto& agent = liveAgents[i];
UpdatePerception(agent, world, deltaTime);
TickBehaviorTree(agent, deltaTime);
ApplySteering(agent, deltaTime);
});The ParallelPerceptionSystem dispatches per-agent perception queries across worker threads. It follows a three-phase pattern that avoids ECS access from worker threads:
- BuildSpatialIndex (main thread) -- Insert perceivable entities into an Octree.
-
GatherAgentData (main thread) -- Snapshot per-agent state into
AgentPerceptionJobstructs. -
UpdateAllAgents (parallel) -- Dispatch
JobSystem::ParallelForover the agent array; each worker queries the Octree and runs sight/hearing checks.
This pattern is important: ECS reads/writes happen only on the main thread (phases 1 and 2), while the parallel phase (3) operates exclusively on pre-gathered, thread-local data structures.
// From ParallelPerception.cpp
auto& jobSystem = JobSystem::Get();
const int agentCount = static_cast<int>(m_agentJobs.size());
if (jobSystem.IsInitialized() && agentCount >= m_minBatchSize)
{
jobSystem.ParallelFor(0, agentCount, [&](int i)
{
ProcessAgentPerception(m_agentJobs[i], m_octree, currentTime);
});
}The ParallelSystemExecutor (in SparkEngine/Source/Engine/ECS/Systems/ParallelSystemExecutor.h) analyzes component read/write sets per system and groups non-conflicting systems into parallel batches. Each batch runs its systems concurrently on the JobSystem thread pool, while batches execute sequentially to preserve data dependencies:
ParallelSystemExecutor executor;
// Declare component access patterns
executor.DeclareAccess<PhysicsUpdateSystem>({typeid(Transform)}, {typeid(RigidBodyComponent)});
executor.DeclareAccess<AIUpdateSystem>({typeid(Transform)}, {typeid(AIComponent)});
executor.DeclareAccess<AudioUpdateSystem>({typeid(Transform)}, {}); // read-only
// Build schedule (determines which systems can run in parallel)
executor.BuildSchedule();
// Per frame: batches run sequentially, systems within a batch run in parallel
executor.Execute(world, deltaTime);Systems within a batch are submitted via JobSystem::Submit and collected with futures:
Batch 0: [PhysicsUpdateSystem, AudioUpdateSystem] -- run in parallel
Batch 1: [AIUpdateSystem] -- runs after batch 0
The JobSystem class is fully thread-safe. Submit, ParallelFor, WaitForAll, and all diagnostic methods can be called from any thread.
However, the jobs themselves must be written with thread safety in mind:
| Rule | Rationale |
|---|---|
| Do not access the ECS registry from worker threads | EnTT is not thread-safe for concurrent mutation. Gather data on the main thread first. |
Do not call PhysicsSystem methods from worker threads |
Jolt Physics supports multithreaded job dispatch, but direct API calls should be synchronized. |
| Avoid shared mutable state without synchronization | Standard data-race rules apply. |
| Prefer per-index output arrays over shared containers |
ParallelFor with per-index writes to a pre-allocated array is data-race free. |
Use WaitForAll() before reading results from fire-and-forget jobs |
Without the future, there is no other synchronization point. |
Keep GraphicsEngine calls on the main thread |
GPU submission is single-threaded; only std::atomic frame state is safe cross-thread. |
-
Job granularity: Each
Submitcall allocates astd::packaged_taskand acquires the queue mutex. For very fine-grained work (nanosecond-scale operations), preferParallelForwith a largerminBatchSizeover many individualSubmitcalls. -
Worker count: The default (
hardware_concurrency - 1) is generally optimal. Over-subscribing the CPU with more workers than cores causes context-switch overhead. - Queue contention: The single shared queue with a single mutex is adequate for moderate submission rates. Extremely high-frequency submission (thousands of micro-jobs per frame) may benefit from per-thread work-stealing queues in a future iteration.
-
Profiling: Use the
Profilerto measure the wall-clock time ofParallelForcalls and compare against the serial baseline. Parallelization only helps when per-item work significantly exceeds the scheduling overhead.
Query the job system state at runtime via console commands or debug overlays:
auto& js = Spark::JobSystem::Get();
std::stringstream ss;
ss << "JobSystem: " << (js.IsInitialized() ? "YES" : "NO");
if (js.IsInitialized())
{
ss << " (" << js.GetWorkerCount() << " workers)";
ss << " pending: " << js.GetPendingJobCount();
}The engine's engine.status console command includes JobSystem status automatically, reporting whether it is initialized and how many worker threads are active.
For the ParallelSystemExecutor, use Console_GetScheduleInfo() to print the parallel batch layout and verify that systems are grouped as expected.
The Spark::ThreadSafeQueue<T> template (in SparkEngine/Source/Utils/ThreadSafeQueue.h) provides a mutex-guarded bounded FIFO queue used by several subsystems alongside the JobSystem:
- NetworkManager: message I/O buffering
- AudioEngine: command buffering from game thread to audio thread
While JobSystem uses a raw std::queue internally with its own mutex, ThreadSafeQueue is available for your own producer/consumer patterns between the main thread and worker threads.
- No job dependencies or continuations: Jobs cannot declare "run after job X completes." Use futures or manual synchronization for ordering.
- No priority queues: All jobs are FIFO. High-priority work cannot preempt queued low-priority work.
- No work stealing: Workers are bound to a single shared queue. A per-thread deque with work stealing would improve cache locality for fine-grained workloads.
- No main-thread dispatch: There is no mechanism to schedule a callback back onto the main thread from a worker. Results must be polled via futures.
These limitations are documented in the Codebase Health wiki page under architectural considerations.
| File | Description |
|---|---|
SparkEngine/Source/Utils/JobSystem.h |
JobSystem singleton (header-only) |
SparkEngine/Source/Utils/ThreadSafeQueue.h |
Companion thread-safe queue template |
SparkEngine/Source/Core/EngineSetup.h |
Bootstrap registration and InitializeJobSystem()
|
SparkEngine/Source/Engine/ECS/Systems/ParallelSystemExecutor.h |
Parallel ECS system batching |
SparkEngine/Source/Engine/AI/AISystem.cpp |
AI parallel agent updates |
SparkEngine/Source/Engine/AI/ParallelPerception.h |
Parallel perception with Octree |
SparkEngine/Source/Engine/AI/ParallelPerception.cpp |
Parallel perception implementation |
- Architecture Overview -- Engine architecture, subsystem layout, and key patterns
-
Entity Component System -- ECS architecture, components, systems, and the
ParallelSystemExecutor - AI and Navigation -- Behavior trees, NavMesh, and parallel perception
- Testing -- Unit tests and CTest integration
- Profiler and Debugging -- Profiler, debug overlays, and performance measurement
Published from 2b03dc797148. Edit the canonical source in wiki/.
- Documentation
- Docs route
- Wiki index
- Guides
- Tutorials
- Samples
- Examples
- API Reference
- API route
- Reference
- Build Guide
- Dependencies
- FAQ
- Changelog
- Roadmap
- Contributing
- Code of Conduct
- Home
- FAQ
- Getting Started
- Quick-Start Tutorial
- Making Your First Game
- Making Your First Multiplayer Game
- Artist Workflow Guide
- Editor Walkthrough
- Migration Guide
- How SparkEngine Works
- Architecture Overview
- Engine Architecture Flowchart
- Creating a Game Module
- Game Modules (catalog)
- Entity Component System
- Rendering and Graphics
- Physics
- Cloth Simulation
- Audio
- Input System
- Camera System
- Scripting with AngelScript
- Visual Scripting
- AI and Navigation
- Animation
- 2D Systems
- Networking
- Dedicated Server
- Multiplayer Quick Start
- Area Server Architecture
- Scene Management
- Large World Support
- Collaborative Editing
- Coroutine System
- Event System
- Event Response System
- Job System
- UI System
- UI Layout Extensions
- Localization
- Dialogue System
- Destruction System
- Replay System
- Achievement System
- Loading System
- Mod System
- Content Delivery
- Tween System
- Memory Integrity
- Gameplay Systems
- Terrain and Procedural Generation
- Save System
- Persistence System
- Day Night Cycle and Weather
- Cinematic Sequencer
- Runtime Prefabs
- SparkEditor
- Editor Tutorials
- SparkConsole
- SparkDaemon
- Shader Pipeline
- Asset Pipeline
- Asset Validation
- Asset Migration
- Game Packaging
- Online Services
- DataTable System
- Loot and Crafting System
- CSG System
- Font System
- Timer Manager
- Movie Render Pipeline
- HLOD and World Partition
- Remote Debug System
- Selection Manager
- Asset Dependency Graph
- Editor Automation
- File Watcher
- Project Templates
- System Requirements
- VR Support
- Mobile Platform
- Accessibility
- Platform Input
- Platform Certification
- Cross-Compilation: Wine Testing
- RHI Abstraction Layer
- D3D11 Backend
- D3D12 Backend
- Vulkan Backend
- OpenGL Backend
- Metal Backend
- DXR Raytracing
- Hybrid Ray Tracing
- Upscaling (DLSS/FSR)
- Render Graph
- Shader Graph
- GPU Particles
- GPU-Driven Rendering
- Volumetric Fog
- Volumetric Clouds
- Global Illumination
- Virtual Texturing
- Water Rendering
- Clustered Lighting
- Material System
- Post-Processing
- Shadow System
- Particle System
- Decal System
- Sky and Atmosphere
- Foliage System
- Mesh Shaders
- Neural Rendering
- Configuration Reference
- Performance Tips
- Benchmark Framework
- Threading Model
- Fuzz Policy and Parser Security
- Memory Safety
- Memory Management Patterns
- Build System and CMake Modules
- Profiler and Debugging
- Performance Profiling Guide
- Telemetry System
- Crash Reporting
- Golden Image Testing
- Utilities
- Testing
- Fuzz Policy and Parser Security
- Codebase Statistics
- Codebase Health
- Error Handling Patterns
- Hot Reload Overview
- Troubleshooting
- Contributing
- Workflow Patterns
- Build Optimizations
- CI Reproducible Builds
- GitHub API and PR Checks
- Git Rebase Conflicts
- Clang-Format
- Code Quality Violations
- AI Bloat Pattern
- MinGW + Wine Cross-Compilation
- Live Editor Testing
- Engine & Renderer Landscape
- DuetOS Portability Catalog
- Five-Engine Analysis
- Eleven-Engine Analysis
- ThorVG / Unity Graphics Analysis
- Advanced Techniques Catalog
- Third-Party Library Evaluation
- Engine Viability Evaluation
- Engine Feature Recommendations
- Project Recommendations
- Mac Compatibility Analysis
- Codebase Observations
- Codebase Bloat Audit
- Test Suite Audit
- Documentation Coverage Audit
- ThirdParty Dependencies Audit
- Load Test Baseline
- Gameplay Systems Status
- SparkGame Module Status
- Stub and Abandoned Features
- Memory Integrity System
- Memory Safety Evaluation
- Hardware Acceleration Systems
- Jolt Physics Integration
- GPU/CPU Separation Plan
- Daemon Services Architecture
- Reflection & Polymorphism Refactoring Plan
- SparkBuild In-Tree
- Wine No-JobSystem Breakthrough
- Wine Role and Fallback Tiers