Preliminary. ThinkMeta ConcurrentTasks and this documentation are preliminary; everything is subject to change.

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:

  1. Counting. The reader must wait while every slot is in use, and the writer must wait until the reader has filled the next slot.
  2. 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 Wait returns 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 lets Wait pass 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.AddTask before 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, Pack reports 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)
OK

The 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

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;
}

Source and project on GitHub

Next