feat: add JobSystem

This commit is contained in:
2026-10-24 00:59:22 +02:00
parent b798ed0378
commit 9c5a97cfbd
7 changed files with 502 additions and 1 deletions
+2 -1
View File
@@ -10,6 +10,7 @@ add_executable(destrum_tests
gameplay_tests.cpp
editor_tests.cpp
audio_tests.cpp
jobs_tests.cpp
${CMAKE_SOURCE_DIR}/lightkeeper/src/components/Activatable.cpp
${CMAKE_SOURCE_DIR}/lightkeeper/src/components/Interactable.cpp
${CMAKE_SOURCE_DIR}/lightkeeper/src/components/InteractableButton.cpp
@@ -38,6 +39,6 @@ target_link_libraries(destrum_tests PRIVATE destrum::destrum destrum::editor)
destrum_enable_warnings(destrum_tests)
destrum_copy_runtime_dlls(destrum_tests)
foreach(suite core serialization physics assets graphics ui gameplay editor audio)
foreach(suite core serialization physics assets graphics ui gameplay editor audio jobs)
add_test(NAME destrum_tests.${suite} COMMAND destrum_tests ${suite})
endforeach()
+167
View File
@@ -0,0 +1,167 @@
#include <atomic>
#include <chrono>
#include <stdexcept>
#include <thread>
#include <vector>
#include <destrum/Threading/JobSystem.h>
#include "TestUtils.h"
using namespace test;
namespace {
JobSystem& Jobs() { return JobSystem::GetInstance(); }
void testExecuteAndWait()
{
Jobs().Init(4);
JobCounter counter;
std::atomic<int> sum{0};
for (int i = 1; i <= 100; ++i) {
Jobs().Execute(counter, [&sum, i] { sum += i; });
}
Jobs().Wait(counter);
check(counter.IsDone(), "counter should be done after Wait");
check(sum == 5050, "every executed job should run before Wait returns");
Jobs().Shutdown();
}
void testDispatchCoversEveryIndexOnce()
{
Jobs().Init(4);
constexpr std::uint32_t count = 1003;
std::vector<std::atomic<int>> hits(count);
JobCounter counter;
Jobs().Dispatch(counter, count, 64, [&hits](JobDispatchArgs args) {
check(args.groupIndex == args.index / 64, "group index should match the index range");
++hits[args.index];
});
Jobs().Wait(counter);
for (const auto& hit : hits) {
check(hit == 1, "Dispatch should visit each index exactly once");
}
Jobs().Shutdown();
}
void testNestedWaitDoesNotDeadlock()
{
Jobs().Init(2);
JobCounter outer;
std::atomic<int> innerRuns{0};
for (int i = 0; i < 8; ++i) {
Jobs().Execute(outer, [&innerRuns] {
JobCounter inner;
for (int j = 0; j < 8; ++j) {
Jobs().Execute(inner, [&innerRuns] { ++innerRuns; });
}
Jobs().Wait(inner);
});
}
Jobs().Wait(outer);
check(innerRuns == 64, "nested jobs should all run");
Jobs().Shutdown();
}
void testBlockedLowJobDoesNotStallHigh()
{
Jobs().Init(2);
std::atomic<bool> release{false};
auto blocker = Jobs().Async([&release] {
while (!release) {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
return 7;
});
JobCounter counter;
std::atomic<int> runs{0};
Jobs().Dispatch(counter, 32, 4, [&runs](JobDispatchArgs) { ++runs; });
Jobs().Wait(counter);
check(runs == 32, "high jobs should finish while a low job is blocked");
release = true;
check(blocker.get() == 7, "Async should return the job result");
Jobs().Shutdown();
}
void testAsyncRethrows()
{
Jobs().Init(2);
auto future = Jobs().Async([]() -> int { throw std::runtime_error("boom"); });
bool threw = false;
try {
(void)future.get();
} catch (const std::runtime_error&) {
threw = true;
}
check(threw, "Async should rethrow the job's exception on get");
JobCounter counter;
Jobs().Execute(counter, [] { throw std::runtime_error("logged"); });
Jobs().Wait(counter);
check(counter.IsDone(), "a throwing job should still complete its counter");
Jobs().Shutdown();
}
void testMainThreadQueue()
{
Jobs().Init(2);
const auto mainId = std::this_thread::get_id();
std::vector<int> order;
bool onMain = true;
JobCounter counter;
Jobs().Execute(counter, [&] {
for (int i = 0; i < 5; ++i) {
Jobs().RunOnMainThread([&, i] {
order.push_back(i);
onMain = onMain && std::this_thread::get_id() == mainId;
});
}
});
Jobs().Wait(counter);
check(order.empty(), "main thread jobs should wait for the drain");
Jobs().DrainMainThreadQueue();
check(order == std::vector<int>{0, 1, 2, 3, 4}, "main thread jobs should run in submission order");
check(onMain, "main thread jobs should run on the draining thread");
Jobs().Shutdown();
}
void testShutdownDrainsPendingJobs()
{
Jobs().Init(1);
std::atomic<int> runs{0};
std::vector<std::future<void>> futures;
for (int i = 0; i < 50; ++i) {
futures.push_back(Jobs().Async([&runs] { ++runs; }));
}
Jobs().Shutdown();
Jobs().Shutdown();
check(runs == 50, "Shutdown should finish queued jobs");
check(!Jobs().IsRunning(), "job system should be stopped");
}
void testRunsInlineWhenStopped()
{
check(!Jobs().IsRunning(), "job system should start stopped");
JobCounter counter;
int value = 0;
Jobs().Execute(counter, [&value] { value = 3; });
check(value == 3 && counter.IsDone(), "jobs should run inline without workers");
check(Jobs().Async([] { return 5; }).get() == 5, "Async should run inline without workers");
}
}
void RunJobsTests()
{
testRunsInlineWhenStopped();
testExecuteAndWait();
testDispatchCoversEveryIndexOnce();
testNestedWaitDoesNotDeadlock();
testBlockedLowJobDoesNotStallHigh();
testAsyncRethrows();
testMainThreadQueue();
testShutdownDrainsPendingJobs();
}
+2
View File
@@ -11,6 +11,7 @@ void RunUiTests();
void RunGameplayTests();
void RunEditorTests();
void RunAudioTests();
void RunJobsTests();
namespace {
struct Suite {
@@ -28,6 +29,7 @@ namespace {
{"gameplay", RunGameplayTests},
{"editor", RunEditorTests},
{"audio", RunAudioTests},
{"jobs", RunJobsTests},
};
}