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

Tutorial 5: Reading without blocking

On this page

Where we are

Chapter 4 sizes the scheduler for the work. The blocks are encoded in parallel and written in order.

The problem

Packer reads the whole file before it starts the first task. While the file is read, every scheduler thread is idle:

main      │ read whole file │ wait     │W│W│ wait │W│W│ wait │W│W│ …
thread 1  │ idle            │ encode 0 │ encode 2 │ encode 4 │ …
thread 2  │ idle            │ encode 1 │ encode 3 │ encode 5 │ …

On a second run the file comes from the file system cache, and this costs little. On a file that is read from disk for the first time – the normal case for an encoder – reading can take as long as encoding. Encoding should start as soon as the first block is in memory.

So reading moves into a task of its own, the reader, which starts each block's task right after reading it. But a task that calls ReadFile blocks the scheduler thread it runs on: for the whole time the disk works, that thread encodes nothing.

The building block

AsyncFile reads and writes with overlapped I/O. Its Read suspends the calling task until the data is there; the thread runs other tasks in the meantime – here, the encoding of the blocks read before. The completion arrives on the core's I/O thread, which EnableAsyncIo starts and DisableAsyncIo stops. See Asynchronous I/O.

Why not ReadFile in the reader task? It would work, but it takes a scheduler thread away for the duration of every read. With one thread per logical processor, that thread is missing from the encoding.

Why not read in main? main writes. Reading and writing in one loop would make them wait for each other again. Writing stays in main with plain, blocking WriteFile: main is not a scheduler thread, so blocking it takes nothing away from the encoding.

Important

Contract. Call EnableAsyncIo before you open an AsyncFile, and close every AsyncFile before DisableAsyncIo. No operation may be pending when a file is closed.

The change

The program imports the file module and the error helpers:

#include "tutorial-common.h"

#include <memory>

#include <ThinkMeta.ConcurrentTasks/Core.h>
import ThinkMeta.ConcurrentTasks.AsyncIo.AsyncFile;
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;

The reader is a WaitableTask that calls Packer::Read, so that main can wait until it has finished:

class Packer;

// Runs Packer::Read as a task.
class ReaderTask : public TC::Tasks::WaitableTask
{
public:
    explicit ReaderTask(Packer& packer) noexcept : packer_(packer) {}

    void Run() noexcept override;

private:
    Packer& packer_;
};

Read reads block after block straight into the file buffer and forks the block's task at once. Each read carries its file offset; an AsyncFile has no file pointer:

class Packer
{
    // ...
    // Runs as a task: reads block after block and forks its encoding at once.
    // Read suspends the task, not the thread, while the disk works.
    void Read() noexcept
    {
        for (std::size_t i = 0; i < count_; ++i) {
            const std::size_t offset = i * blockSize_;
            const auto        wanted
                = static_cast<DWORD>(std::min(blockSize_, data_.size() - offset));
            DWORD      read  = 0; // DWORD: Read's out parameter
            const auto error = input_.Read(&data_[offset], wanted, read, offset);
            if (Error::Failed(error) || read != wanted) {
                // Main waits for every block, so it still gets one: an empty
                // one. Chapter 9 handles errors properly.
                readOk_ = false;
                read    = 0;
            }
            tasks_[i].input = std::span(data_).subspan(offset, read);
            scheduler_->AddTask(&tasks_[i]);
        }
    }
    // ...
};

Pack opens the input as an AsyncFile, allocates the tasks, starts the reader and then writes:

