Skip to content

ColinH/mini-coro-plus

Repository files navigation

Mini Coro Plus

Minimalistic asymmetric stackful coroutines for C++17.

Based on the design and implementation of minicoro by Eduardo Bart.

Uses assembly context switching code from LuaCoco by Mike Pall.

Status

Not quite finished yet, but the first unit tests all run successfully.

Currently only x86-64 and AArch64 are supported and only on Posix-compatible platforms.

Hello

A traditional "hello world" example.

The .ipp file contains the library implementation and must be included in exactly one translation unit. The .hpp file contains the interface and can be included as often as required.

#include <iostream>

#include "mini_coro_plus.hpp"
#include "mini_coro_plus.ipp"

void function( mcp::control& ctrl )
{
   std::cout << "Hello";
   ctrl.yield();
   std::cout << "World!";
}

int main()
{
   mcp::coroutine coro( function );
   coro.resume();
   std::cout << ", ";
   coro.resume();
   std::cout << std::endl;
   return 0;
}

To compile this example best use the included Makefile that builds all included .cpp files into corresponding executables -- and runs the tests. The "hello world" example can then be invoked manually.

mini-coro-plus> make
c++ -std=c++17 -stdlib=libc++ -pedantic -MM -MQ build/dep/hello.d hello.cpp -o build/dep/hello.d
c++ -std=c++17 -stdlib=libc++ -pedantic -Wall -Wextra -Werror -O3 hello.cpp  -o build/bin/hello
build/bin/tests
mcp: all testcases succeeded
mini-coro-plus> build/bin/hello
Hello, World!

Creating

A coroutine is created with a std::function< void() >, and, optionally, a stack size.

As usual this means that function pointers, function objects (aka. functors) and closures (evaluated lambda expressions) can be passed as first argument.

A newly created coroutine sits in state STARTING and does not implicitly jump into its coroutine function!

Running

Running the coroutine function is achieve by calling resume() from outside the coroutine which puts the coroutine into state RUNNING.

This call to resume() will return when one of the following conditions is met:

  1. The coroutine function returns. The coroutine enters state COMPLETED and may not be resumed again.
  2. The coroutine function calls ctrl.yield() on a suitable instance of mcp::control. The coroutine enters state SLEEPING and can be resumed again later. Resuming continues execution of the coroutine function after the aforementioned yield().
  3. The coroutine function throws an exception. The coroutine enters state COMPLETED and may not be resumed again. In this case the call to resume() will throw the coroutine's exception rather than returning normally.

If the resume() is performed by another coroutine (itself in RUNNING state) then this calling coroutine transitions to state CALLING.

It is an error to call any function other than state() on a coroutine in COMPLETED state.

It is an error to call abort() on a coroutine that is in RUNNING or CALLING state.

Destroying

Destroying a coroutine object in states RUNNING or CALLING is an error.

Destroying a coroutine object in states STARTING or COMPLETED does nothing special.

Destroying a coroutine in state SLEEPING performs an implicit resume() into the coroutine in order to clean up the coroutine by destroying all local objects currently on the coroutine stack. This cleanup is achieved by throwing a dedicated exception from the yield() call inside the coroutine, the yield() call that last put the coroutine into SLEEPING state.

In order to not interfere with the cleanup, coroutine functions must take care to not accidentally catch and ignore exceptions of type mcp::internal::terminator! These exceptions must escape from the coroutine function.

Calling abort() on a coroutine performs the cleanup for coroutines in state SLEEPING but not much else.

Interface

The following is an excerpt of mini_coro_plus.hpp with all parts that are not considered part of the public interface removed.

namespace mcp
{
   enum class state : std::uint8_t
   {
      STARTING,  // Created without entering the coroutine function.
      RUNNING,   // Entered the coroutine function and currently running it.
      SLEEPING,  // Entered the coroutine which then yielded back out again.
      CALLING,   // Entered the coroutine which then resumed a different one.
      COMPLETED  // Finished the coroutine function to completion.
   };

   [[nodiscard]] constexpr bool can_abort( const state ) noexcept;
   [[nodiscard]] constexpr bool can_resume( const state ) noexcept;
   [[nodiscard]] constexpr bool can_yield( const state ) noexcept;

