Using the JobSystem in Raftel Engine
This tutorial introduces Raftel Engine's JobSystem, a multi-threaded task execution system that helps you improve performance by distributing work across multiple CPU cores.
What is JobSystem?
The JobSystem is a thread pool implementation that allows you to execute tasks concurrently across multiple CPU cores. It's particularly useful for:
- Loading assets in the background while maintaining smooth rendering
- Performing computationally intensive operations without freezing the UI
- Processing multiple independent tasks simultaneously
- Utilizing all available CPU cores for better performance
Thread Safety Note
When using the JobSystem, be careful with tasks that modify shared resources. Use proper synchronization mechanisms (mutexes, atomic variables) to avoid data races.
Basic Usage
Using the JobSystem involves three simple steps:
#include "raftel/job_system.hpp"
int main() {
// Step 1: Create the JobSystem
auto jobSystem = Raftel::JobSystem::make();
// Step 2: Submit work to the JobSystem
auto futureResult = jobSystem->AddWork([](int a, int b) {
return a + b;
}, 5, 3);
// Step 3: Get the result when needed
int result = futureResult.get(); // This will be 8
return 0;
}
Creating the JobSystem
To start using the JobSystem, you first need to create an instance:
#include "raftel/job_system.hpp"
// Create the JobSystem
auto jobSystem = Raftel::JobSystem::make();
The JobSystem creates a thread pool with one thread per CPU core by default, maximizing your potential for parallel execution.
Submitting Tasks
You can submit tasks to the JobSystem using the AddWork
method. This method accepts a function (or lambda) and its arguments:
Simple Task
// Submit a simple task
auto future = jobSystem->AddWork([]() {
return "Task completed";
});
Task with Parameters
// Submit a task with parameters
auto future = jobSystem->AddWork([](int a, int b) {
return a * b;
}, 10, 20);
Task with Member Function
// Submit a task that calls a member function
auto future = jobSystem->AddWork(&MyClass::compute,
myObject, param1, param2);
Void Task (No Return Value)
// Submit a task with no return value
auto future = jobSystem->AddWork([](const std::string& msg) {
std::cout << msg << std::endl;
}, "Processing...");
Understanding Return Values
The AddWork
method returns a std::future
object that will eventually contain the result of the task. The future's template type will match the return type of your function.
Getting Results
Once you've submitted a task, you can get its result using the get()
method of the returned future:
// Submit a calculation
auto futureResult = jobSystem->AddWork([](int a, int b) {
return a + b;
}, 5, 3);
// Do other work here...
// Get the result when needed
int result = futureResult.get(); // This will block until the result is ready
The get()
method will block the calling thread until the result is available. If you want to check if the result is ready without blocking, you can use wait_for()
instead:
// Check if the result is ready without blocking
if (futureResult.wait_for(std::chrono::seconds(0)) == std::future_status::ready) {
// Result is ready
int result = futureResult.get();
// Use the result...
} else {
// Result is not ready yet
// Continue with other work...
}
Practical Example: Asynchronous Mesh Loading
A common use case for the JobSystem is loading assets in the background to avoid freezing the rendering loop. Here's an example of asynchronously loading a 3D mesh:
#include "raftel/window.hpp"
#include "raftel/mesh.hpp"
#include "raftel/shader.hpp"
#include "raftel/job_system.hpp"
#include "raftel/input.hpp"
int main(void) {
auto windowSystemOpt = Raftel::WindowSystem::make();
auto windowOpt = Raftel::Window::make("Job System Demo", *windowSystemOpt);
auto jobSystem = Raftel::JobSystem::make();
if (!windowOpt) {
std::cerr << "Error creating window.\n";
return -1;
}
windowOpt->MakeContextCurrent();
// Define mesh paths
std::vector meshPaths = {
"../assets/obj/luffytrial.obj",
"../assets/obj/cube.obj",
"../assets/obj/sword.obj",
};
size_t currentMeshIndex = 0;
// Load initial mesh
Raftel::Mesh myModel;
Raftel::ShaderProgram shaderProgram;
if (!myModel.loadMesh(meshPaths[currentMeshIndex])) {
std::cerr << "Error loading initial mesh.\n";
return -1;
}
// Set up texture and material
auto texture = Raftel::Texture::loadTexture("../assets/textures/luffytextrial.png");
auto material = std::make_shared();
material->setAlbedo(texture);
myModel.setMaterial(material);
myModel.setupMesh();
// Load shader
if (!shaderProgram.load("../assets/shaders/test.vs", "../assets/shaders/test.fs")) {
std::cerr << "Error loading shaders.\n";
return -1;
}
// Variables for async loading
bool isLoading = false;
std::future> futureMesh;
// Main render loop
while (!windowOpt->ShouldClose()) {
windowOpt->input->updateKeys();
windowOpt->clear();
// Check for mesh switch request
if (windowOpt->input->isKeyPressed(Raftel::Input::Keys::Key_Space) && !isLoading) {
isLoading = true;
// Choose next mesh index
size_t nextMeshIndex = (currentMeshIndex + 1) % meshPaths.size();
// Submit mesh loading job to JobSystem
futureMesh = jobSystem->AddWork([&meshPaths, nextMeshIndex]() {
auto tempModel = std::make_shared();
if (!tempModel->loadMesh(meshPaths[nextMeshIndex])) {
std::cerr << "Error loading mesh: " << meshPaths[nextMeshIndex] << "\n";
}
return tempModel;
});
currentMeshIndex = nextMeshIndex;
}
// Check if async loading is complete
if (futureMesh.valid()) {
if (futureMesh.wait_for(std::chrono::seconds(0)) == std::future_status::ready) {
// Mesh loading is complete
auto result = futureMesh.get();
result->setupMesh();
myModel = std::move(*result);
isLoading = false;
}
}
// Render current mesh
shaderProgram.use();
myModel.setUniforms(shaderProgram.GetProgramID());
myModel.draw(shaderProgram);
windowOpt->swapBuffers();
}
return 0;
}
In this example:
- We load an initial mesh synchronously at startup
- When the user presses Space, we submit a job to load the next mesh in the background
- We continue rendering the current mesh while the new one loads
- We check each frame if the loading job has completed
- When the job completes, we apply the new mesh
This pattern keeps the application responsive while performing intensive operations like mesh loading.
Best Practices
Task Granularity
Create tasks that are substantial enough to offset the overhead of thread switching. Very small tasks may not benefit from parallelization.
Avoid Thread Contention
Design tasks to work on independent data to minimize synchronization needs and maximize parallelism.
Thread Safety
Use proper synchronization (mutexes, atomic variables) when tasks need to access shared resources.
Futures Management
Always check if a future is valid before calling get()
or wait_for()
to avoid undefined behavior.
When to Use JobSystem
The JobSystem is best used for:
- Asset Loading: Loading meshes, textures, or sounds in the background
- Physics Calculations: Distributing physics updates across multiple cores
- AI Processing: Running AI algorithms for multiple entities in parallel
- Data Processing: Processing large amounts of data in parallel chunks
- Procedural Generation: Generating terrain, textures, or other content in the background
GPU Operations
Remember that the JobSystem runs tasks on CPU threads. For GPU-bound operations, parallelizing with JobSystem may not improve performance since the bottleneck is the GPU, not the CPU.
Complete API Reference
The JobSystem API is simple but powerful:
namespace Raftel {
class JobSystem {
public:
// Create a JobSystem instance
static std::unique_ptr make();
// Add work to the JobSystem
template
auto AddWork(Function&& function, Args&&... args)
-> std::future;
};
}
For complete details, see the JobSystem API Reference.