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_;
}
// ...
};mainwaits 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
mainis never left waiting for a task that will not run. The output is then incomplete, andPackreports the error. Chapter 6 ends the pipeline cleanly instead. mainwaits 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
OKThe 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;
}