   [[nodiscard]] constexpr std::string_view to_string( const state ) noexcept;

   std::ostream& operator<<( std::ostream&, const state );

   // Control is for coroutine functions to control the coroutine they are currently running in.

   class control
   {
   public:
      control();

      [[nodiscard]] mcp::state state() const noexcept;  // Always state::RUNNING when used correctly.
      [[nodiscard]] std::size_t stack_size() const noexcept;

      void yield();
      void yield( std::any&& any );
      void yield( const std::any& any );

      [[nodiscard]] std::any& yield_any();
      [[nodiscard]] std::any& yield_any( std::any&& any );
      [[nodiscard]] std::any& yield_any( const std::any& any );

      template< typename... Ts >
      void yield( Ts&&... ts );

      template< typename... Ts >
      [[nodiscard]] std::any& yield_any( Ts&&... ts );

      template< typename T, typename... As >
      [[nodiscard]] T yield_as( As&&... as );

      template< typename T, typename... As >
      [[nodiscard]] std::optional< T > yield_opt( As&&... as );

      template< typename T, typename... As >
      [[nodiscard]] T* yield_ptr( As&&... as );
   };

   // Coroutine is for creating and controlling coroutines from the outside.

   class coroutine
   {
   public:
      explicit coroutine( std::function< void() >&&, const std::size_t stack_size = 0 );
      explicit coroutine( const std::function< void() >&, const std::size_t stack_size = 0 );

      explicit coroutine( std::function< void( control& ) >&&, const std::size_t stack_size = 0 );
      explicit coroutine( const std::function< void( control& ) >&, const std::size_t stack_size = 0 );

      [[nodiscard]] mcp::state state() const noexcept;
      [[nodiscard]] std::size_t stack_size() const noexcept;
      [[nodiscard]] std::size_t stack_used() const noexcept;  // Not very precise?

      void abort();
      void clear();
      void resume();
      void resume( std::any&& any );
      void resume( const std::any& any );

      [[nodiscard]] std::any& resume_any();
      [[nodiscard]] std::any& resume_any( std::any&& any );
      [[nodiscard]] std::any& resume_any( const std::any& any );

      template< typename... Ts >
      void resume( Ts&&... ts );

      template< typename... Ts >
      [[nodiscard]] std::any& resume_any( Ts&&... ts );

      template< typename T, typename... As >
      [[nodiscard]] T resume_as( As&&... as );

      template< typename T, typename... As >
      [[nodiscard]] std::optional< T > resume_opt( As&&... as );

      template< typename T, typename... As >
      [[nodiscard]] T* resume_ptr( As&&... as );
   };

}  // namespace mcp

Control Flow

Assume that coro is an mcp::coroutine created with a closure, function or functor F as first argument, and that ctrl is an mcp::control for said coroutine obtained either by using the default constructor within a running coroutine or as argument to F.

Action Where What Where What
Start Outside Call coro.resume() Inside F() is called
Yield Inside Call ctrl.yield() Outside coro.resume() returns
Resume Outside Call coro.resume() Inside ctrl.yield() returns
Finish Inside Return from F() Outside coro.resume() returns
Throw Inside F() throws E Outside coro.resume() throws E

Remember that creating a coroutine does not yet call F. A coroutine can be resumed multiple times, until it finishes by returning from or throwing an exception within F.

Multithreading

This library is thread agnostic and compatible with multi-threaded applications.

In a multi-threaded application it is safe for multiple threads to create and run coroutines. Calling mcp::control().yield() applies to the coroutine running on the current thread. It is an error to call from outside of a running coroutine.

Coroutines can be created on one thread and resumed on a different thread. The usual care needs to be taken as a coroutine object must not be used in multiple threads simultaneously as bad things will happen.

Development

This project was started to investigate whether it is possible to make minicoro more C++ compatible. The main questions were (a) how can C++ exceptions propagate from inside a coroutine to outside, and (b) can the coroutine object itself use RAII to clean up after itself? Turns out we can demonstrate here that the answer is "yes", however we can also see that the changes are quite intrusive, and therefore not well suited for additional or optional inclusion in minicoro.


Copyright (c) 2024 Dr. Colin Hirsch

Releases

No releases published

Packages

No packages published