class Packer
{
    // ...
    bool Pack(TC::ITaskScheduler& scheduler, const Arguments& arguments)
    {
        AsyncFile::FileSize size = 0;
        if (Error::Failed(input_.Create(arguments.input, GENERIC_READ,
                                        FILE_SHARE_READ, OPEN_EXISTING,
                                        FILE_FLAG_SEQUENTIAL_SCAN))
            || Error::Failed(input_.GetSize(size)))
            return false;
        File output = File::Create(arguments.output);
        if (!output.IsOpen())
            return false;

        scheduler_ = &scheduler;
        blockSize_ = arguments.blockSize;
        data_.resize(size);
        count_ = (data_.size() + blockSize_ - 1) / blockSize_;
        tasks_ = std::make_unique<EncodeTask[]>(count_);

        // The reader forks the blocks; main only writes. Waiting for a task that
        // the reader has not started yet is fine: the wait ends when it has run.
        scheduler.AddTask(&reader_);
        std::uint32_t crc = 0;
        bool          ok  = true;
        for (std::size_t i = 0; i < count_; ++i) {
            tasks_[i].Wait();
            crc = WriteBlock(output, tasks_[i].encoded, crc, ok);
        }
        reader_.Wait();
        input_.Close(); // no read is pending any more
        return WriteFooter(output, crc) && ok && readOk_;
    }
    // ...
};
  • main waits for block 0 while the reader may not even have started it. That is fine: a Wait on a task ends when the task has run, no matter when it was started.
  • On a read error the reader still starts every remaining block, with empty input, so that main is never left waiting for a task that will not run. The output is then incomplete, and Pack reports the error. Chapter 6 ends the pipeline cleanly instead.
  • main waits for the reader before it closes the file, so no read is pending.

main enables asynchronous I/O, and destroys the Packer – and with it the file – before it disables it:

