Coros
An easy-to-use and fast library for task-based parallelism, utilizing coroutines.
Install / Use
/learn @mtmucha/CorosREADME
Coros is a header-only C++23 library designed for task-based parallelism, that utilizes coroutines and the new expected type. Key features include:
- Ease of use: Straightforward interface and header-only installation.
- Performance: Optimized to ensure you don't miss out on performance (see benchmarks below).
- Exception handling: Utilizes
std::expectedfor error management. - Monadic operations: Supports easy chaining of tasks with
and_thenmethod.
Transforming a standard sequential function into a parallel task with Coros is as simple as:
<p align="center"> <picture> <source media="(prefers-color-scheme: dark)" srcset="images/what_black.png"> <source media="(prefers-color-scheme: light)" srcset="images/what_white.png"> <img alt="Shows an illustrated sun in light mode and a moon with stars in dark mode." src="images/what_black.png" width="80%"> </picture> </p>Benchmarks
We have conducted two benchmarks to evaluate the performance of our library:
- Fibonacci Calculation: Calculating the 30th Fibonacci number to assess the scheduling overhead.
- Matrix Multiplication: A standard workload that tests overall performance.
Calculating Fibonacci number
<p align="center"> <a> <img src="images/graph_w1.png" alt="Code Screenshot 1" width="48%" style="margin-right: 10px;"> </a> <a> <img src="images/graph_w2.png" alt="Code Screenshot 2" width="48%" style="margin-left: 10px;"> </a> </p>Matrix multiplication
<p align="center"> <a> <img src="images/coros_graphs_13.png" alt="Code Screenshot 1" width="48%" style="margin-right: 10px;"> </a> <a> <img src="images/coros_graphs_14.png" alt="Code Screenshot 2" width="48%" style="margin-left: 10px;"> </a> </p>Documentation
Using the Coros library is straightforward, there are three main components:
coros::ThreadPool: As the name suggests, this is a thread pool that manages a desired number of worker threads and executes individual tasks.coros::Task<T>: This object serves as the task container, which must be used as the return value from tasks. The T parameter specifies the type the task returns. For a simple example, refer to the accompanying image.- Awaiters: These are objects that support the
co_awaitoperator, allowing them to be awaited within tasks. They are typically used for control flow, for example, waiting for other tasks to finish.
For additional details about the library, refer to the documentation provided below and check out the examples in the example folder.
- Documentation
- Installation
- Creating tasks and starting execution
- Waiting for other tasks
- Enqueueing tasks
- Chaining tasks
Installation
To install the Coros library, simply place the include folder into your project directory and set the include path. There are four key headers you can include:
#include "start_tasks.h": Provides the functionality to set up tasks, thread pool object, and launch task execution from the main thread.#include "wait_tasks.h": Enables suspension of individual tasks while waiting for others to complete.#include "enqueue_tasks.h": Allows for the enqueuing of tasks into a thread pool without awaiting their completion.#include "chain_tasks.h": Supports chaining of tasks, this chain is then executed on a thread pool.
To compile the library, ensure your compiler supports C++23 feature std::expected. Compatible compilers:
- GCC 13 or newer
- Clang 17 or newer
- MSVC (not yet supported)
Do not forget to enable coroutines for given compiler, for example -fcoroutines for GCC.
[!NOTE] The library uses
std::hardware_destructive_interference_sizeif supported by the compiler. You can also set this value manually by passing a flag to the compiler, or you may choose to ignore it. This is used as an optimization to avoid false sharing.
Creating tasks and starting execution
To set up a task and start parallel execution the necessary steps are:
- Construct a
coros::ThreadPool: This will be the execution environment for your tasks. - Construct tasks using
coros::Task<T>: Define the tasks and create a task object. These tasks can be run on the thread pool object. - Start execution: Use
coros::start_syncorcoros::start_asyncto initiate execution from the main thread.
coros::start_sync/coros::start_async are functions designed to start parallel execution from the main thread.
To create coros::Task<T>, coros::ThreadPool or use coros::start_sync/coros::start_async
include the #include "start_tasks" header.
coros::Task<T>
Task is a coroutine(added in C++20) that returns a coros::Task<T> object. To
transform a regular function into a coroutine, instead of return keyword a co_return
keyword must be used. And to transform a coroutine into a task a coros::Task<T> must
be a return type of the coroutine. Tasks support two keywords :
A coros::Task<T> is a coroutine object, a feature introduced in C++20. To convert a standard function into a coroutine, replace the return keyword with co_return.
Additionally, the function must specify coros::Task<T> as its return type to function as a task. Tasks support two keywords:
co_return: Use this keyword instead of return in your return statements.co_await: Use this for flow control with awaitable objects(objects that supportco_awaitoperator).
The return type T must satisfy constraint std::is_constructible<T,T>.
This requirement ensures that return values can be constructed from an r-value reference, utilizing either a move or a copy constructor.
This constraint arises because coroutine parameters themselves must also satisfy the std::is_constructible<T,T> condition.
coros::Task<int> add_one(int val) {
co_return val + 1;
}
int main() {
coros::ThreadPool tp{/*number_of_threads=*/2};
// Create the task object by calling the coroutine.
coros::Task<int> task = add_one(41);
// Wait until the task is finished, this call blocks.
coros::start_sync(tp, task);
// After this point, task is completed.
// Optional check for stored value.
if (task.has_value()) {
std::cout << "Result : " << *task << std::endl;
} else {
// It is possible to process the caught exception.
std::cout << "Task failed" << std::endl;
}
}
[!WARNING] While it's possible to use lambda coroutines to construct tasks, be cautious with captures and references. Best practice is passing values and references through coroutine parameters rather than captures to ensure safety and avoid unexpected behavior. For more detail see the C++ Core Guidelines.
Under the hood, coros::Task<T> employs std::expected<T, std::exception_ptr> to store the outcome of the coroutine/task.
This structure holds either a value, indicating successful completion, or an std::exception_ptr if an exception occurred.
For convenience, coros::Task<T> offers methods analogous to those of std::expected:
T value(): Accesses the stored value directly.std::exception_ptr error(): Retrieves the storedstd::exception_ptr.operator*(): Provides direct access to the result.T value_or(T defaultValue): Returns the stored value if the task contains a value; otherwise, it returns the specified default value.std::expected<T, std::exception_ptr> expected(): Returns the underlying std::expected object.operator bool(): Returns true if the task contains a successfully stored value.bool has_value(): Returns true if the task contains a successfully stored value.
[!NOTE] Methods
value()andoperator*()are not supported for specializationcoros::Task<void>.
coros::Task<T> supports the co_await operator, making it an awaitable object.
When a task is awaited using co_await, it behaves similarly to a regular function call: the awaited task executes and, upon completion, control returns to the calling task.
The difference is that this operation typically does not consume additional stack space, thanks to the coroutine-to-coroutine control transfer.
coros::Task<int> add_one(int val) {
co_return val + 1;
}
coros::Task<int> add_value(int val) {
coros::Task<int> another_task = add_one(val);
// Once the another task finishes, control is returned,
// this works like a regular function.
co_await another_task;
// Accesses the another_task's result and increments it by one.
// NOTE : check for the value is omitted.
co_return *another_task + 1;
}
coros::start_sync(coros::ThreadPool&, Tasks&&...)
To start tasks on a thread pool, you specify the desired thread pool and the tasks to be executed.
It is crucial that the coros::ThreadPool object outlives the execution of the tasks.
<summa
