fix(threadpool): batches run every task once, and Stop ends the whole pool

- Enqueue(vector) dropped tasks when the count didn't divide by the
  workers: the last one got only the remainder. Tasks are now split as
  evenly as the count allows, each worker's taskCount grows by what it
  was given (the single-task Enqueue picks by it), and Start(1), which
  has no workers, leaves the batch for the caller's JoinPool.
- Stop wakes every slot, JoinPool's too, after joining the workers: a
  thread that had joined the pool slept on. It takes each slot's mutex
  before notifying, so a waiter between its check and its sleep can't miss
  the wake-up, and works on the pool it started with. Again, or before
  Start, it does nothing; joining a thread twice threw. Its documentation
  says what it doesn't do: stop from a pool task, or from two threads at
  once, run tasks enqueued once it has begun, or have JoinPool wait for it.
- Tests: EnqueueBatch, StopJoinPool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-10-20 19:06:37 +02:00
commit f71cb43e85
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
5 changed files with 308 additions and 149 deletions

View file

@ -0,0 +1,77 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// ThreadPool::Enqueue(vector) runs every task exactly once, whatever the
// task count. When the count didn't divide by the number of worker threads,
// the last worker got only the remainder and tasksPerThread tasks were
// dropped. The counts here cover fewer tasks than workers, an exact multiple,
// one more and one less than a multiple, and a large uneven batch, then a
// batch of single Enqueue calls, which rely on the per-thread task counts the
// batch path also kept wrong.
#include <cstdlib>
import Crafter.Thread;
import std;
using namespace Crafter;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what) {
std::println("{} {}", ok ? "PASS" : "FAIL", what);
if (!ok) ++failures;
}
// Waits until `done` reaches `expected` or a few seconds pass.
bool WaitFor(const std::atomic<std::uint32_t>& done, std::uint32_t expected) {
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
while (done.load() < expected) {
if (std::chrono::steady_clock::now() > deadline) return false;
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
return true;
}
void RunBatch(std::uint32_t count) {
std::atomic<std::uint32_t> done = 0;
std::vector<std::atomic<std::uint32_t>> runs(count);
std::vector<std::function<void(void)>> tasks;
tasks.reserve(count);
for (std::uint32_t i = 0; i < count; ++i) {
tasks.emplace_back([&runs, &done, i] {
runs[i]++;
done++;
});
}
ThreadPool::Enqueue(std::move(tasks));
const bool finished = WaitFor(done, count);
// A little longer, in case something runs twice.
std::this_thread::sleep_for(std::chrono::milliseconds(20));
const bool once = std::ranges::all_of(runs, [](const std::atomic<std::uint32_t>& r) { return r.load() == 1; });
Check(finished && once, std::format("a batch of {} tasks runs every task exactly once ({} of {} done)", count, done.load(), count));
}
} // namespace
int main() {
ThreadPool::Start(4);
const std::uint32_t workers = ThreadPool::activeThreads;
std::println(" {} worker threads", workers);
for (std::uint32_t count : {1u, workers - 1, workers, workers + 1, 3 * workers + 2, 1000u}) {
RunBatch(count);
}
// Single Enqueue calls pick the least loaded worker by its task count;
// a count left wrong by a batch would starve or flood one of them.
std::atomic<std::uint32_t> done = 0;
for (std::uint32_t i = 0; i < 200; ++i) {
ThreadPool::Enqueue([&done] { done++; });
}
Check(WaitFor(done, 200), std::format("200 single tasks after the batches all run ({} done)", done.load()));
ThreadPool::Stop();
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}

View file

@ -0,0 +1,43 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// ThreadPool::Stop ends the pool for everyone in it, the thread that joined
// it with JoinPool too. Stop woke and joined the worker threads only, so a
// JoinPool caller slept on, never told. And Stop called again joined threads
// already joined, which throws.
//
// Here another thread stops the pool this one has joined, then stops it
// again. A watchdog fails the test if JoinPool doesn't return.
#include <cstdlib>
#include <unistd.h>
import Crafter.Thread;
import std;
using namespace Crafter;
using namespace std::chrono_literals;
int main() {
std::thread watchdog([] {
std::this_thread::sleep_for(10s);
std::println("FAIL JoinPool didn't return within 10 s of Stop");
_exit(EXIT_FAILURE);
});
watchdog.detach();
ThreadPool::Start(4);
std::atomic<int> ran = 0;
for (int i = 0; i < 100; ++i) ThreadPool::Enqueue([&ran] { ++ran; });
std::thread stopper([] {
std::this_thread::sleep_for(100ms);
ThreadPool::Stop();
});
ThreadPool::JoinPool();
stopper.join();
std::println("{} Stop from another thread ends the JoinPool of the thread that joined, after the queued tasks ({} of 100)",
ran == 100 ? "PASS" : "FAIL", ran.load());
ThreadPool::Stop();
std::println("PASS and stopping it again does nothing");
return ran == 100 ? EXIT_SUCCESS : EXIT_FAILURE;
}