Capy Review
Rainer Deyke via Boost <[email protected]> Wed, 24 Jun 2026 16:07:15 +0200
| Newsgroups | gmane.comp.lib.boost.devel |
|---|---|
| Message-ID | <[email protected]> |
On 6/20/26 01:08, Jeff Garland via Boost wrote:
> The Boost Formal Review of the *Corosio* and *Capy* libraries will
> begin on *June
> 23, 2026* and will conclude on *July 7, 2026*.
Here is my formal review of Capy. It's going to be a long one, but I
think it's important that all of these things be said. To make it
easier to read, I have divided the review into several section with
cross-references between them. Summary and verdict at the bottom.
+------------+
| Background |
+------------+
I recently started a project using a pre-review version of Capy as a
test run of Capy. The project, part of a bigger project, is an
asynchronous texture loader. It consists of the following parts:
- An asynchronous filesystem wrapper. I used SDL3's asynchronous IO
functions as the base layer. I just needed to wrap this in the
appropriate awaitables.
(Why not use Corosio instead? For one thing, I never even got Corosio
to build before the review period.)
- An asychronous archive reader. My textures are in a zip-like
archive. I need to open the archive, read the index, support concurrent
asychronous read operations on the archive, and close the archive
afterwards.
- The actual data parsing. This is handled by libwebp. It is also
the main opportunity for actual parallel execution.
- Uploading the texture images to the GPU as OpenGL textures. This
step must be performed on the main thread due to an OpenGL limitation.
(Why not use Vulkan? Because I'm also targeting web platforms through
Emscripten, which means OpenGL ES emulation through WebGL. And because
I'm running on Linux, so I don't even have the option of using WebGPU.)
I figured this would work as a suitable test for Capy. It's just far
enough removed from Capy's main area of competency to be interesting.
Well, long story short, I wasn't able to implement even the first step
in Capy (see The Fatal Flaw below). Instead, I used TooManyCooks, a
competitor to Capy, and it worked. However, I think I can still use
this experience as a basis for reviewing Capy. I will be making several
comparisons to TooManyCooks; this is not an endorsement of that library.
I will be using a documentation-first approach for this review. In
other words:
- I will assume that the documentation is correct and complete unless
proven otherwise. Anything not documented is assumed to be an
implementation detail.
- If the documentation and the code differ, I will consider this an
error in the code, not the documentation.
(Disclaimer: I started familiarizing myself with Capy three weeks ago,
and read the entire documentation at that time. I constantly referred
to the current documentation while writing this review, but I didn't
reread the entire documentation again within the review period, so it's
possible that I missed some recent additions.)
+----------------+
| The Fatal Flaw |
+----------------+
Let's start with the problem that prevented me from using Capy at all.
In order to wrap SDL's asynchronous io functions, I needed to implement
not just a custom awaitable, but a custom IoAwaitable. Because Capy
doesn't support plain awaitables at all. More on that below. Well,
Capy provides a nice block of example code on how to implement an
IoAwaitable on
<https://develop.capy.cpp.al/capy/4.coroutines/4d.io-awaitable.html>.
The only problem is that this example code does not work at all. It
doesn't even compile. Here is the code I tried to compile:
import std;
import "boost/capy.hpp";
using namespace boost::capy;
using result_type = std::string;
void start_operation() {}
struct my_awaitable
{
io_env const* env_ = nullptr;
std::coroutine_handle<> continuation_;
result_type result_;
bool await_ready() const noexcept
{
return false; // Or true if result is immediately available
}
std::coroutine_handle<> await_suspend(std::coroutine_handle<> h,
io_env const* env)
{
// Store pointer to environment, never copy
env_ = env;
continuation_ = h;
// Start async operation...
start_operation();
// Return noop to suspend
return std::noop_coroutine();
}
result_type await_resume()
{
return result_;
}
private:
void on_completion()
{
// Resume on caller's executor
env_->executor.dispatch(continuation_);
}
};
The first five lines were added by me to fix the obvious compile errors;
the rest is the example code from the web page copied verbatim.
Here are the exact error messages I got:
../../../programs/capy_test/io_awaitable_test.cpp: In member function
'void my_awaitable::on_completion()':
../../../programs/capy_test/io_awaitable_test.cpp:41:33: error: cannot
convert 'std::__n4861::coroutine_handle<void>' to
'boost::capy::continuation&'
41 | env_->executor.dispatch(continuation_);
| ^~~~~~~~~~~~~
| |
|
std::__n4861::coroutine_handle<void>
In file included from libs/capy/install/include/boost/capy/ex/io_env.hpp:14,
from
libs/capy/install/include/boost/capy/concept/io_awaitable.hpp:15,
from libs/capy/install/include/boost/capy/task.hpp:15,
from libs/capy/install/include/boost/capy/io_task.hpp:14,
from libs/capy/install/include/boost/capy.hpp:24,
of module ./libs/capy/install/include/boost/capy.hpp, imported at
../../../programs/capy_test/io_awaitable_test.cpp:2:
libs/capy/install/include/boost/capy/ex/executor_ref.hpp:216:52: note:
initializing argument 1 of 'std::__n4861::coroutine_handle<void>
boost::capy::executor_ref::dispatch(boost::capy::continuation&) const'
216 | std::coroutine_handle<> dispatch(continuation& c) const
| ~~~~~~~~~~~~~~^
Now, you might be wondering, what's a boost::capy::continuation? It's
documented here:
<https://develop.capy.cpp.al/capy/reference/boost/capy/continuation.html>.
Basically it's a pair of the coroutine handle we were expecting from
the documentation and an otherwise undocumented implementation detail of
the executor. Based on the documentation, there is no way to get or
create a working boost::capy::continuation, so there is no way to create
a custom IoAwaitable.
I reported this problem three weeks ago:
<https://github.com/cppalliance/capy/issues/296>. It still hasn't been
fixed, or even replied to.
This alone is already reason enough to reject Capy. Without custom
(Io)awaitables, Capy is a toy library. Moreover, with such a glaring
flaw unfixed for three weeks, I have no confidence in the rest of the
library. How many more critical showstopper errors have I been unable
to uncover because I never made it that far?
This is a reject-level flaw.
+-------------------------+
| The IoAwaitable Problem |
+-------------------------+
Capy coroutines cannot co_await awaitables from other coroutine
libraries. Capy coroutines cannot co_await awaitables at all unless
these awaitables also model the IoAwaitable concept. Conversely,
writing a class to model the IoAwaitable concept inextricably links the
class to the Capy library. This is a literal truth and a deliberate
design choice. Using co_await in a capy::task on a regular awaitable
generates a compile error, by design.
That doesn't mean that it is completely impossible to combine coroutine
libraries, but it is quite difficult. Regular awaitables could
theoretically be wrapped in IoAwaitables, although custom IoAwaitables
are currently broken (see The Fatal Flaw above). Capy could do that for
you generically, although it doesn't (presumably as a deliberate design
decision). It is also possible to post non-Capy tasks to a non-Capy
executor from a Capy coroutine, and conversely, to post Capy tasks to a
Capy executor from non-Capy coroutines (but see Synchronization below).
What this comes down to is that Capy wants to be the only coroutine
library in your program. It's not so much a library as an
all-encompassing framework. And as a framework, it needs to be held to
a higher standard for completeness. A library can be augmented with
other, unrelated libraries. A framework can only be extended with
extensions written for that specific framework.
(Corosio is such an extension, and I should probably take it into
account here. But I won't, because I haven't examined Corosio in detail
yet, and because Corosio is a separate library that should be reviewed
separately from Capy.)
I do not believe that Capy meets this criteria at all. As a framework,
it is too restrictive to be useful. Details below.
+---------------+
| Running Tasks |
+---------------+
Running tasks requires a two-call syntax:
run_async(pool.get_executor())(compute());
The explanation for why it needs to happen is that the first call sets
thread-local state that the 'compute' function consumes. There is even
a warning about not caching the result of the first function call.
However, there is no warning about the far more likely problem of
precomputing the argument to the second call. What this means is that
innocent-looking functions like the following do not work correctly:
void run_on_pool(auto task) {
run_async(pool.get_executor())(task);
}
This is, in practice, a huge problem. Not just because it's
restrictive, but because there is no compile-time checking and the
restriction and its implications are so easy to forget about it when
you're writing code that is detached from the actual run_async. For
example, the following looks like it follows the rules at the run_async
call site, but actually doesn't:
boost::capy::task<void> computation_task;
boost::capy::task<void> compute() {
return std::move(computation_task);
}
run_async(pool.get_executor())(compute());
This is a serious problem. It's not unreasonable for an object to
expose the ability to enqueue tasks through a function instead of a
public thread pool variable like the run_on_pool function above.
However, I would like to see some discussion around this before I
declare this a reject-level flaw, because it's not clear to me how this
problem can be avoided without serious compromises elsewhere. (But
TooManyCooks doesn't have this problem and doesn't use the two-call
syntax at all, so there's that.)
+-----------------+
| Synchronization |
+-----------------+
Thread pools create some interesting synchronization problems, even
without the presence of coroutines. All multithreaded code requires
synchronization, but many of the standard synchronization techniques are
broken in the context of thread pools, and coroutines add their own
unique complications.
The standard library presents a rich set of thread synchronization
structures, which can be roughly divided into unordered (the mutex
family: two operation cannot run at the same time, but order between
them is irrelevant) and ordered (basically everything else: an operation
in one thread needs to wait for an operation in another thread to
complete). Unfortunately these types are designed under the assumption
that each "task" has its own thread, and they break down when this
assumption is violated. In normal threaded code, a thread can "yield"
by waiting on a synchronization object, allowing other threads to run.
In code using thread pools, the opposite is true: waiting on a
synchronization object locks up the thread, preventing other tasks from
running in that thread. This results in inefficient thread usage at
best and deadlocks at worst.
To illustrate the problem, consider this situation:
- Task A is waiting on a std::condition_variable for a notification
from task B.
- Task B is to perform some work, then notify task A via a
std::condition_variable.
- Our thread pool only has one thread. (The same problem exists in
larger thread pools, it just requires more tasks to manifest.)
- Task A is scheduled and runs. Task A locks up the thread waiting
for the std::condition_variable, and never returns control to the scheduler.
- Task B cannot be scheduled until task A returns control to the
scheduler. In other words, tasks A and B are mutually deadlocked.
What this means is that all of the synchronization types of the standard
library are unsafe by default in thread pool code. They can still be
used in limited cases, but each use requires some extra analysis as to
whether it is safe or not.
The unordered (mutex) family of synchronization types comes away
relative well, in connection with a gotcha of their own:
- A mutex from the standard library can only be unlocked by the same
thread that locked it. Unlocking by another thread is undefined behavior.
- A co_await call can silently transfer a coroutine to another thread.
- It is therefore categorically wrong to hold a mutex lock across a
co_await call.
- However, this also means that any correctly written task that holds
a mutex will keep running (i.e. not co_await, and therefore not return
control to the coroutine scheduler) so long as the lock is held.
- Waiting on a mutex from within a coroutine is therefore potentially
inefficient, but not inherently unsafe.
The ordered family of synchronization comes away much worse. It can be
used relatively safely when the sender is a coroutine and the receiver
isn't. It can technically be used when the receiver is a coroutine and
the sender isn't, but it really shouldn't, because it locks up threads
in the pool from doing other work. It must never be used when both the
sender and the receiver are coroutines, because this can lead to a
deadlock. Fortunately ordered synchronization is usually a lot less
important in coroutine code than in code using threads directly.
Ideally, for optimal thread utilization, a coroutine should never wait
for anything without going through co_await. That's why coroutines are
linked to asynchronous i/o instead of just calling synchronous i/o
functions from coroutines. This requires a set of synchronization
structures that are awaitable (or IoAwaitable, see The IoAwaitable
Problem above).
TooManyCooks
(<https://fleetcode.com/oss/tmc/docs/v1.6/control_structures/index.html>)
provides its own rich set of awaitable synchronization structures:
- tmc::atomic_condvar
- tmc::auto_reset_event
- tmc::barrier
- tmc::latch
- tmc::manual_reset_event
- tmc::mutex
- tmc::semaphore
Capy provides boost::capy::strand and boost::capy::async_mutex. The
former is functionally basically a subset of the former - nice to have,
but not a big deal when the latter is available.
Oh, and there's also boost::capy::async_event for ordered
synchronization. Missed that one on the first read-through because it
only seems to appear in the reference section.
In addition, TooManyCooks provides a simple way for a coroutine to
signal the (presumably non-coroutine) context from which it was posted:
tmc::post_waitable, which returns a std::future. This allows the
calling code to wait for a specific coroutine to finish and retrieve its
return value. The closest Capy equivalent I can find is
boost::capy::thread_pool::join(), which waits for /all/ coroutines in a
pool to finish and discards their return values.
Interestingly /neither/ Capy /nor/ TooManyCooks provide the
synchronization structure that I really wanted for my archive reader
(see Background above). What I really wanted was coroutine version of
std::shared_mutex:
- While reading the archive index, I need exclusive access. None of
the files in the archive can be accessed until the index is read.
- The archive is opened as read-only, and read operations only need
shared access: they need to run after reading the index and before
closing the archive, but they can run concurrently with each other all
they want.
- For closing the archive I need exclusive access again. And, yes,
closing the archive is itself an asynchronous operation that needs to
run in a coroutine. That's how SDL_CloseAsyncIO works.
I ended up hacking together a working solution using a combination of
less powerful synchronization primitives including a std::atomic<int> to
count the active readers. It's not a good solution, and it violates the
principle that coroutines should only ever wait using co_await, but it
works for now.
There are actually five general types of synchronization to consider:
- Unordered between coroutines. (boost::capy::async_mutex)
- Unordered between coroutines and non-coroutines. (std::mutex
works, but blocks threads)
- Ordered: coroutine notifies coroutine. (boost::capy::async_event)
- Ordered: non-coroutine notifies coroutine. (also
boost::capy::async_event; async_event::set is synchronous)
- Ordered: coroutine notifies non-coroutine.
(std::condition_variable works and is safe in this direction;
tmc::post_waitable is nicer)
So I guess the argument can be made that Capy is /technically/ complete
and doesn't need any more synchronization structures because it covers
all five categories. But that's not an argument that I will be making.
I want an asynchronous shared_mutex.
This is not a reject-level flaw, but if I were to vote for the
acceptance of Capy, it would be with the condition of more work in this
area. Especially given Capy's framework status (see The IoAwaitable
Problem above).
+----------------------------+
| Running in the Main Thread |
+----------------------------+
As mentioned in Background above, my coroutines have an operation they
need to run in the main thread. Doing this in Capy requires the following:
int result = co_await boost::capy::run(main_thread_executor)
(
[=]() -> boost::capy::task<> {
// Code to run in main thread here
}
);
Control is returned to the original coroutine running on the original
executor, which can be useful sometimes, but is unnecessary work in my case.
The equivalent in TooManyCooks looks like this:
co_await tmc::resume_on(main_thread_executor);
// Code to run in main thread here
I prefer the latter: it is shorter, clearer, and performs less
unnecessary work. However, a bigger difference is hidden behind the
main_thread_executor identifier. In the TooManyCooks case, this is an
instance of tmc::ex_manual_st, an executor provided by TooManyCooks. In
the Capy case, I would have to write the executor myself. The process
for doing so is documented, but after the IoAwaitable situation (see The
Fatal Flaw above), I don't trust this documentation to match the
implementation.
In fact, Capy doesn't seem to provide /any/ executors beyond
thread_pool. TooManyCooks provides four of them, not including the
type-erased executor type one (which Capy does provide) and an adaptor
for (Boost.)ASIO.
Again, not a reject-level flaw, but I expect more from an
all-encompassing framework like Capy (see The IoAwaitable Problem above).
+-------------------+
| Memory Allocation |
+-------------------+
Using coroutines requires a lot of extra memory allocations, and I'm not
talking about the coroutine frame itself. Consider a function like this:
boost::capy::task<std::string> read_text_file(
std::string const &fname) {
SDL_AsyncIO *asyncio = SDL_AsyncIOFromFile(
fname.c_str(),
"r");
// No further use of fname beyond this point.
// Note that SDL_AsyncIOFromFile is a synchronous operation.
}
This is perfectly safe if the function is called and the resulting task
is co_awaited within another coroutine. It is not safe if the function
is called through boost::capy::run_async, because the string is passed
in by reference and the original string may no longer exist when the
coroutine starts execution.
I'm honestly not sure what can be done about this. The same problem
exists in TooManyCooks. But one thing I have noticed is that the
problem exists in part because boost::capy::task is inherently lazy.
Consider an eager alternative:
boost::capy::eager_task<std::string> read_text_file(
std::string const &fname) {
SDL_AsyncIO *asyncio = SDL_AsyncIOFromFile(
fname.c_str(),
"r");
co_yield; // Initial suspend
// At this point fname is a dangling reference,
// but we no longer need it. If we did need it,
// we could have created our own copy before the
// initial yield.
}
Because the task is eager, it has a chance to use or copy its
by-reference arguments before they go out of scope.
This is honestly a bit of a nitpick. I do not consider it a condition
for acceptance.
+-------------------+
| More Nits to Pick |
+-------------------+
Using PascalCase for concepts is inconsistent with the standard library,
and I don't like it. It does, however, have precedence in Boost, so
maybe I shouldn't say anything about it. Here I am, saying something
about it anyway.
WriteSource is useful for writing a complete file in one go. ReadSource
is only useful for reading a complete file in one go when the size of
the file is known beforehand - in other words, seldom to never. A
concept that reads the entire file into a single dynamically allocated
block of memory and then passes ownership of that block of memory to its
caller is clearly needed. I have no use for partially read texture
files, and libwebp doesn't accept them. (Yes, this is requires extra
memory allocations, but see Memory Allocation above. And pushing the
extra memory allocations onto the consumer doesn't eliminate them.)
On the other hand, I don't know why these concepts are a part of Capy in
the first place. As far as I can tell, nothing in Capy uses or models
these concepts. They seem to be Corosio concepts that exist in Capy
purely to encourage Capy users that don't use Corosio to write
Corosio-compatible code. (This applies mostly to the Stream/Source/Sink
concepts, but the buffer concepts also appear to be isolated from the
rest of Capy, even if Capy does provide concrete types that model them
and algorithms to operate on them.)
The Echo Server with Corosio
(<https://develop.capy.cpp.al/capy/8.examples/8i.echo-server-corosio.html>)
should be moved to the Corosio documentation - especially if Capy is
accepted into Boost and Corosio is not. There's a reason why Capy and
Corosio are separate libraries, and having an example that uses Corosio
in the Capy documentation breaks this separation.
+----------------------------------------+
| Specific Answers to Specific Questions |
+----------------------------------------+
> 1. What is your evaluation of the usefulness of the libraries?
This is a review of Capy only. I may do another one of Corosio later,
but it will be a lot shorter. So I will be answering all questions as
if they applied to Capy only.
I think that the problem Capy is trying to solve is a very important
one, and I find its competitor TooManyCooks very useful despite its
flaws. Unfortunately, Capy fails to solve this problem in a productive
way: it cuts you off from the outside world of coroutine utilities, then
doesn't give you enough tools to operate within this restriction. That
it doesn't even give you the means to create your own tools (see The
Fatal Flaw above) is just the icing on top.
> 2. What is your evaluation of the design?
I think it tries very hard to be clever and efficient at a high cost of
flexibility. This design makes sense for a component of a
self-contained program, or maybe a completely invisible implementation
detail of a library, but not for a public library in its own right. The
interface is both highly brittle (see Running Tasks above) and highly
restrictive (see The IoAwaitable Problem above).
> 3. What is your evaluation of the implementation?
> 4. What is your evaluation of the documentation?
One or the other (or possibly both) is flawed to the point of
unusability (see The Fatal Flaw above).
> 5. Have you used either or both libraries? What was your experience?
I tried, but didn't get very far (see The Fatal Flaw above).
> 6. Are the libraries ready for inclusion in Boost?
No, absolutely not.
> 7. If not, what changes would you recommend before acceptance?
Make it possible to create custom IoAwaitables by following the
documentation. (This should hopefully be easy; I expect it to be done
before the end review period.)
Either bring the library up to approximate feature parity with
TooManyCooks, or allow its coroutines to interact with standard
awaitables, or better yet, both. (I expect both of these to be hard, to
the point where it wouldn't surprise me if it never happened.)
(To be fair, there are areas where Capy is ahead of TooManyCooks, like
stop token propagation and exception handling.)
> 8. Do the libraries fit well within the existing Boost ecosystem?
Capy neither cooperates with existing
> 9. Are there API, naming, usability, extensibility, or implementation
> concerns that should be addressed?
I'm still not happy about using PascalCase for concepts.
+---------+
| Summary |
+---------+
I think that Capy in its current state is a trap. It cuts you off from
the wider world of C++ coroutine libraries (see The IoAwaitable Problem
above), while not providing sufficient functionality on its own (see
Synchronization and Running in the Main Thread above) and not even
giving you the tools to build your own functionality (see The Fatal Flaw
above). I believe that the only people who can successfully use Capy in
its current state are those with write access to its GitHub, and
possibly those using libraries, written by people with write access to
the Capy GitHub, that only use Capy as an implementation detail.
Everybody else will eventually be forced to abandon it eventually for
its restrictions.
I therefore vote to REJECT Capy in its current state from inclusion in
Boost.
Although a lot of this review hinges on a specific, presumably not all
that hard to fix bug (see The Fatal Flaw above), this is not my only
reason for voting to reject Capy. I fully expect that particular bug to
be fixed before the review period ends. I do not expect a fix for the
twin problems of Capy trying to be an all-encompassing coroutine
framework and falling completely short of that goal, which is ultimately
just as important.
--
Rainer Deyke - [email protected]
_______________________________________________
Boost mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://lists.boost.org/mailman3/lists/boost.lists.boost.org/
Archived at: https://lists.boost.org/archives/list/[email protected]/message/RNXXUYVBKMI5I5SIG6TODIHVIJH7C6ON/