int wmain(int argc, wchar_t* argv[])
{
    Arguments arguments;
    if (!ParseArguments(argc, argv, arguments))
        return arguments.exitCode;

    const std::uint64_t fileSize = File::OpenRead(arguments.input).Size();
    const std::uint64_t blocks
        = (fileSize + arguments.blockSize - 1) / arguments.blockSize;
    if (blocks > TC::MaximumTaskQueueCapacity) {
        std::printf("%llu blocks are too many; choose larger blocks\n",
                    static_cast<unsigned long long>(blocks));
        return 1;
    }

    TC::ConvertThreadToTask();
    // AsyncFile completes its reads on the core's I/O thread.
    if (!TC::AsyncIo::EnableAsyncIo())
        return 1;

    TC::SchedulerInfo info{};
    info.numberOfThreads   = arguments.threads;
    info.taskQueueCapacity = std::max(static_cast<std::uint32_t>(blocks),
                                      TC::DefaultTaskQueueCapacity);
    TC::ITaskScheduler* scheduler
        = TC::CreateTaskScheduler(TC::SchedulerKind::Default, &info);
    if (scheduler == nullptr)
        return 1;
    scheduler->Start();

    bool packed = false;
    {
        // Lives until after Stop, and its file is gone before DisableAsyncIo.
        Packer          packer;
        const Stopwatch total;
        packed = packer.Pack(*scheduler, arguments);
        std::printf("time: %.2f s\n", total.Seconds());
        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
OK

The encoding now starts with the first block:

reader    │R0│R1│R2│R3│R4│R5│ …
main      │ wait        │W0│W1│W2│ │W3│W4│W5│ …
thread 1  │··│ encode 0 │ encode 3 │ …
thread 2  │·····│ encode 1 │ encode 4 │ …
thread 3  │········│ encode 2 │ encode 5 │ …

With the file in the cache the difference to chapter 4 is small. It grows with a slow disk or a cold file: then reading runs in the shadow of the encoding instead of before it.

What limits us now

  • Memory. The whole file and every encoded block are still in memory at the same time. A file larger than the memory cannot be packed.
  • Tasks. There is still one task object per block, and the queue capacity still grows with the file.

Both go away when blocks no longer get their own memory and task, but share a fixed number of slots.

Learn more

Show the whole program
// Tutorial chapter 5: reading without blocking - a reader task reads the file
// with AsyncFile and starts each block's task as soon as the block is in memory.
#include "tutorial-common.h"

#include <memory>

#include <ThinkMeta.ConcurrentTasks/Core.h>
import ThinkMeta.ConcurrentTasks.AsyncIo.AsyncFile;
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, as in chapter 3.
class EncodeTask : public TC::Tasks::WaitableTask
{
public:
    void Run() noexcept override { EncodeBlock(input, encoded); }

    std::span<const std::byte> input;
    EncodedBlock               encoded;
};

class Packer;

// Runs Packer::Read as a task.
class ReaderTask : public TC::Tasks::WaitableTask
{
public:
    explicit ReaderTask(Packer& packer) noexcept : packer_(packer) {}

    void Run() noexcept override;

private:
    Packer& packer_;
};

class Packer
{
public:
    bool Pack(TC::ITaskScheduler& scheduler, const Arguments& arguments)
    {
        AsyncFile::FileSize size = 0;
        if (Error::Failed(input_.Create(arguments.input, GENERIC_READ,
                                        FILE_SHARE_READ, OPEN_EXISTING,
                                        FILE_FLAG_SEQUENTIAL_SCAN))
            || Error::Failed(input_.GetSize(size)))
            return false;
        File output = File::Create(arguments.output);
        if (!output.IsOpen())
            return false;

        scheduler_ = &scheduler;
        blockSize_ = arguments.blockSize;
        data_.resize(size);
        count_ = (data_.size() + blockSize_ - 1) / blockSize_;
        tasks_ = std::make_unique<EncodeTask[]>(count_);

        // The reader forks the blocks; main only writes. Waiting for a task that
        // the reader has not started yet is fine: the wait ends when it has run.
        scheduler.AddTask(&reader_);
        std::uint32_t crc = 0;
        bool          ok  = true;
        for (std::size_t i = 0; i < count_; ++i) {
            tasks_[i].Wait();
            crc = WriteBlock(output, tasks_[i].encoded, crc, ok);
        }
        reader_.Wait();
        input_.Close(); // no read is pending any more
        return WriteFooter(output, crc) && ok && readOk_;
    }

    // Runs as a task: reads block after block and forks its encoding at once.
    // Read suspends the task, not the thread, while the disk works.
    void Read() noexcept
    {
        for (std::size_t i = 0; i < count_; ++i) {
            const std::size_t offset = i * blockSize_;
            const auto        wanted
                = static_cast<DWORD>(std::min(blockSize_, data_.size() - offset));
            DWORD      read  = 0; // DWORD: Read's out parameter
            const auto error = input_.Read(&data_[offset], wanted, read, offset);
            if (Error::Failed(error) || read != wanted) {
                // Main waits for every block, so it still gets one: an empty
                // one. Chapter 9 handles errors properly.
                readOk_ = false;
                read    = 0;
            }
            tasks_[i].input = std::span(data_).subspan(offset, read);
            scheduler_->AddTask(&tasks_[i]);
        }
    }

private:
    TC::ITaskScheduler*           scheduler_ = nullptr;
    AsyncFile                     input_;
    std::size_t                   blockSize_ = 0;
    std::size_t                   count_     = 0;
    std::vector<std::byte>        data_;
    std::unique_ptr<EncodeTask[]> tasks_;
    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;

    const std::uint64_t fileSize = File::OpenRead(arguments.input).Size();
    const std::uint64_t blocks
        = (fileSize + arguments.blockSize - 1) / arguments.blockSize;
    if (blocks > TC::MaximumTaskQueueCapacity) {
        std::printf("%llu blocks are too many; choose larger blocks\n",
                    static_cast<unsigned long long>(blocks));
        return 1;
    }

    TC::ConvertThreadToTask();
    // AsyncFile completes its reads on the core's I/O thread.
    if (!TC::AsyncIo::EnableAsyncIo())
        return 1;

    TC::SchedulerInfo info{};
    info.numberOfThreads   = arguments.threads;
    info.taskQueueCapacity = std::max(static_cast<std::uint32_t>(blocks),
                                      TC::DefaultTaskQueueCapacity);
    TC::ITaskScheduler* scheduler
        = TC::CreateTaskScheduler(TC::SchedulerKind::Default, &info);
    if (scheduler == nullptr)
        return 1;
    scheduler->Start();

    bool packed = false;
    {
        // Lives until after Stop, and its file is gone before DisableAsyncIo.
        Packer          packer;
        const Stopwatch total;
        packed = packer.Pack(*scheduler, arguments);
        std::printf("time: %.2f s\n", total.Seconds());
        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