Why Capy?
Capy makes co_await the only way to do asynchronous work. There are no callbacks, no futures, and no sender/receiver layer to reconcile.
That restriction buys a guarantee the hybrid libraries cannot make: a coroutine always resumes on the executor it was started with. State you touch between two suspension points needs no mutex, because nothing else can run there. An awaitable that could resume you on any thread is rejected at compile time.
#include <boost/capy/buffers/make_buffer.hpp>
#include <boost/capy/io/any_stream.hpp>
#include <boost/capy/task.hpp>
using namespace boost::capy;
// Every resumption happens on the executor this task was started with, so
// `count` needs no mutex: nothing else can run between the suspension
// points.
task<> count_messages(any_stream& stream, int& count)
{
char buf[64];
for(;;)
{
auto [ec, n] = co_await stream.read_some(make_buffer(buf));
if(ec)
break;
++count;
}
}
What Capy Is Not
Capy is not a networking library. It has no sockets, no acceptors, no DNS, no TLS, and no platform event loop. Those belong to Corosio, which is built on Capy.
Capy is the execution model and the byte-stream layer beneath them. It also stands alone for work that never touches the network: HTTP parsing, protocol state machines, serialization.
Where Capy Advances Beyond Asio
Boost.Asio is currently the world leader in portable asynchronous I/O. The standard is silent here. The global ecosystem offers nothing comparable.
Capy advances beyond Boost.Asio in several specific domains, and the sections below take them one at a time.
Coroutine-Only Stream Concepts
When Asio introduced AsyncReadStream and AsyncWriteStream, it was revolutionary. For the first time, C++ had formal concepts for buffer-oriented I/O. You could write algorithms that worked with any stream—TCP sockets, SSL connections, serial ports—without knowing the concrete type.
But Asio made a pragmatic choice: support every continuation style. Callbacks. Futures. Coroutines. This "universal model" meant the same async operation could complete in any of these ways. The implementation had to handle all cases. Optimizations specific to one model were off the table.
Capy makes a different choice. It commits fully to coroutines. When you know the continuation is always a coroutine, you can optimize in ways that hybrid approaches cannot. The frame is always there. The executor context propagates naturally. Cancellation flows downward without ceremony.
What Capy Offers
-
ReadStream,WriteStream,Stream— partial I/O (returns what’s available)
Type-Erasing Stream Wrappers
Every C++ developer who has worked with Asio knows the pain. You write a function that accepts a stream. But which stream? tcp::socket? ssl::stream<tcp::socket>? websocket::stream<ssl::stream<tcp::socket>>? Each layer wraps the previous one, and the type grows. Your function signature becomes a template. Your header includes explode. Your compile times suffer. Your error messages become novels.
Asio does offer type-erasure—but at the wrong level. any_executor erases the executor. any_completion_handler erases the callback. These help, but they don’t address the fundamental problem: the stream type itself propagates everywhere.
Why hasn’t anyone type-erased the stream? Because with callbacks and futures, it’s expensive. The completion handler type is part of the stream’s operation signature. Erasing it means virtual calls on the hot path—for every continuation, not just every I/O operation.
Coroutines change this equation. A coroutine’s continuation is always the same thing: a handle to resume. The caller doesn’t need to know what type resumes it. This is structural type-erasure—built into the language. Type-erasing a stream costs five indirect calls per I/O operation, not one per continuation: construct the awaitable, await_ready, await_suspend, await_resume, destroy it. A synchronously completed read costs four, because await_suspend is skipped when await_ready returns true. No per-callback overhead. No template instantiation cascades.
The same protocol function then runs against an in-memory test stream, a TCP socket, or a TLS stream without changing a line. That is what makes the testing section below possible: the code under test is the code you ship.
Write any_stream& and accept any stream. Your function compiles once. It links anywhere. Your build times drop. Your binaries shrink. Your error messages become readable. And because coroutines are ordinary functions (not templates), you get natural ABI stability. Link against a new stream implementation without recompiling your code.
What Capy Offers
-
any_read_stream,any_write_stream,any_stream— type-erased partial I/O -
read,write— algorithms that work with erased or concrete streams
Buffer Sequences
Asio got buffer sequences right. The concept-driven approach—ConstBufferSequence, MutableBufferSequence—enables scatter/gather I/O without allocation. You can combine buffers from different sources and pass them to a single write call. The operating system handles them as one logical transfer.
We adopt Asio’s buffer sequence model because it works.
But we improve on it. Need to trim bytes from the front of a buffer sequence? Asio makes you work for it. Capy provides buffer_slice and front—byte-range slicing primitives for efficient byte-level manipulation. Need to compose two buffers without copying? Use std::array<const_buffer, 2> (or any range of buffers) directly — Capy’s buffer-sequence concepts accept arbitrary ranges.
std::ranges cannot help here. ranges::size returns the number of buffers, not the total bytes. Range views can drop entire elements, but buffer sequences need byte-level trimming. Buffer sequences need their own concepts.
What Capy Offers
-
ConstBufferSequence,MutableBufferSequence— core concepts (Asio-compatible) -
buffer_slice,front— byte-level manipulation utilities
Comparison
Capy adopts ConstBufferSequence, MutableBufferSequence, const_buffer and mutable_buffer from Asio unchanged. What it adds is byte-level slicing:
| Capy | Asio |
|---|---|
- |
|
- |
|
|
|
Byte-level trimming |
- |
Coroutine Execution Model
When you write a coroutine, three questions arise immediately. Where does it run? How do you cancel it? How is its frame allocated?
Where does it run? A coroutine needs an executor—something that schedules its resumption. When coroutine A awaits coroutine B, B needs to know A’s executor so completions dispatch to the right place. This context must flow downward through the call chain. Pass it explicitly to every function? Your APIs become cluttered. Query it from the caller’s promise? Your awaitables become tightly coupled to specific promise types.
How do you cancel it? A user clicks Cancel. A timeout expires. The server is shutting down. Your coroutine needs to stop—gracefully, without leaking resources. C++20 gives us std::stop_token, a beautiful one-shot notification mechanism. But how does a nested coroutine receive the token? Pass it explicitly? More API clutter. And what about pending I/O operations—can they be cancelled at the OS level, or do you wait for them to complete naturally?
How is its frame allocated? Coroutine frames live on the heap by default. For high-throughput servers handling thousands of concurrent operations, allocation overhead matters. You want to reuse frames. You want custom allocators. But here’s the catch: the frame is allocated before the coroutine body runs. The allocator can’t be a parameter—parameters live in the frame.
Asio has answers to these questions, but they’re constrained. Asio must support callbacks and futures alongside coroutines. It cannot build an execution model optimized for coroutines alone. And it bundles everything together—execution model, networking, timers, platform abstractions—in one monolithic library.
The standard has std::execution (P2300), the sender/receiver model. It’s powerful and general. It’s also complex, academic, and not designed for coroutines first. It has the "late binding problem"—allocators flow backward, determined at the point of connection rather than at the point of creation. Ergonomic allocator control is difficult. P3552R3 proposes a task type, but it’s built on sender/receiver and inherits its limitations.
Capy builds an execution model purpose-built for coroutines and I/O.
The IoAwaitable protocol solves context propagation. When you co_await, the caller passes its executor and stop token to the child through an extended await_suspend signature. No explicit parameters. No promise coupling. Context flows forward, naturally.
Stop tokens propagate automatically. Cancel at the top of your coroutine tree, and every nested operation receives the signal. Capy integrates with OS-level cancellation—CancelIoEx on Windows, IORING_OP_ASYNC_CANCEL on Linux. Pending I/O operations cancel immediately.
Frame allocation uses forward flow. The two-call syntax of run_async(executor)(my_task()) sets a thread-local allocator before the task is evaluated. The task’s operator new reads it. No late binding. No backward flow. Ergonomic control over where every frame is allocated.
And Capy separates execution from platform. The execution model—executors, cancellation, allocation—lives in Capy. Platform abstractions—sockets, io_uring, IOCP—live in Corosio. Clean boundaries. Testable components. You can use Capy’s execution model with a different I/O backend if you choose.
Most importantly, Capy defines a taxonomy of awaitables. IoAwaitable is the base protocol for any type that participates in context propagation. IoRunnable refines it with the interface that run_async and run need to start a task. This hierarchy means you can write your own task types that integrate with Capy’s execution model. Asio’s awaitable<T> is a concrete type, not a concept. You use it or you don’t. Capy gives you building blocks.
Neither Asio nor std::execution offers this combination of forward-flow allocator control, automatic stop-token propagation, and execution/platform separation.
What Capy Offers
-
IoAwaitable,IoRunnable— taxonomy of awaitable concepts -
task<T>— concrete task type implementing the protocol (user-defined tasks also supported) -
run,run_async— launcher functions with forward-flow allocator control -
strand,thread_pool,async_mutex,async_event,async_waker: concurrency primitives -
frame_allocator,recycling_memory_resource— coroutine-optimized allocation
Comparison
strand, thread_pool and execution_context carry the same names and the same roles they do in Asio. What follows is what differs:
| Capy | Asio | std |
|---|---|---|
- |
- |
|
- |
- |
|
- |
- |
|
|
P3552R3** |
|
- |
- |
|
|
- |
|
|
- |
|
|
- |
- |
- |
- |
|
- |
- |
|
- |
- |
|
- |
- |
|
|
- |
|
User-defined task types |
- |
- |
Execution/platform isolation |
- |
- |
Forward-flow allocator control |
- |
- |
*Asio’s are not extensible, no concept taxonomy
**P3552R3 is sender/receiver based, has allocator timing issue
***std has the token but no automatic propagation
You Do Not Have to Choose
Capy’s protocol is a vocabulary type. Independent libraries interoperate through it without anyone writing pairwise adapters. Without a shared protocol, N coroutine libraries need N×(N−1) adapters between them.
Foreign asynchronous code reaches Capy through a small I/O awaitable that captures the caller’s executor and re-posts the resumption through it. Two worked bridges ship with the library: a P2300 sender and an Asio operation through the use_capy completion token.
Adopting Capy does not mean abandoning what you already run.
Deterministic Testing
Asynchronous code is hard to test. The failures that matter are timing-dependent, and the transport is external to the test.
test::run_blocking drives a coroutine to completion on the calling thread. Each call builds a private single-threaded loop, so a test needs no executor and no thread pool of its own.
test::stream is a connected in-memory pair implementing the same partial-I/O concepts a socket does. Protocol code written against any_stream& runs against it unchanged, so the code under test is the code you ship.
test::fuse repeats the test body in two full sweeps, error-code mode and then exception mode, injecting a failure at each site that can fail. Error paths get taken rather than assumed. test::bufgrind iterates every split point of a buffer sequence, so every chunk-boundary condition is exercised.
None of this needs a network, a port, or a sleep.
What Capy Offers
-
test::run_blocking— drive a coroutine to completion from a test -
test::stream,test::read_stream,test::write_stream— in-memory mocks -
test::fuse— inject a failure at each failure site -
test::bufgrind,test::buffer_to_string— exercise and assert on every split
What You Give Up
These are trade-offs, and they cost something.
-
You need a second library for networking. Sockets, acceptors, DNS and TLS are Corosio’s job, not Capy’s.
-
C++20 or nothing. GCC 12, Clang 17, MSVC 14.34 or later. There is no fallback for older compilers.
-
Coroutine-only means coroutine-only. Callback and future APIs do not compose with Capy directly. Foreign work needs the bridge described above.
-
It is a compiled library. Capy is not header-only, so consumers link it.
-
Asio has two decades of production use and Capy does not. Where Asio’s behavior is settled by years of field reports, Capy’s is settled by design and by tests.
Each of these is the direct cost of something above. Read them together.
The Road Ahead
Boost.Asio set the standard for portable asynchronous I/O in C++ more than two decades ago, and holds it still. It defined the promising Networking TS. Asio earned its place through years of production use, careful evolution, and relentless focus on real problems faced by real developers.
Capy builds on Asio’s foundation—the buffer sequences, the executor model, the hard-won lessons about what works. But where Asio must preserve compatibility with decades of existing code, Capy is free to commit fully to the future. C++20 coroutines are not an afterthought here. They are the foundation.
The result is something new. Stream concepts designed for coroutines alone. Type-erasure at the level where it matters most. A simple execution model discovered through use-case-first design. Clean separation between execution and platform. A taxonomy of awaitables that invites extension rather than mandating a single concrete type.
Meanwhile, the C++ standards committee has produced std::execution—a sender/receiver model of considerable theoretical elegance. It is complex, and its relationship to the I/O problems that most C++ developers face daily remains unclear.
Boost has always been where the practical meets the principled. Where real-world feedback shapes design. Where code ships before papers standardize. Capy continues this tradition.
This library advances beyond Asio in the domains where they overlap. Not by abandoning what works, but by building on it. Not by chasing theoretical purity, but by solving the problems that have frustrated C++ developers for years. Those problems are template explosion, compile-time costs, error message novels, ergonomic concurrency, and more.
Capy is already the foundation for work that does not look alike. CERN’s traccc project uses Capy without Corosio for GPU reconstruction pipelines. The Http parser is built entirely on Capy’s byte streams. Corosio, Websocket, Beast2 and Burl build the networking stack on top of it.
Capy has no external dependencies and does not require Boost.