Corral C++20 Structured Concurrency Library

repository·master·Indexed 42 days ago

https://github.com/hudson-trading/corral

A lightweight, header-only C++20 library providing structured concurrency for single-threaded, cooperative multitasking. Corral organizes tasks into a parent-child tree to ensure predictable resource management, exception propagation, and task cancellation. It is I/O agnostic and includes built-in support for Boost.Asio and standalone Asio via corral::asio_awaitable. Key features include task combiners like corral::allOf and corral::anyOf, dynamic task spawning via Nurseries, and a non-exception-based cancellation model.

Tokens
17.1K
Snippets
46
Records
60
Agent score
50%

What's inside Corral

  1. What is Corral and how does it work?

    master

    Corral is a lightweight C++20 library for cooperative, single-threaded multitasking using coroutines. It implements structured concurrency, meaning tasks are organized into a parent-child tree. Parents are responsible for:

    1. Waiting for all children to finish.
    2. Propagating exceptions raised by children.

    This design ensures that resource management, task cancellation, and error handling behave predictably. Because it uses cooperative multitasking in a single-threaded environment, you have exclusive access to all state between co_await points, reducing concurrency hazards. You can run multiple 'corral universes' by having multiple threads, each running its own independent corral instance, provided tasks in different threads do not interact.

  2. What is a Task in Corral?

    master

    A task is an independent thread of execution within Corral's cooperative multitasking environment.

    Key characteristics:

    • Cooperative Multitasking: Tasks are not OS threads. Only one task is actively executing at any given time. When a task hits a co_await point, it suspends, allowing other tasks to resume.
    • Concurrency vs. Parallelism: Corral is designed for concurrency (doing multiple things at once, like waiting on multiple sockets) rather than parallelism (speeding up CPU-bound work). It will not utilize more than 100% of a single CPU core.
    • Composition: Tasks are composed of other tasks or awaitables. A corral::Task<T> is a handle that wraps an invocation of an async function.
    • Awaitables: While corral::Task<T> is the primary way to create tasks, many primitive operations in Corral use custom awaitable objects for better performance (to avoid the heap allocation required by async function stack frames).
  3. Manage dynamic tasks with a Nursery

    master

    While combiners like allOf() require a known number of tasks, a Nursery allows for dynamic task spawning. A nursery runs a single initial task (defined via an async lambda) which can then spawn additional tasks by calling nursery.start().

    To use a nursery, you must use the CORRAL_WITH_NURSERY(n) macro. When the nursery block terminates, it must return one of two values:

    • corral::join: Waits for all child tasks to complete normally.
    • corral::cancel: Requests cancellation of all remaining child tasks.

    Nurseries are ideal for patterns like server accept() loops where the number of connections is unknown at compile-time.

    corral::Task<void> acceptorLoop(tcp::acceptor& acc) {
        CORRAL_WITH_NURSERY(n) {
            while (true) {
                tcp::socket sock = co_await acc.async_accept(io_context, corral::asio_awaitable);
                n.start(serve, std::move(sock));
            }
        };
    }
  4. Implement custom awaitables for event loop operations

    master
    Once EventLoopTraits is specialized, you can extend corral by defining custom awaitables. These awaitables wrap your event loop's specific operations, such as performing I/O, waiting for timeouts, or waiting for specific signals/events. The exact implementation of these awaitables depends on the specific API of your chosen event loop.
  5. Understand Structured Concurrency and the Task Tree

    master

    Corral enforces structured concurrency, meaning a task can only run when it is being awaited by another task. This creates a hierarchical task tree where every task has exactly one parent.

    Key properties of the task tree:

    • Exception Propagation: Unhandled exceptions in a child are re-raised in the parent. If unhandled throughout, they are raised out of corral::run().
    • Cancellation Propagation: Cancellation requests propagate down the tree. If a parent is cancelled, all its children are cancelled.
    • Automatic Cleanup: If a child of a nursery/combiner throws an exception, the parent automatically cancels all other children to propagate the exception quickly.
    • Scope Safety: Because tasks cannot be 'detached', you can safely use local C++ objects (like std::ifstream) across async calls, knowing the task will not outlive the object's scope.
  6. Manage child tasks with a Nursery

    master

    A Nursery provides a scope for running a dynamic number of child tasks. It is the core mechanism for structured concurrency in corral.

    Using CORRAL_WITH_NURSERY

    Use the CORRAL_WITH_NURSERY(nursery) macro within an async function to create a scoped nursery. The block must end with one of two return statements:

    • co_return corral::join;: Waits for all child tasks to exit.
    • co_return corral::cancel;: Cancels all child tasks and waits for them to exit.

    Starting Tasks in a Nursery

    1. Simple Start: nursery.start(callable, args...) starts a task that will run at the next co_await point. Arguments are moved into storage valid until task completion.
    2. Synchronous Start (with TaskStarted): nursery.start(callable, args..., TaskStarted<Ret>{...}) starts a task and suspends the caller until the task explicitly invokes the TaskStarted object. This allows the task to be 'reparented' to the nursery after its initial setup. Any exceptions in the child before this point are reraised in the caller.

    Nursery API

    • cancel(): Requests cancellation of all tasks in the nursery.
    • taskCount(): Returns the number of currently running tasks.
    • executor(): Returns the pointer to the nursery's executor (null after the nursery closes).
    Task<void> my_scope() {
        CORRAL_WITH_NURSERY(nursery) {
            nursery.start(some_async_work, arg1);
            nursery.start([](corral::TaskStarted<void> started) {
                // Task setup logic
                started(); 
            });
            co_return corral::join;
        }
    }
  7. Use UnsafeNursery to bridge async tasks to callback interfaces

    master

    When you need to implement a callback-style interface using async logic, but you do not have a structured concurrency parent (a nursery hierarchy) to manage the task lifetime, use corral::UnsafeNursery.

    Requirements and Risks

    • Exception Handling: If a task in an UnsafeNursery throws an exception, the program will call std::terminate(). You must wrap task logic in try/catch blocks and invoke the callback with an error code/value in the catch block.
    • Destruction: When an UnsafeNursery is destroyed, it must not have any active tasks. If tasks remain, the destructor attempts a synchronous cancellation. If tasks are not synchronously cancellable, an assertion failure occurs or undefined behavior follows.
    • Executor: Each UnsafeNursery creates its own corral::Executor. To interact with other tasks, ensure they all use the same event loop (identified via EventLoopTraits<...>::eventLoopID()).

    Managing Lifetime

    • Joining: You can adopt an unsafe nursery into structured concurrency by calling co_await unsafeNursery.join(). This task becomes the new parent and waits for all tasks to exit.
    • Asynchronous Destruction: Use asyncClose() to cancel tasks and provide a continuation callback that runs once the nursery is safe to destroy. This is useful for objects that need to be deleted from within a completion callback.
    class MyReader : public IReader {
        corral::UnsafeNursery nursery_;
    
        // Implementation using async logic
        corral::Task<ssize_t> readImpl(void* buf, size_t len);
    
      public:
        // Must specify the event loop explicitly
        MyReader(boost::asio::io_service& io) : nursery_(io) {}
    
        void read(void* buf, size_t len, std::function<void(ssize_t)> cb) {
            nursery_.start([=]() -> corral::Task<> {
                try {
                    ssize_t result = co_await readImpl(buf, len);
                    cb(result);
                } catch (std::exception&) { 
                    cb(-1); 
                }
            });
        }
    };
  8. Spawn tasks that outlive their spawning function

    master

    While Corral adheres to structured concurrency (where tasks are typically awaited by their parents), you can spawn tasks that outlive the function that started them by passing a corral::Nursery& reference to another function. As long as the nursery object remains alive (i.e., the CORRAL_WITH_NURSERY block has not completed), any function with access to that reference can spawn tasks into it.

    Complexity Degree via Signatures:

    • void func(): Synchronous; cannot spawn async tasks.
    • corral::Task<void> func(): Async; can spawn children but must join them before returning.
    • void func(corral::Nursery&): Can spawn tasks that continue running after the function returns.
    void delayedPrint(corral::Nursery& n) {
        n.start([]() -> corral::Task<> {
            co_await corral::sleepFor(io_service, 1s);
            std::cout << "Hello, world!" << std::endl;
        });
    }
    
    corral::Task<void> func() {
        CORRAL_WITH_NURSERY(n) {
            delayedPrint(n);
            co_return corral::join;
        };
    }
  9. How the awaiter state machine works

    master

    Corral manages awaitables through an implicit state machine that governs the order of method calls and handles transitions between readiness, suspension, and cancellation.

    Key States & Transitions:

    • Initial: The starting point. Can call await_early_cancel() or await_ready().
    • NotReady: The awaiter is not ready and must suspend. await_suspend() is called here.
    • Running: The operation is in progress. await_cancel() can be called to transition to Cancelling.
    • ReadyAfterCancel: The operation finished after a cancellation request. await_must_resume() is called to decide if the task should proceed normally or as a cancellation.
    • Done/Cancelled: Terminal states where the awaiter is destroyed.

    Debugging Tip: If you define #define CORRAL_AWAITABLE_STATE_DEBUG before including Corral headers, the state machine becomes explicit and enables runtime checks to ensure your awaiter follows these rules (note: this is slow and intended for debugging only).

  10. How cancellation propagates in the task tree

    master

    Cancellation in Corral follows a tree-based propagation model:

    1. Downwards Propagation (Request): When a task is cancelled, the request propagates down the tree to whatever it is currently awaiting. If the task is actively running or scheduled but not currently awaiting, the request is held until the task reaches an await point.
    2. Upwards Propagation (Result): When a task is successfully cancelled, the result propagates up the tree. The parent sees this as a third outcome: "completed by propagating cancellation" (distinct from returning a value or throwing an exception).

    Behavior by Parent Type

    • Async Functions: Respond to child cancellation by destroying local variables/parameters and propagating the cancellation to their own parent.
      • Note: You cannot use catch or catch (...) to handle cancellation, as it is not an exception. Use RAII (e.g., folly::ScopeGuard) for synchronous cleanup.
    • Combiners:
      • corral::anyOf(): Produces a result when any child completes; propagates cancellation only if all children are cancelled.
      • corral::allOf(): Produces a result only when all children complete; propagates cancellation if any child is cancelled.
      • Note: Both combiners propagate exceptions immediately, even if other tasks are being cancelled.
  11. Implement ThreadNotification to connect ThreadPool to an event loop

    master

    Because Corral is event-loop agnostic, you must provide a way for the ThreadPool to notify the main thread when a task completes. This is achieved by specializing corral::ThreadNotification for your specific event loop.

    You must implement the member function: void post(EventLoop&, void (*fn)(void*), void* arg)

    This function must ensure that fn(arg) is scheduled to run on the main event loop. Common implementations include using asio::post() for Boost.Asio or QMetaObject::invokeMethod for Qt.

  12. Implement a 'Live Object' pattern

    master

    To associate a nursery with a class instance (e.g., for background tasks like managing an HTTP/2 connection), use the 'Live Object' pattern. This avoids lifetime traps where a nursery outlives the object it is supposed to support.

    Instead of passing a nursery to the constructor, define a nursery pointer member and an async run() method. Use corral::openNursery() to simplify the implementation. openNursery() ensures the nursery pointer is cleared only when the nursery actually closes (after all tasks exit), allowing cleanup tasks to still use the pointer.

    Usage Pattern:

    1. Define a corral::Nursery* member.
    2. Implement run() using corral::openNursery(nursery_, ...).
    3. To start the object, call co_await n.start(&MyClass::run, &obj) within a nursery block. Use the suspending version of start() to ensure the object is ready before use.
    class MyLiveClass {
        corral::Nursery* nursery_ = nullptr;
      public:
        corral::Task<void> run(corral::TaskStarted<> started = {}) {
            return corral::openNursery(nursery_, std::move(started));
        }
    
        corral::Task<void> asyncThing();
        void beginThing() {
            nursery_->start(&MyLiveClass::asyncThing, this);
        }
    };
    
    // Usage
    MyLiveClass obj;
    CORRAL_WITH_NURSERY(n) {
        co_await n.start(&MyLiveClass::run, &obj);
        obj.beginThing();
        co_return corral::cancel;
    };