Files
EVEngine/test/thread.cpp
T

681 lines
23 KiB
C++

#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "common/Exception.h"
#include "platform_event/PlatformEvent.h"
#include "thread/Channel.h"
#include "thread/JobSystem.h"
#include "thread/Task.h"
#include "thread/Thread.h"
#include "thread/ThreadPool.h"
#include <atomic>
#include <chrono>
#include <functional>
#include <memory>
#include <string>
#include <thread>
#include <vector>
namespace {
eve::thread::Thread *threadModule() { return eve::thread::Thread::create(); }
bool expectException(const std::function<void()> &fn) {
try {
fn();
} catch (const eve::Exception &) {
return true;
}
return false;
}
} // namespace
TEST_CASE("thread.create") {
auto *mod = threadModule();
REQUIRE(mod != nullptr);
CHECK_EQ(mod->getName(), std::string("Thread"));
CHECK(mod->getHardwareConcurrency() >= 1);
}
TEST_CASE("thread.channel.pushPop") {
std::unique_ptr<eve::thread::Channel> ch(threadModule()->newChannel());
REQUIRE(ch.get() != nullptr);
CHECK(!ch->hasData());
CHECK_EQ(ch->getCount(), 0);
std::string empty = ch->pop();
CHECK_EQ(empty, std::string(""));
ch->push("a");
ch->push("b");
CHECK(ch->hasData());
CHECK_EQ(ch->getCount(), 2);
std::string first = ch->pop();
std::string second = ch->pop();
CHECK_EQ(first, std::string("a"));
CHECK_EQ(second, std::string("b"));
CHECK(!ch->hasData());
}
TEST_CASE("thread.channel.namedShared") {
auto *mod = threadModule();
auto *a = mod->getChannel("results");
auto *b = mod->getChannel("results");
REQUIRE(a != nullptr);
CHECK_EQ(a, b);
CHECK_EQ(a->getName(), std::string("results"));
a->clear();
a->push("ok");
std::string got = b->pop();
CHECK_EQ(got, std::string("ok"));
CHECK(expectException([&] { mod->getChannel(""); }));
}
TEST_CASE("thread.channel.supplyTimeout") {
std::unique_ptr<eve::thread::Channel> ch(threadModule()->newChannel());
auto t0 = std::chrono::steady_clock::now();
std::string got = ch->supply(30);
CHECK_EQ(got, std::string(""));
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - t0)
.count();
CHECK(ms >= 20);
}
TEST_CASE("thread.channel.crossThread") {
std::unique_ptr<eve::thread::Channel> ch(threadModule()->newChannel());
std::thread producer([&] {
std::this_thread::sleep_for(std::chrono::milliseconds(20));
ch->push("from-worker");
});
std::string got = ch->supply(1000);
producer.join();
CHECK_EQ(got, std::string("from-worker"));
}
TEST_CASE("thread.channel.demandBlocksUntilValue") {
std::unique_ptr<eve::thread::Channel> ch(threadModule()->newChannel());
std::thread producer([&] {
std::this_thread::sleep_for(std::chrono::milliseconds(20));
ch->push("demanded");
});
std::string got = ch->demand();
producer.join();
CHECK_EQ(got, std::string("demanded"));
CHECK(!ch->hasData());
}
TEST_CASE("thread.postMain.pushesToEventQueue") {
auto *ev = eve::platform_event::PlatformEvent::create();
REQUIRE(ev != nullptr);
ev->clear();
threadModule()->postMain("thread.task.done", "result-1");
threadModule()->postMain("thread.task.done", "result-2");
CHECK_EQ(ev->pollName(), std::string("thread.task.done"));
CHECK_EQ(ev->pollData(), std::string("result-2"));
}
TEST_CASE("thread.postMain.emptyNameThrows") {
CHECK(expectException([&] { threadModule()->postMain(""); }));
}
TEST_CASE("thread.postMain.fromWorkerThread") {
auto *ev = eve::platform_event::PlatformEvent::create();
REQUIRE(ev != nullptr);
ev->clear();
std::unique_ptr<eve::thread::ThreadPool> pool(threadModule()->newThreadPool(1));
std::unique_ptr<eve::thread::Task> task(pool->submit([mod = threadModule()] {
mod->postMain("thread.worker.done", "w");
}));
task->wait();
CHECK_EQ(task->getStatus(), std::string("done"));
CHECK_EQ(ev->pollName(), std::string("thread.worker.done"));
CHECK_EQ(ev->pollData(), std::string("w"));
}
TEST_CASE("thread.pool.submitSleep") {
std::unique_ptr<eve::thread::ThreadPool> pool(threadModule()->newThreadPool(2));
REQUIRE(pool.get() != nullptr);
CHECK_EQ(pool->getWorkerCount(), 2);
CHECK(pool->isRunning());
std::unique_ptr<eve::thread::Task> task(pool->submitSleep(20));
REQUIRE(task.get() != nullptr);
task->wait();
CHECK(task->isDone());
CHECK(!task->hasFailed());
CHECK_EQ(task->getStatus(), std::string("done"));
pool->waitAll();
}
TEST_CASE("thread.pool.submitPush") {
auto *mod = threadModule();
std::unique_ptr<eve::thread::Channel> ch(mod->newChannel());
std::unique_ptr<eve::thread::ThreadPool> pool(mod->newThreadPool(2));
std::unique_ptr<eve::thread::Task> task(pool->submitPush(ch.get(), "hello", 10));
REQUIRE(task.get() != nullptr);
task->wait();
CHECK_EQ(task->getStatus(), std::string("done"));
CHECK(!task->hasFailed());
std::string got = ch->supply(500);
CHECK_EQ(got, std::string("hello"));
}
TEST_CASE("thread.pool.parallelWork") {
std::unique_ptr<eve::thread::ThreadPool> pool(threadModule()->newThreadPool(4));
std::atomic<int> counter{0};
std::vector<eve::thread::Task *> tasks;
for (int i = 0; i < 32; ++i) {
tasks.push_back(pool->submit([&counter] {
counter.fetch_add(1, std::memory_order_relaxed);
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}));
}
pool->waitAll();
for (auto *t : tasks) {
REQUIRE(t != nullptr);
CHECK(t->isDone());
delete t;
}
CHECK_EQ(counter.load(), 32);
}
TEST_CASE("thread.pool.taskFailure") {
std::unique_ptr<eve::thread::ThreadPool> pool(threadModule()->newThreadPool(1));
std::unique_ptr<eve::thread::Task> task(pool->submit([] {
throw eve::Exception("boom");
}));
task->wait();
CHECK(task->hasFailed());
CHECK_EQ(task->getStatus(), std::string("failed"));
CHECK_EQ(task->getError(), std::string("boom"));
}
TEST_CASE("thread.defaultPool") {
auto *pool = threadModule()->getPool();
REQUIRE(pool != nullptr);
CHECK(pool->getWorkerCount() >= 1);
CHECK_EQ(pool, threadModule()->getPool());
}
TEST_CASE("thread.pool.stopRejectsSubmit") {
std::unique_ptr<eve::thread::ThreadPool> pool(threadModule()->newThreadPool(1));
pool->stop();
CHECK(!pool->isRunning());
CHECK(expectException([&] { pool->submitSleep(1); }));
}
TEST_CASE("thread.pool.queueOwnsTaskState") {
std::unique_ptr<eve::thread::ThreadPool> pool(threadModule()->newThreadPool(1));
eve::thread::Task *task = pool->submitSleep(10);
delete task;
pool->waitAll();
CHECK_EQ(pool->getPendingCount(), 0);
}
TEST_CASE("thread.pool.jobOwnsAnonymousChannelState") {
std::unique_ptr<eve::thread::ThreadPool> pool(threadModule()->newThreadPool(1));
auto *channel = threadModule()->newChannel();
std::unique_ptr<eve::thread::Task> task(pool->submitPush(channel, "late", 10));
delete channel;
task->wait();
CHECK_EQ(task->getStatus(), std::string("done"));
}
TEST_CASE("thread.pool.workerWaitAllRejected") {
std::unique_ptr<eve::thread::ThreadPool> pool(threadModule()->newThreadPool(1));
std::atomic<bool> rejected{false};
std::unique_ptr<eve::thread::Task> task(pool->submit([&] {
try {
pool->waitAll();
} catch (const eve::Exception &) {
rejected.store(true, std::memory_order_relaxed);
}
}));
task->wait();
CHECK(rejected.load(std::memory_order_relaxed));
}
TEST_CASE("thread.pool.workerStopRejected") {
std::unique_ptr<eve::thread::ThreadPool> pool(threadModule()->newThreadPool(1));
std::atomic<bool> rejected{false};
std::unique_ptr<eve::thread::Task> task(pool->submit([&] {
try {
pool->stop();
} catch (const eve::Exception &) {
rejected.store(true, std::memory_order_relaxed);
}
}));
task->wait();
CHECK(rejected.load(std::memory_order_relaxed));
CHECK(pool->isRunning());
}
TEST_CASE("thread.pool.workerCountBounded") {
CHECK(expectException([&] {
std::unique_ptr<eve::thread::ThreadPool> pool(threadModule()->newThreadPool(257));
}));
}
TEST_CASE("thread.pool.destructorFromWorkerIsSafe") {
auto *pool = threadModule()->newThreadPool(1);
std::unique_ptr<eve::thread::Task> task(pool->submit([pool] { delete pool; }));
task->wait();
CHECK_EQ(task->getStatus(), std::string("done"));
}
TEST_CASE("thread.pool.workerDestructorDoesNotJoinDependentWorker") {
auto *pool = threadModule()->newThreadPool(2);
std::atomic<eve::thread::Task *> deletingTask{nullptr};
std::atomic<bool> waiterReady{false};
std::unique_ptr<eve::thread::Task> waiter(pool->submit([&] {
eve::thread::Task *target = nullptr;
while ((target = deletingTask.load(std::memory_order_acquire)) == nullptr)
std::this_thread::yield();
waiterReady.store(true, std::memory_order_release);
target->wait();
}));
std::unique_ptr<eve::thread::Task> destroyer(pool->submit([&] {
while (!waiterReady.load(std::memory_order_acquire))
std::this_thread::yield();
delete pool;
}));
deletingTask.store(destroyer.get(), std::memory_order_release);
destroyer->wait();
waiter->wait();
CHECK_EQ(destroyer->getStatus(), std::string("done"));
CHECK_EQ(waiter->getStatus(), std::string("done"));
}
// ---- JobSystem ----
TEST_CASE("thread.jobsystem.create") {
std::unique_ptr<eve::thread::JobSystem> js(eve::thread::createJobSystem(2));
REQUIRE(js.get() != nullptr);
CHECK_EQ(js->getWorkerCount(), 2);
CHECK(js->isRunning());
CHECK_EQ(js->getOutstandingCount(), 0);
js->waitAll();
js->stop();
CHECK(!js->isRunning());
js->stop(); // idempotent
}
TEST_CASE("thread.jobsystem.submitWait") {
std::unique_ptr<eve::thread::JobSystem> js(eve::thread::createJobSystem(2));
std::atomic<int> counter{0};
std::vector<eve::thread::Job *> jobs;
for (int i = 0; i < 16; ++i) {
jobs.push_back(js->submit([&counter] {
counter.fetch_add(1, std::memory_order_relaxed);
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}));
}
for (auto *j : jobs) {
REQUIRE(j != nullptr);
j->wait();
CHECK(j->isDone());
delete j;
}
CHECK_EQ(counter.load(), 16);
js->waitAll();
}
TEST_CASE("thread.jobsystem.parallelFor") {
std::unique_ptr<eve::thread::JobSystem> js(eve::thread::createJobSystem(4));
constexpr int n = 1000;
std::vector<int> data(n, -1);
eve::thread::Job *loop = js->parallelFor(
0, n, [&data](int first, int last) {
for (int i = first; i < last; ++i) data[i] = i;
},
37);
REQUIRE(loop != nullptr);
loop->wait();
CHECK(loop->isDone());
for (int i = 0; i < n; ++i) CHECK_EQ(data[i], i);
delete loop;
js->waitAll();
}
TEST_CASE("thread.jobsystem.parallelForEmpty") {
std::unique_ptr<eve::thread::JobSystem> js(eve::thread::createJobSystem(2));
std::atomic<int> calls{0};
eve::thread::Job *loop = js->parallelFor(
5, 5, [&calls](int, int) { calls.fetch_add(1, std::memory_order_relaxed); }, 4);
REQUIRE(loop != nullptr);
loop->wait();
CHECK(loop->isDone());
CHECK_EQ(calls.load(), 0);
delete loop;
}
TEST_CASE("thread.jobsystem.parallelForAutoChunk") {
std::unique_ptr<eve::thread::JobSystem> js(eve::thread::createJobSystem(2));
std::atomic<long long> sum{0};
eve::thread::Job *loop = js->parallelFor(
0, 10000,
[&sum](int first, int last) {
long long s = 0;
for (int i = first; i < last; ++i) s += i;
sum.fetch_add(s, std::memory_order_relaxed);
},
0);
loop->wait();
CHECK(sum.load() == 10000LL * 9999 / 2);
delete loop;
}
TEST_CASE("thread.jobsystem.parallelForCompletion") {
std::unique_ptr<eve::thread::JobSystem> js(eve::thread::createJobSystem(2));
std::atomic<int> callbacks{0};
eve::thread::Job *loop = js->parallelFor(0, 100, [](int, int) {}, 10);
loop->setCompletionCallback([&callbacks] {
callbacks.fetch_add(1, std::memory_order_relaxed);
});
loop->wait();
CHECK_EQ(callbacks.load(), 1);
delete loop;
}
TEST_CASE("thread.jobsystem.taskGroup") {
std::unique_ptr<eve::thread::JobSystem> js(eve::thread::createJobSystem(3));
std::atomic<int> counter{0};
std::unique_ptr<eve::thread::TaskGroup> group(js->createTaskGroup());
REQUIRE(group.get() != nullptr);
for (int i = 0; i < 24; ++i) {
group->fork([&counter] {
counter.fetch_add(1, std::memory_order_relaxed);
});
}
group->wait();
CHECK_EQ(group->getPendingCount(), 0);
CHECK_EQ(counter.load(), 24);
}
TEST_CASE("thread.jobsystem.taskGroupReuse") {
std::unique_ptr<eve::thread::JobSystem> js(eve::thread::createJobSystem(2));
std::atomic<int> counter{0};
std::unique_ptr<eve::thread::TaskGroup> group(js->createTaskGroup());
for (int round = 0; round < 3; ++round) {
for (int i = 0; i < 8; ++i)
group->fork([&counter] { counter.fetch_add(1, std::memory_order_relaxed); });
group->wait();
CHECK_EQ(group->getPendingCount(), 0);
}
CHECK_EQ(counter.load(), 24);
}
TEST_CASE("thread.jobsystem.taskGroupCompletionCallbacks") {
std::unique_ptr<eve::thread::JobSystem> js(eve::thread::createJobSystem(2));
std::atomic<int> counter{0};
std::atomic<int> callbacks{0};
std::unique_ptr<eve::thread::TaskGroup> group(js->createTaskGroup());
for (int i = 0; i < 8; ++i) {
group->fork(
[&counter] { counter.fetch_add(1, std::memory_order_relaxed); },
[&callbacks] { callbacks.fetch_add(1, std::memory_order_relaxed); });
}
group->wait();
CHECK_EQ(counter.load(), 8);
CHECK_EQ(callbacks.load(), 8);
}
TEST_CASE("thread.jobsystem.dependencies") {
std::unique_ptr<eve::thread::JobSystem> js(eve::thread::createJobSystem(2));
std::atomic<int> produced{0};
std::atomic<int> seen{0};
eve::thread::Job *a = js->createJob([&produced] { produced.store(42); });
eve::thread::Job *b = js->createJob([&produced, &seen] { seen.store(produced.load()); });
b->addDependency(a);
CHECK_EQ(b->getPendingDependencyCount(), 1);
js->schedule(b); // waits for a
js->schedule(a);
b->wait();
a->wait();
CHECK_EQ(seen.load(), 42);
CHECK(a->isDone());
CHECK(b->isDone());
delete a;
delete b;
}
TEST_CASE("thread.jobsystem.heapDeleteWhileCompletingStress") {
// Regression for a heap-use-after-free in releaseDependents: with the
// completionDone flag and the dependents-release split across two lock
// acquisitions, a waiter could return from wait() and delete the heap job
// while the completing worker was still iterating job->dependents (ASan:
// voxel.world.cross_chunk_seam_culled). Deleting immediately after wait()
// from the waiting thread widens the race; 200 iterations raise the odds.
std::unique_ptr<eve::thread::JobSystem> js(eve::thread::createJobSystem(4));
std::atomic<int> ran{0};
for (int i = 0; i < 200; ++i) {
eve::thread::Job *child =
js->createJob([&ran] { ran.fetch_add(1, std::memory_order_relaxed); });
eve::thread::Job *dependent = js->createJob([] {});
dependent->addDependency(child);
js->schedule(child);
js->schedule(dependent);
child->wait();
delete child; // frees the dependents vector while the worker may
// still be in completeJob's tail (pre-fix UAF)
dependent->wait();
delete dependent;
}
CHECK_EQ(ran.load(), 200);
}
TEST_CASE("thread.jobsystem.dependencyOnCompletedJobIsNoOp") {
std::unique_ptr<eve::thread::JobSystem> js(eve::thread::createJobSystem(2));
std::atomic<int> counter{0};
eve::thread::Job *a = js->submit([&counter] { counter.fetch_add(1); });
a->wait();
eve::thread::Job *b = js->createJob([&counter] { counter.fetch_add(1); });
b->addDependency(a); // a already done -> ignored
CHECK_EQ(b->getPendingDependencyCount(), 0);
js->schedule(b);
b->wait();
CHECK_EQ(counter.load(), 2);
delete a;
delete b;
}
TEST_CASE("thread.jobsystem.failedPredecessorStillReleasesDependent") {
std::unique_ptr<eve::thread::JobSystem> js(eve::thread::createJobSystem(2));
std::atomic<int> bRuns{0};
eve::thread::Job *a = js->createJob([] { throw eve::Exception("boom"); });
eve::thread::Job *b = js->createJob([&bRuns] { bRuns.store(1); });
b->addDependency(a);
js->schedule(a);
js->schedule(b);
b->wait();
a->wait();
CHECK(a->hasFailed());
CHECK(b->isDone());
CHECK_EQ(bRuns.load(), 1);
delete a;
delete b;
}
TEST_CASE("thread.jobsystem.joinNode") {
std::unique_ptr<eve::thread::JobSystem> js(eve::thread::createJobSystem(2));
std::atomic<int> runs{0};
eve::thread::Job *leaf = js->createJob([&runs] { runs.fetch_add(1); });
eve::thread::Job *join = js->createJob(eve::thread::JobFunc{}); // pure dependency node
join->addDependency(leaf);
js->schedule(leaf);
js->schedule(join);
join->wait();
leaf->wait();
CHECK_EQ(runs.load(), 1);
delete leaf;
delete join;
}
TEST_CASE("thread.jobsystem.completionCallback") {
std::unique_ptr<eve::thread::JobSystem> js(eve::thread::createJobSystem(2));
std::atomic<int> bodyRan{0};
std::atomic<int> callbackRan{0};
eve::thread::Job *job = js->submit([&bodyRan] { bodyRan.store(1); });
job->setCompletionCallback([&bodyRan, &callbackRan] {
callbackRan.store(bodyRan.load());
});
job->wait();
CHECK_EQ(callbackRan.load(), 1);
delete job;
}
TEST_CASE("thread.jobsystem.failure") {
std::unique_ptr<eve::thread::JobSystem> js(eve::thread::createJobSystem(1));
eve::thread::Job *job = js->submit([] { throw eve::Exception("kaboom"); });
job->wait();
CHECK(job->isDone());
CHECK(job->hasFailed());
CHECK_EQ(job->getError(), std::string("kaboom"));
delete job;
}
TEST_CASE("thread.jobsystem.workerForkJoin") {
// Fork/join from inside a pool worker must not deadlock even with a single
// worker: the waiting worker helps execute the ready children.
std::unique_ptr<eve::thread::JobSystem> js(eve::thread::createJobSystem(1));
std::atomic<int> counter{0};
eve::thread::Job *outer = js->submit([&] {
std::unique_ptr<eve::thread::TaskGroup> group(js->createTaskGroup());
for (int i = 0; i < 32; ++i) {
group->fork([&counter] {
counter.fetch_add(1, std::memory_order_relaxed);
});
}
group->wait();
});
outer->wait();
CHECK_EQ(counter.load(), 32);
delete outer;
}
TEST_CASE("thread.jobsystem.workerParallelFor") {
std::unique_ptr<eve::thread::JobSystem> js(eve::thread::createJobSystem(1));
std::atomic<long long> sum{0};
eve::thread::Job *outer = js->submit([&] {
eve::thread::Job *loop = js->parallelFor(
0, 500,
[&sum](int first, int last) {
long long s = 0;
for (int i = first; i < last; ++i) s += i;
sum.fetch_add(s, std::memory_order_relaxed);
},
10);
loop->wait();
delete loop;
});
outer->wait();
CHECK(sum.load() == 500LL * 499 / 2);
delete outer;
}
TEST_CASE("thread.jobsystem.frameArena") {
std::unique_ptr<eve::thread::JobSystem> js(eve::thread::createJobSystem(2));
for (int frame = 0; frame < 3; ++frame) {
js->beginFrame();
std::atomic<int> counter{0};
std::vector<eve::thread::Job *> jobs;
for (int i = 0; i < 64; ++i) {
jobs.push_back(js->submitFrame([&counter] {
counter.fetch_add(1, std::memory_order_relaxed);
}));
}
for (auto *j : jobs) j->wait();
js->endFrame(); // joins (already done) and recycles the arena
CHECK_EQ(counter.load(), 64);
}
js->waitAll();
}
TEST_CASE("thread.jobsystem.frameParallelFor") {
std::unique_ptr<eve::thread::JobSystem> js(eve::thread::createJobSystem(2));
js->beginFrame();
std::vector<int> data(200, -1);
eve::thread::Job *loop = js->parallelForFrame(
0, 200,
[&data](int first, int last) {
for (int i = first; i < last; ++i) data[i] = i;
},
16);
loop->wait();
js->endFrame();
for (int i = 0; i < 200; ++i) CHECK_EQ(data[i], i);
}
TEST_CASE("thread.jobsystem.frameTaskGroup") {
std::unique_ptr<eve::thread::JobSystem> js(eve::thread::createJobSystem(2));
js->beginFrame();
std::atomic<int> counter{0};
eve::thread::TaskGroup *group = js->createFrameTaskGroup();
for (int i = 0; i < 32; ++i) {
group->fork([&counter] { counter.fetch_add(1, std::memory_order_relaxed); });
}
group->wait();
CHECK_EQ(counter.load(), 32);
js->endFrame(); // destroys the group and its arena-allocated children
}
TEST_CASE("thread.jobsystem.dependencyAfterScheduleRejected") {
std::unique_ptr<eve::thread::JobSystem> js(eve::thread::createJobSystem(1));
eve::thread::Job *a = js->submit([] {});
eve::thread::Job *b = js->createJob([] {});
CHECK(expectException([&] { a->addDependency(b); }));
CHECK(expectException([&] { a->addDependency(a); }));
a->wait();
delete a;
js->schedule(b);
b->wait();
delete b;
}
TEST_CASE("thread.jobsystem.stopRejectsSchedule") {
std::unique_ptr<eve::thread::JobSystem> js(eve::thread::createJobSystem(1));
eve::thread::Job *job = js->createJob([] {});
js->stop();
CHECK(!js->isRunning());
CHECK(expectException([&] { js->schedule(job); }));
CHECK(expectException([&] { js->submit([] {}); }));
delete job;
}
TEST_CASE("thread.jobsystem.workerWaitAllRejected") {
std::unique_ptr<eve::thread::JobSystem> js(eve::thread::createJobSystem(1));
std::atomic<bool> rejected{false};
eve::thread::Job *job = js->submit([&] {
try {
js->waitAll();
} catch (const eve::Exception &) {
rejected.store(true, std::memory_order_relaxed);
}
});
job->wait();
CHECK(rejected.load(std::memory_order_relaxed));
delete job;
}
TEST_CASE("thread.jobsystem.defaultSystem") {
auto *jobs = threadModule()->getJobSystem();
REQUIRE(jobs != nullptr);
CHECK(jobs->getWorkerCount() >= 1);
CHECK_EQ(jobs, threadModule()->getJobSystem());
std::atomic<int> counter{0};
eve::thread::Job *job = jobs->submit([&counter] {
counter.fetch_add(1, std::memory_order_relaxed);
});
job->wait();
CHECK_EQ(counter.load(), 1);
delete job;
}