What’s Missing?#
Generators#
TooManyCooks does not currently provide a coroutine generator type.
Exceptions and Coroutines#
TooManyCooks does not support propagating exceptions out of a tmc::task.
You must catch exceptions within the coroutine where they are thrown.
Awaitables can throw exceptions, but tasks cannot.
// This is fine: the exception is caught where it is thrown
tmc::task<void> handling_exceptions {
try {
co_await potentially_throwing_awaitable{};
} catch (std::runtime_error const& ex) {
// Do something about it
}
}
// This is not supported: the exception is not caught.
// User attempts to propagate the exception up to the parent coroutine.
tmc::task<void> unhandled_exceptions {
co_await potentially_throwing_awaitable{};
}
tmc::task<void> parent {
try {
co_await unhandled_exceptions();
} catch (std::runtime_error const& ex) {
// Doesn't work
}
}
Why Not Exceptions? - Performance#
Exceptions in C++ are typically “zero cost” when they are not thrown. However, this is not true in C++20 coroutines!
To propagate an exception out of a coroutine to its parent, we must have pre-allocated space
for an exception pointer, and check whether an exception exists there when resuming the parent.
Thus, propagating exceptions out of C++20 coroutines incur the same penalty on the hot path as
if you were handling them manually using a std::expected.
This penalty would exist for every coroutine, even those that cannot throw.
Additionally, there is a question of how to handle exceptions in a fork-join context. For example, if multiple child tasks throw an exception, what should we do? Some other libraries handle this by only re-throwing the first child exception, but that doesn’t allow you to handle all failure scenarios.
Handling all exceptions from N forked child tasks requires pre-allocating space for N exception pointers, even if none of them throw. This is a substantial memory penalty that all library users would have to pay.
Why Not Exceptions? - Safety#
TooManyCooks allocates storage in the parent’s frame for child tasks to place their results. This means that the parent must outlive its child tasks. If a forked child task has not been joined, and its parent task throws an exception and is terminated, that child task would have nowhere to place its result. This is an additional footgun that users would need to manually work around, by ensuring that potentially-throwing operations are not called before joining any forked child tasks.
Why Not Exceptions? - Usability#
Exceptions already make it more difficult to reason about code by hiding away explicit control flow from the user. When exceptions are in the mix, you must assume that any function you call can jump out of your current function to the parent function catch block, many levels higher.
Adding concurrency and parallelism to the equation with coroutines further complicates understanding of the control flow, and increases the risk of issues arising with async lifetimes.
I strongly recommend using std::expected instead for operations that might fail.