Tutorial 6: A bounded ring
On this page
Where we are
Chapter 5 reads the file in a reader task, encodes each block as soon as it is in memory and writes the blocks in order.
The problem
Every block still has its own memory and its own task object, so the program needs memory for the whole file plus every encoded block, and the queue capacity grows with the file. Yet at any moment only a few blocks are actually in flight – read but not yet written. The rest are either waiting to be read or finished.
The fix is a ring of a fixed number of slots. A block takes a free slot, is read into it, encoded in it and written from it; then the slot is free for a later block:
┌───────── free slot ──────────┐
▼ │
reader ──► slot: read ──► encode task ──► writer (in order)Two things are needed for that, and neither can be done with what we have so far:
- Counting. The reader must wait while every slot is in use, and the writer must wait until the reader has filled the next slot.
- Joining again and again. The writer waits for a slot's encode task, and the same slot runs
another encode task later. A WaitableTask runs once: after its first exit,
every
Waitreturns at once.
The building blocks
A semaphore counts permits. Wait takes one and suspends the task while there
is none; Release adds one. The ring has two:
freeSlots_starts with one permit per slot. The reader takes a permit before it fills a slot, the writer gives it back after it has written the slot. This bounds the blocks in flight.readySlots_starts at 0. The reader releases a permit for every slot it has filled and forked, the writer takes one before it looks at the next slot.
Why not an event per slot? An event says something happened; a semaphore says how often. The reader may be several slots ahead of the writer, and every one of those slots counts.
A TaskGroup_s per slot joins the slot's encode task. AddTask counts a task
in, its exit counts it out, and Wait waits until the count is back to 0. After that the group can
be used again – exactly what a slot needs. The encode task becomes a plain
Task, because the group does the waiting.
The single-waiter variants. Both types come in two flavours: Semaphore and
TaskGroup allow any number of tasks to wait at once; Semaphore_s
and TaskGroup_s allow at most one, and are cheaper for it. In the ring every
object has exactly one waiter: only the reader waits for freeSlots_, only the writer for
readySlots_ and for the slots' groups. See Single-waiter variants.
Important
Contract. A _s object may have at most one waiting task at a time; a release build does not
check it. A task may be added to the scheduler again only after it has exited – here guaranteed,
because the reader reuses a slot only after the writer has waited for its group and released the
slot.
The change
The program imports the semaphore, the task group and the plain task:
#include "tutorial-common.h"
#include <memory>
#include <ThinkMeta.ConcurrentTasks/Core.h>
import ThinkMeta.ConcurrentTasks.AsyncIo.AsyncFile;
import ThinkMeta.ConcurrentTasks.Synchronization.Semaphore;
import ThinkMeta.ConcurrentTasks.Synchronization.TaskGroup;
import ThinkMeta.ConcurrentTasks.Tasks.Task;
import ThinkMeta.ConcurrentTasks.Tasks.WaitableTask;
import ThinkMeta.ConcurrentTasks.Windows.Error;
namespace TC = ThinkMeta::ConcurrentTasks;
using TC::AsyncIo::AsyncFile;
using TC::Windows::Error;
using namespace Tutorial;A slot holds the raw block, its encode task and the group that joins it:
// Encodes one block. A plain Task this time: the slot's group tells when it has
// run, and the same object runs again for a later block.
class EncodeTask : public TC::Tasks::Task
{
public:
void Run() noexcept override { EncodeBlock(input, encoded); }
std::span<const std::byte> input;
EncodedBlock encoded;
};
// One slot of the ring: a block on its way from the reader through its encode
// task to the writer.
struct Slot {
std::vector<std::byte> buffer; // the raw block
std::size_t size = 0; // 0: the end of the input
EncodeTask task;
TC::Synchronization::TaskGroup_s encoded; // the writer waits here
};The Packer holds the ring and the two semaphores. Each has one waiter, so the single-waiter
variant is enough:
class Packer
{
// ...
TC::ITaskScheduler* scheduler_ = nullptr;
AsyncFile input_;
std::size_t slotCount_ = 0;
std::unique_ptr<Slot[]> slots_;
// Free slots and forked blocks. Each has exactly one waiting task - the
// reader, the writer - so the single-waiter variant is enough.
TC::Synchronization::Semaphore_s freeSlots_;
TC::Synchronization::Semaphore_s readySlots_;
ReaderTask reader_{*this};
bool readOk_ = true;
};The reader fills free slots and forks their encoding. At the end of the file, or after a read error, it passes on an empty slot:
class Packer
{
// ...
// The reader: fills free slots block after block and forks each block's
// encoding. An empty slot tells the writer that the input has ended.
void Read() noexcept
{
for (std::size_t block = 0;; ++block) {
freeSlots_.Wait(); // at most slotCount blocks in flight
Slot& slot = slots_[block % slotCount_];
const std::uint64_t offset = std::uint64_t{slot.buffer.size()} * block;
DWORD read = 0; // DWORD: Read's out parameter
const auto error
= input_.Read(slot.buffer.data(),
static_cast<DWORD>(slot.buffer.size()), read, offset);
if (Error::Failed(error) && error != Error::EndOfFile)
readOk_ = false; // read is 0: the pipeline ends here
slot.size = read;
if (slot.size == 0) {
readySlots_.Release(); // the end mark
return;
}
slot.task.input = std::span(slot.buffer).first(slot.size);
slot.encoded.AddTask(&slot.task); // count it in before it can exit
scheduler_->AddTask(&slot.task);
readySlots_.Release(); // the writer may wait for this block now
}
}
// ...
};The writer – still main – takes the slots in order:
class Packer
{
// ...
// The writer: takes the blocks in order, waits for each one's encoding,
// writes it and hands the slot back to the reader.
bool Pack(TC::ITaskScheduler& scheduler, const Arguments& arguments,
std::size_t slotCount)
{
if (Error::Failed(input_.Create(arguments.input, GENERIC_READ,
FILE_SHARE_READ, OPEN_EXISTING,
FILE_FLAG_SEQUENTIAL_SCAN)))
return false;
File output = File::Create(arguments.output);
if (!output.IsOpen())
return false;
scheduler_ = &scheduler;
slotCount_ = slotCount;
slots_ = std::make_unique<Slot[]>(slotCount);
for (std::size_t i = 0; i < slotCount; ++i)
slots_[i].buffer.resize(arguments.blockSize);
freeSlots_.Release(slotCount); // every slot is free at the start
scheduler.AddTask(&reader_);
std::uint32_t crc = 0;
bool ok = true;
for (std::size_t block = 0;; ++block) {
readySlots_.Wait(); // the reader has forked this block, or ended
Slot& slot = slots_[block % slotCount];
if (slot.size == 0)
break; // the end mark
slot.encoded.Wait();
crc = WriteBlock(output, slot.task.encoded, crc, ok);
freeSlots_.Release(); // the slot can take the next block
}
reader_.Wait();
input_.Close();
return WriteFooter(output, crc) && ok && readOk_;
}
// ...
};- Order of the waits. The writer waits for
readySlots_before it waits for the slot's group. A group whose count is 0 letsWaitpass at once; without the semaphore, the writer could pass a group that the reader has not counted the task into yet. - Count in before the task can exit. The reader calls
encoded.AddTaskbefore AddTask on the scheduler. The other way round, a fast task could exit before it is counted. - The end. The empty slot ends the writer's loop. Neither stage needs to know the number of
blocks in advance, and a read error ends the pipeline cleanly: the writer stops,
Packreports the error.
main chooses four slots per thread – enough to keep every thread busy while the reader and the
writer catch up – and no longer sets a queue capacity:
int wmain(int argc, wchar_t* argv[])
{
Arguments arguments;
if (!ParseArguments(argc, argv, arguments))
return arguments.exitCode;
// Enough blocks in flight to keep every thread busy while the reader and
// the writer catch up. At most this many encode tasks are runnable, so the
// default queue capacity is enough again.
const std::uint32_t threads = arguments.threads != 0
? arguments.threads
: GetActiveProcessorCount(ALL_PROCESSOR_GROUPS);
const std::size_t slotCount = std::size_t{4} * threads;
TC::ConvertThreadToTask();
if (!TC::AsyncIo::EnableAsyncIo())
return 1;
TC::SchedulerInfo info{};
info.numberOfThreads = threads;
TC::ITaskScheduler* scheduler
= TC::CreateTaskScheduler(TC::SchedulerKind::Default, &info);
if (scheduler == nullptr)
return 1;
scheduler->Start();
bool packed = false;
{
Packer packer; // lives until after Stop
const Stopwatch total;
packed = packer.Pack(*scheduler, arguments, slotCount);
std::printf("time: %.2f s (%zu slots of %zu KB)\n", total.Seconds(),
slotCount, arguments.blockSize / 1024);
scheduler->Stop();
}
scheduler->Destroy();
TC::AsyncIo::DisableAsyncIo();
TC::ConvertTaskToThread();
if (!packed) {
std::printf("cannot pack the file\n");
return 1;
}
return CheckOutput(arguments) ? 0 : 1;
}The result
blockpack input.bin output.bpk -r reference.bpk
time: … s (… slots of 1024 KB)
OKThe time is about the same as in chapter 5, but the program has changed in kind:
- Memory is the number of slots times the block size, whatever the size of the file.
- Tasks: at most one encode task per slot is runnable, so the default queue capacity is enough again.
- Task objects are created once per slot and reused.
What limits us now
The writer computes the CRC over every byte it writes, alone, while the encode tasks wait for free slots. It looks tempting to let the encode tasks update the CRC as they finish – the next chapter tries that, and shows why it goes wrong.
Learn more
- Task graphs – the same ring as a reusable pattern
- Single-waiter variants, Semaphores and locks, Waiting for tasks
- Semaphore_s, TaskGroup_s, Task
Show the whole program
// Tutorial chapter 6: a bounded ring - blocks travel through a fixed number of
// slots from the reader through an encode task to the writer, so memory and
// tasks no longer grow with the file.
#include "tutorial-common.h"
#include <memory>
#include <ThinkMeta.ConcurrentTasks/Core.h>
import ThinkMeta.ConcurrentTasks.AsyncIo.AsyncFile;
import ThinkMeta.ConcurrentTasks.Synchronization.Semaphore;
import ThinkMeta.ConcurrentTasks.Synchronization.TaskGroup;
import ThinkMeta.ConcurrentTasks.Tasks.Task;
import ThinkMeta.ConcurrentTasks.Tasks.WaitableTask;
import ThinkMeta.ConcurrentTasks.Windows.Error;
namespace TC = ThinkMeta::ConcurrentTasks;
using TC::AsyncIo::AsyncFile;
using TC::Windows::Error;
using namespace Tutorial;
// Encodes one block. A plain Task this time: the slot's group tells when it has
// run, and the same object runs again for a later block.
class EncodeTask : public TC::Tasks::Task
{
public:
void Run() noexcept override { EncodeBlock(input, encoded); }
std::span<const std::byte> input;
EncodedBlock encoded;
};
// One slot of the ring: a block on its way from the reader through its encode
// task to the writer.
struct Slot {
std::vector<std::byte> buffer; // the raw block
std::size_t size = 0; // 0: the end of the input
EncodeTask task;
TC::Synchronization::TaskGroup_s encoded; // the writer waits here
};
class Packer;
// Runs Packer::Read as a task, as in chapter 5.
class ReaderTask : public TC::Tasks::WaitableTask
{
public:
explicit ReaderTask(Packer& packer) noexcept : packer_(packer) {}
void Run() noexcept override;
private:
Packer& packer_;
};
class Packer
{
public:
// The writer: takes the blocks in order, waits for each one's encoding,
// writes it and hands the slot back to the reader.
bool Pack(TC::ITaskScheduler& scheduler, const Arguments& arguments,
std::size_t slotCount)
{
if (Error::Failed(input_.Create(arguments.input, GENERIC_READ,
FILE_SHARE_READ, OPEN_EXISTING,
FILE_FLAG_SEQUENTIAL_SCAN)))
return false;
File output = File::Create(arguments.output);
if (!output.IsOpen())
return false;
scheduler_ = &scheduler;
slotCount_ = slotCount;
slots_ = std::make_unique<Slot[]>(slotCount);
for (std::size_t i = 0; i < slotCount; ++i)
slots_[i].buffer.resize(arguments.blockSize);
freeSlots_.Release(slotCount); // every slot is free at the start
scheduler.AddTask(&reader_);
std::uint32_t crc = 0;
bool ok = true;
for (std::size_t block = 0;; ++block) {
readySlots_.Wait(); // the reader has forked this block, or ended
Slot& slot = slots_[block % slotCount];
if (slot.size == 0)
break; // the end mark
slot.encoded.Wait();
crc = WriteBlock(output, slot.task.encoded, crc, ok);
freeSlots_.Release(); // the slot can take the next block
}
reader_.Wait();
input_.Close();
return WriteFooter(output, crc) && ok && readOk_;
}
// The reader: fills free slots block after block and forks each block's
// encoding. An empty slot tells the writer that the input has ended.
void Read() noexcept
{
for (std::size_t block = 0;; ++block) {
freeSlots_.Wait(); // at most slotCount blocks in flight
Slot& slot = slots_[block % slotCount_];
const std::uint64_t offset = std::uint64_t{slot.buffer.size()} * block;
DWORD read = 0; // DWORD: Read's out parameter
const auto error
= input_.Read(slot.buffer.data(),
static_cast<DWORD>(slot.buffer.size()), read, offset);
if (Error::Failed(error) && error != Error::EndOfFile)
readOk_ = false; // read is 0: the pipeline ends here
slot.size = read;
if (slot.size == 0) {
readySlots_.Release(); // the end mark
return;
}
slot.task.input = std::span(slot.buffer).first(slot.size);
slot.encoded.AddTask(&slot.task); // count it in before it can exit
scheduler_->AddTask(&slot.task);
readySlots_.Release(); // the writer may wait for this block now
}
}
private:
TC::ITaskScheduler* scheduler_ = nullptr;
AsyncFile input_;
std::size_t slotCount_ = 0;
std::unique_ptr<Slot[]> slots_;
// Free slots and forked blocks. Each has exactly one waiting task - the
// reader, the writer - so the single-waiter variant is enough.
TC::Synchronization::Semaphore_s freeSlots_;
TC::Synchronization::Semaphore_s readySlots_;
ReaderTask reader_{*this};
bool readOk_ = true;
};
void ReaderTask::Run() noexcept
{
packer_.Read();
}
int wmain(int argc, wchar_t* argv[])
{
Arguments arguments;
if (!ParseArguments(argc, argv, arguments))
return arguments.exitCode;
// Enough blocks in flight to keep every thread busy while the reader and
// the writer catch up. At most this many encode tasks are runnable, so the
// default queue capacity is enough again.
const std::uint32_t threads = arguments.threads != 0
? arguments.threads
: GetActiveProcessorCount(ALL_PROCESSOR_GROUPS);
const std::size_t slotCount = std::size_t{4} * threads;
TC::ConvertThreadToTask();
if (!TC::AsyncIo::EnableAsyncIo())
return 1;
TC::SchedulerInfo info{};
info.numberOfThreads = threads;
TC::ITaskScheduler* scheduler
= TC::CreateTaskScheduler(TC::SchedulerKind::Default, &info);
if (scheduler == nullptr)
return 1;
scheduler->Start();
bool packed = false;
{
Packer packer; // lives until after Stop
const Stopwatch total;
packed = packer.Pack(*scheduler, arguments, slotCount);
std::printf("time: %.2f s (%zu slots of %zu KB)\n", total.Seconds(),
slotCount, arguments.blockSize / 1024);
scheduler->Stop();
}
scheduler->Destroy();
TC::AsyncIo::DisableAsyncIo();
TC::ConvertTaskToThread();
if (!packed) {
std::printf("cannot pack the file\n");
return 1;
}
return CheckOutput(arguments) ? 0 : 1;
}