GitHub - TroyKomodo/pollcoro: A header-only C++20 coroutine library using *polling* instead of the traditional resume-based approach.

27 min read Original article ↗

pollcoro

A C++20 modules-based coroutine library using polling instead of the traditional resume-based approach.

Unlike most C++ coroutine libraries that rely on continuation-passing and resume callbacks, pollcoro uses an explicit polling model inspired by Rust’s Future trait. Awaitables expose an poll() method that returns either ready or pending, giving you fine-grained control over execution.

Warning

This library is experimental and not yet production ready.

Features

  • Poll-based design — explicit control over when and how coroutines make progress

  • Zero-cost abstractions — non-blocking stream pipelines can be fully optimized away by the compiler, resulting in code as efficient as hand-written loops

  • C++20 modules — modern module-based design for faster builds and better encapsulation

  • Async streamsstream<T> with co_yield, co_await, and yield-from support

  • Custom allocators — pluggable memory allocation for coroutine frames with thread-local scoping

  • CMake integration — modern CMake with find_package support

Polling vs Resume-Based Coroutines

Most C++ coroutine libraries use a resume-based model where an awaitable stores a continuation (the coroutine_handle) and calls resume() when ready. This creates implicit control flow — the awaitable decides when and where execution continues.

// Resume-based: awaitable controls execution
struct resume_awaitable {
    std::coroutine_handle<> handle_;

    void await_suspend(std::coroutine_handle<> h) {
        handle_ = h;
        // Later, some callback does: handle_.resume();
    }
};

pollcoro uses a poll-based model inspired by Rust’s Future trait. Instead of giving away control, the executor repeatedly polls awaitables, which return ready or pending:

// Poll-based: executor controls execution
struct poll_awaitable {
    pollcoro::awaitable_state<int> poll(const pollcoro::waker& w) {
        if (is_ready()) {
            return pollcoro::awaitable_state<int>::ready(result_);
        }
        waker_ = w;  // Store waker to signal "poll me again"
        return pollcoro::awaitable_state<int>::pending();
    }
};

Why Polling?

Aspect Resume-Based Poll-Based

Control

Awaitable resumes coroutine directly

Executor decides when to poll

Thread Safety

Resume can happen on any thread

All polling happens on executor’s thread

Cancellation

Requires explicit cancellation tokens

Just stop polling — no cleanup needed

Composition

Complex with multiple continuations

Natural — poll children, aggregate results

Debugging

Stack traces show resume site

Stack traces show poll call chain

The polling model trades some efficiency (extra poll calls) for predictability and simpler reasoning about control flow.

Installation

CPM.cmake is a lightweight dependency manager for CMake. It downloads and caches dependencies automatically, making it easy to add libraries to your project without submodules or system-wide installation.

include(cmake/CPM.cmake)  # or wherever you put CPM.cmake

CPMAddPackage("gh:troykomodo/pollcoro#main")

target_link_libraries(your_target PRIVATE pollcoro::pollcoro)

FetchContent

If you prefer CMake’s built-in FetchContent:

include(FetchContent)

FetchContent_Declare(
    pollcoro
    GIT_REPOSITORY https://github.com/troykomodo/pollcoro.git
    GIT_TAG main
)
FetchContent_MakeAvailable(pollcoro)

target_link_libraries(your_target PRIVATE pollcoro::pollcoro)

Git Submodule

git submodule add https://github.com/troykomodo/pollcoro.git external/pollcoro
add_subdirectory(external/pollcoro)
target_link_libraries(your_target PRIVATE pollcoro::pollcoro)

CMake Configuration

Link against pollcoro::pollcoro and use import pollcoro; in your code:

target_link_libraries(your_target PRIVATE pollcoro::pollcoro)

Note

Requires CMake 3.28+ and a C+20 modules-capable compiler (Clang 16, MSVC 19.34+). GCC has poor modules support and is not recommended.

Quick Start

#include <iostream>

import pollcoro;

pollcoro::task<int> async_add(int a, int b) {
    co_return a + b;
}

pollcoro::task<int> compute() {
    int x = co_await async_add(10, 20);
    int y = co_await async_add(x, 5);
    co_return y;
}

int main() {
    int result = pollcoro::block_on(compute());
    std::cout << "Result: " << result << std::endl;  // Result: 35
}

Examples

The examples/ directory contains several demonstrations:

  • fib.cc — Concurrent Fibonacci using wait_all to compute both branches in parallel

  • stream.cc — Stream combinators: take, skip, map, flatten, zip, fold, window, and more

  • pipelines.cc — Complex stream pipelines, zero-cost optimization, type transformations

  • custom_awaitable.cc — Creating custom awaitables without coroutines for better performance

  • generic.cc — Type-erased awaitables and streams with generic_awaitable and generic_stream_awaitable

  • wait_combinators.cc — Concurrent operations with wait_all and wait_first

  • thread_callback.cc — Bridging threaded callbacks into coroutines using single_event

  • mutex.cc — Async mutex and shared_mutex safe to hold across yield points

  • modules.cc — Using pollcoro with C++20 module imports

  • c_interop.c / c_interop_impl.cc — Exposing pollcoro tasks through a C API

  • exceptions.cc — Exception handling and propagation in coroutines

  • timer_sleep.cc — Using sleep_until with a custom timer implementation

  • interop_cppcoro.cc — Bidirectional interop with cppcoro using to_pollable and to_resumable

  • interop_asio.cc — Bidirectional interop with ASIO (adapters defined in example)

  • cxx_cppcoro_interop.cc — Low-level manual interop with cppcoro via C API

  • reference.cc — Using ref to poll a task without consuming it

  • allocator.cc — Custom allocators for coroutine frame allocation

Core Concepts

The Polling Model

In pollcoro, every awaitable implements poll(waker& w) which returns a awaitable_state<T>:

struct my_awaitable {
    pollcoro::awaitable_state<int> poll(const pollcoro::waker& w) {
        if (/* ready */) {
            return pollcoro::awaitable_state<int>::ready(42);
        }
        // Store waker to notify when ready
        stored_waker_ = w;
        return pollcoro::awaitable_state<int>::pending();
    }
};

The waker is a callback that signals "poll me again" — call it when your awaitable becomes ready.

pollcoro::awaitable Concept

Any type satisfying this concept can be co_await`ed inside a `task:

template<typename T>
concept awaitable = requires(T t, const waker& w) {
    { t.poll(w) } -> /* returns awaitable_state<U> */;
};

pollcoro::stream_awaitable Concept

Any type satisfying this concept can be used with co_yield inside a stream (yield-from):

template<typename T>
concept stream_awaitable = requires(T t, const waker& w) {
    { t.poll_next(w) } -> /* returns stream_awaitable_state<U> */;
};

The stream<T> type itself satisfies stream_awaitable, enabling yield-from composition.

Exception Handling

Exceptions in pollcoro work just like normal C++ exceptions. They bubble up through co_await the same way they would through regular function calls. If nothing catches them, they propagate all the way up to the poll site (e.g., block_on).

pollcoro::task<int> might_throw(int value) {
    if (value < 0) {
        throw std::runtime_error("negative value");
    }
    co_return value * 2;
}

pollcoro::task<int> caller() {
    try {
        co_return co_await might_throw(-5);  // Exception caught here
    } catch (const std::runtime_error& e) {
        std::cout << "Caught: " << e.what() << std::endl;
        co_return 0;
    }
}

If an exception isn’t caught, it continues bubbling up through each co_await until it reaches block_on:

pollcoro::task<int> inner() {
    throw std::runtime_error("something went wrong");
    co_return 42;
}

pollcoro::task<int> outer() {
    co_return co_await inner();  // Exception passes through
}

int main() {
    try {
        pollcoro::block_on(outer());  // Exception caught here
    } catch (const std::runtime_error& e) {
        std::cerr << "Uncaught exception: " << e.what() << std::endl;
    }
}

Mutexes

Standard mutexes (std::mutex) are unsafe to hold across yield points in coroutines. This is because a mutex can only be unlocked on the same thread that locked it, but after a co_await, your coroutine might be polled by a different thread.

// DANGEROUS: don't do this!
pollcoro::task<> bad_example() {
    std::mutex mtx;
    std::unique_lock lock(mtx);
    co_await some_async_operation();  // Might resume on different thread!
    // Unlocking here could be on a different thread than locking — undefined behavior
}

pollcoro provides mutex and shared_mutex types that are safe to use across yield points:

pollcoro::mutex mtx;

pollcoro::task<> safe_example() {
    auto guard = co_await mtx.lock();
    co_await some_async_operation();  // Safe! Lock is coroutine-aware
    // Guard released safely when it goes out of scope
}

pollcoro::shared_mutex smtx;

pollcoro::task<> reader() {
    auto guard = co_await smtx.lock_shared();
    // Multiple readers can hold shared locks concurrently
    co_await read_operation();
}

pollcoro::task<> writer() {
    auto guard = co_await smtx.lock();
    // Exclusive access for writing
    co_await write_operation();
}

Note

mutex and shared_mutex are non-copyable and non-movable. They must be declared in a stable location (class member, global, etc.) and accessed by reference. This ensures the internal state address remains valid for all guards.
struct my_service {
    pollcoro::mutex mtx;  // Stable location as member
    int counter = 0;

    pollcoro::task<> increment() {
        auto guard = co_await mtx.lock();
        ++counter;
    }
};

API Reference

pollcoro::task<T>

The primary coroutine type. Lazy — doesn’t run until polled.

pollcoro::task<int> fetch_value() {
    co_return 42;
}

pollcoro::task<> do_work() {  // void task
    co_await fetch_value();
    co_return;
}

For advanced use cases, you can extract the underlying coroutine handle:

auto task = fetch_value();
auto handle = std::move(task).release();  // task is now empty
// Work directly with the coroutine handle
handle.destroy();  // You're responsible for cleanup

pollcoro::stream<T>

An async stream that produces a sequence of values. Supports co_yield for values, co_await for async operations, and yield-from for delegating to other streams.

// Simple stream
pollcoro::stream<int> fibonacci() {
    int a = 0, b = 1;
    while (true) {
        co_yield b;
        auto tmp = a;
        a = b;
        b += tmp;
    }
}

// Stream with async operations
pollcoro::stream<int> async_counter(int count) {
    for (int i = 0; i < count; ++i) {
        co_await pollcoro::yield();  // Async work
        co_yield i;
    }
}

// Yield-from: delegate to another stream
pollcoro::stream<int> make_range(int start, int end) {
    for (int i = start; i < end; ++i) {
        co_yield i;
    }
}

pollcoro::stream<int> combined() {
    co_yield 100;                  // Single value
    co_yield make_range(0, 3);     // Yield from stream: 0, 1, 2
    co_yield 200;
}

Yield-from with pollcoro::iter

Inside a stream, you can yield from existing collections using pollcoro::iter:

pollcoro::stream<int> example() {
    std::vector<int> vec = {1, 2, 3};

    // Yield from iterator pair
    co_yield pollcoro::iter(vec.begin(), vec.end());

    // Yield from container directly
    co_yield pollcoro::iter(std::array{4, 5, 6});
    co_yield pollcoro::iter(vec);
}

pollcoro::next

Convert a stream into an awaitable that returns std::optional<T>. Returns std::nullopt when the stream is exhausted.

pollcoro::task<> consume_stream() {
    auto s = fibonacci();
    while (auto value = co_await pollcoro::next(s)) {
        std::cout << *value << " ";
        if (*value > 100) break;
    }
}

pollcoro::pending

An awaitable that is always pending. Also available as a stream variant.

// Awaitable that never completes
co_await pollcoro::pending<int>();
co_await pollcoro::pending<void>();

// Stream that never yields
auto s = pollcoro::pending_stream<int>();

pollcoro::ready

An awaitable that is always ready.

co_await pollcoro::ready(42);  // instantly returns 42
co_await pollcoro::ready();    // instantly returns void

pollcoro::stream_awaitable_state<T>

The return type of poll_next() for streams. Represents ready (with value), pending, or done states.

// Creating states
auto ready = pollcoro::stream_awaitable_state<int>::ready(42);
auto pending = pollcoro::stream_awaitable_state<int>::pending();
auto done = pollcoro::stream_awaitable_state<int>::done();

// Checking state
if (state.is_ready()) {
    int value = state.take_result();
} else if (state.is_done()) {
    // Stream exhausted
}

// Mapping results
auto mapped = std::move(state).map([](int n) { return n * 2; });

pollcoro::block_on

Synchronously runs an awaitable to completion, blocking the current thread.

int result = pollcoro::block_on(my_async_task());

pollcoro::sleep_for / pollcoro::sleep_until

Sleep for a specified duration or until a deadline. Requires a timer type that satisfies the timer concept.

// Timer concept requirements:
template<typename Timer>
concept timer = requires(Timer timer) {
    typename Timer::duration;
    typename Timer::time_point;
    { timer.now() } -> std::same_as<typename Timer::time_point>;
    { timer.register_callback(deadline, callback) } -> std::same_as<void>;
};

Example timer implementation:

template<typename Clock = std::chrono::steady_clock>
struct my_timer {
    using duration = typename Clock::duration;
    using time_point = typename Clock::time_point;

    time_point now() const {
        return Clock::now();
    }

    void register_callback(const time_point& deadline, std::function<void()> callback) {
        // Use a thread pool or other efficient scheduling mechanism
        std::thread([deadline, callback] {
            std::this_thread::sleep_until(deadline);
            callback();
        }).detach();
    }
};

Usage:

// Sleep for a duration
co_await pollcoro::sleep_for<my_timer<>>(std::chrono::seconds(1));

// Sleep until a deadline
co_await pollcoro::sleep_until<my_timer<>>(
    std::chrono::steady_clock::now() + std::chrono::seconds(5)
);

// Pass a timer instance directly
my_timer<> timer;
co_await pollcoro::sleep_for(std::chrono::seconds(1), std::move(timer));
co_await pollcoro::sleep_until(deadline, std::move(timer));

pollcoro::wait_all

Wait for multiple awaitables to complete. Returns a tuple of results (void results are filtered out).

// Variadic form
auto [a, b, c] = co_await pollcoro::wait_all(
    task_returning_int(),
    task_returning_string(),
    task_returning_double()
);

// Iterator form (all same type)
std::vector<pollcoro::task<int>> tasks = /* ... */;
std::vector<int> results = co_await pollcoro::wait_all(tasks);

pollcoro::wait_first

Wait for the first awaitable to complete. Returns the result and the index of the winner.

// Variadic form
auto [result, index] = co_await pollcoro::wait_first(task_a(), task_b(), task_c());

// Iterator form
std::vector<pollcoro::task<int>> tasks = /* ... */;
auto [result, index] = co_await pollcoro::wait_first(tasks);

pollcoro::single_event<T>

A one-shot event for bridging external code (threads, callbacks) into the coroutine world.

pollcoro::task<int> wait_for_callback() {
    auto [awaitable, setter] = pollcoro::single_event<int>();

    // Pass setter to some external callback system
    register_callback([setter = std::move(setter)](int value) mutable {
        setter.set(value);
    });

    co_return co_await awaitable;
}

// For void events:
auto [awaitable, setter] = pollcoro::single_event<void>();
setter.set();  // no argument needed

pollcoro::to_pollable

Convert a resume-based awaitable (like cppcoro::task<T>) into a poll-based pollcoro awaitable. This allows you to use awaitables from libraries like cppcoro inside pollcoro coroutines.

// Use a cppcoro awaitable inside pollcoro
pollcoro::task<int> use_cppcoro_inside_pollcoro() {
    // async_compute returns cppcoro::task<int>
    int result = co_await pollcoro::to_pollable(async_compute(21));
    co_return result;
}

// Works with any resume-based awaitable
pollcoro::task<> example() {
    co_await pollcoro::to_pollable(cppcoro_async_sleep(std::chrono::milliseconds(100)));
}

The adapter immediately starts the wrapped coroutine and polls the shared state for completion. When the resume-based awaitable completes (or throws), the result is captured and returned on the next poll.

pollcoro::to_resumable

Convert a poll-based pollcoro awaitable into a resume-based awaitable that can be used with standard C++ coroutine libraries. This allows pollcoro tasks to be awaited inside cppcoro coroutines.

// Use a pollcoro task inside cppcoro
cppcoro::task<int> use_pollcoro_inside_cppcoro(cppcoro::io_service& io_service) {
    // poll_chain returns pollcoro::task<int>
    int result = co_await pollcoro::to_resumable(poll_chain(), io_service);
    co_return result;
}

The scheduler parameter (e.g., cppcoro::io_service) must satisfy the co_scheduler concept — it needs a schedule() method that returns an awaitable. The adapter repeatedly polls the pollcoro awaitable, yielding to the scheduler between polls.

Bidirectional Interop Example

Combine both adapters for full interoperability between poll-based and resume-based coroutines:

// cppcoro async primitive
cppcoro::task<> async_sleep(std::chrono::milliseconds duration);

// pollcoro task that uses cppcoro primitives
pollcoro::task<int> poll_with_cppcoro_sleep(int value) {
    // Use cppcoro inside pollcoro via to_pollable
    co_await pollcoro::to_pollable(async_sleep(std::chrono::milliseconds(100)));
    co_return value;
}

// cppcoro task that drives pollcoro
cppcoro::task<int> cppcoro_drives_pollcoro(cppcoro::io_service& io_service) {
    // Use pollcoro inside cppcoro via to_resumable
    int result = co_await pollcoro::to_resumable(poll_with_cppcoro_sleep(42), io_service);
    co_return result;
}

ASIO Integration

pollcoro can interoperate bidirectionally with ASIO. The interop_asio.cc example demonstrates how to build adapters for:

  • from_asio — Use ASIO awaitables inside pollcoro

  • to_asio — Use pollcoro awaitables inside ASIO coroutines

asio_scheduler — Use pollcoro with to_resumable

A scheduler adapter for using to_resumable with ASIO-based event loops:

asio_scheduler sched{ctx.get_executor()};
auto resumable = pollcoro::to_resumable(my_pollcoro_task(), sched);

See interop_asio.cc for complete ASIO integration examples including:

  • Using ASIO timers inside pollcoro (from_asio)

  • Using pollcoro tasks inside ASIO coroutines (to_asio)

  • Chaining multiple ASIO operations in pollcoro

  • Full bidirectional interop (ASIO → pollcoro → ASIO)

  • Concurrent pollcoro tasks in ASIO

Note

ASIO adapters are not part of the pollcoro module itself. The interop_asio.cc example provides ready-to-use adapter implementations that you can copy into your project.

pollcoro::map

Transform the result of an awaitable or each element of a stream.

// === For awaitables (task<T>) ===

// Function form
auto doubled = co_await pollcoro::map(get_number(), [](auto n) { return n * 2; });

// Pipe syntax
auto doubled = co_await (
    get_number() |
        pollcoro::map([](auto n) {
            return n * 2;
        })
);

// === For streams ===

// Transform each element as it flows through
auto doubled_stream = my_stream | pollcoro::map([](auto n) { return n * 2; });

// Chain with other combinators
auto processed = fibonacci()
    | pollcoro::take(10)
    | pollcoro::map([](auto n) { return n * n; })  // Square each
    | pollcoro::skip(2);

// Map to a different type
auto strings = int_stream | pollcoro::map([](int n) {
    return std::to_string(n);
});

pollcoro::ref

Create a reference wrapper around an awaitable or stream (useful when you can’t move).

// For awaitables
pollcoro::task<int> my_task = /* ... */;
co_await pollcoro::ref(my_task);  // polls my_task without moving it

// For streams
pollcoro::stream<int> my_stream = /* ... */;
auto ref_stream = pollcoro::ref(my_stream);  // reference to stream

Awaitable Resolution

ref automatically resolves the underlying awaitable from various wrapper types. It tries these accessors in order:

  1. Direct awaitable — if the type itself has a poll() method, use it directly

  2. Dereferenceable — if the type supports *t (like std::shared_ptr, std::unique_ptr, raw pointers), dereference to get the awaitable

  3. .get() method — if the type has a .get() method (like std::reference_wrapper), call it to get the awaitable

// Direct reference
pollcoro::task<int> task = some_task();
co_await pollcoro::ref(task);

// Via std::reference_wrapper
auto wrapper = std::ref(task);
co_await pollcoro::ref(wrapper);

// Via shared_ptr
auto ptr = std::make_shared<pollcoro::task<int>>(some_task());
co_await pollcoro::ref(ptr);

// Via unique_ptr
auto uptr = std::make_unique<pollcoro::task<int>>(some_task());
co_await pollcoro::ref(uptr);

// Via raw pointer
pollcoro::task<int>* raw = &task;
co_await pollcoro::ref(raw);

The same resolution logic applies to streams via poll_next().

Lifetime Requirements

Warning

The ref wrapper holds a raw reference to the original awaitable. The original awaitable must outlive the ref wrapper — using a ref after the original is destroyed is undefined behavior. Additionally, when polling through a ref, the waker passed to poll() must remain valid for as long as the referenced awaitable might call it.
// DANGEROUS: original destroyed before ref
auto make_ref() {
    pollcoro::task<int> task = some_task();
    return pollcoro::ref(task);  // task destroyed at end of scope!
}

// SAFE: original outlives ref
pollcoro::task<int> task = some_task();
auto ref = pollcoro::ref(task);
co_await ref;  // task still alive

pollcoro::generic

Type-erased wrapper for awaitables and streams. Useful when you need to store different types in a container or return different types from a function.

// === For awaitables (generic_awaitable<T>) ===

// Wrap any awaitable into a type-erased container
pollcoro::generic_awaitable<int> wrapped = pollcoro::generic(some_task());

// Store different awaitable types uniformly
std::vector<pollcoro::generic_awaitable<int>> tasks;
tasks.push_back(pollcoro::generic(task_a()));
tasks.push_back(pollcoro::generic(task_b()));

// Can be awaited like any other awaitable
int result = co_await wrapped;

// === For streams (generic_stream_awaitable<T>) ===

// Wrap any stream into a type-erased container
pollcoro::generic_stream_awaitable<int> wrapped_stream = pollcoro::generic(my_stream);

// Store different stream types uniformly
std::vector<pollcoro::generic_stream_awaitable<int>> streams;
streams.push_back(pollcoro::generic(fibonacci() | pollcoro::take(10)));
streams.push_back(pollcoro::generic(pollcoro::range(100)));

// Iterate like any other stream
while (auto value = co_await pollcoro::next(wrapped_stream)) {
    std::cout << *value << " ";
}

pollcoro::yield

Yield control back to the executor for a specified number of polls.

co_await pollcoro::yield();     // yield once
co_await pollcoro::yield(3);    // yield 3 times

pollcoro::allocator

Custom allocator support for coroutine frame allocation. By default, pollcoro uses operator new and operator delete, but you can provide custom allocators for fine-grained memory control.

The allocator_impl Concept

Any type implementing allocate(size_t) and deallocate(void*) satisfies the allocator concept:

template<typename Impl>
concept allocator_impl = requires(Impl& impl) {
    { impl.allocate(std::declval<size_t>()) } -> std::same_as<void*>;
    { impl.deallocate(std::declval<void*>()) } -> std::same_as<void>;
};

pollcoro::default_allocator

The default allocator that uses operator new and operator delete:

// Explicitly use the default allocator
co_await pollcoro::allocate_in(pollcoro::default_allocator, some_task);

pollcoro::allocate_in

Execute a coroutine-returning function with a specific allocator. The allocator is captured at creation time and used for all nested coroutine allocations:

my_allocator_impl alloc;

// Create and run a task with custom allocator
co_await pollcoro::allocate_in(alloc, []() -> pollcoro::task<int> {
    co_return 42;
});

// Works with function pointers and any callable
pollcoro::task<> my_task() { co_return; }
co_await pollcoro::allocate_in(alloc, my_task);

// The allocator is preserved across co_await boundaries
co_await pollcoro::allocate_in(alloc, []() -> pollcoro::task<> {
    // All nested coroutines also use `alloc`
    co_await nested_task();
    co_await another_task();
});

Example: Slab Allocator

Here’s a complete example using a custom slab allocator:

template<std::size_t N>
class slab_allocator {
    // ... bitmap-based allocation pools ...

public:
    void* allocate(std::size_t size) {
        if (size <= 128) return small_pool_.allocate();
        if (size <= 512) return medium_pool_.allocate();
        if (size <= 1024) return large_pool_.allocate();
        throw std::bad_alloc();
    }

    void deallocate(void* ptr) noexcept {
        if (small_pool_.owns(ptr)) return small_pool_.deallocate(ptr);
        if (medium_pool_.owns(ptr)) return medium_pool_.deallocate(ptr);
        if (large_pool_.owns(ptr)) return large_pool_.deallocate(ptr);
    }
};

pollcoro::task<> example() {
    auto alloc = slab_allocator<10240>();

    // This coroutine and all nested ones use the slab allocator
    co_await pollcoro::allocate_in(alloc, []() -> pollcoro::task<> {
        co_await pollcoro::yield();
        co_await nested_operation();
    });

    // Back to default allocator
    co_await some_other_task();
}

Allocator Lifetime

The allocator must outlive all coroutines that use it. The allocator reference is captured when the coroutine is created and used for both allocation and deallocation:

// SAFE: allocator outlives the coroutine
slab_allocator<10240> alloc;
auto result = pollcoro::block_on(
    pollcoro::allocate_in(alloc, my_task)
);

// DANGEROUS: allocator destroyed before coroutine completes
auto make_task() {
    slab_allocator<10240> alloc;  // Destroyed at end of function!
    return pollcoro::allocate_in(alloc, my_task);  // BAD
}

Stream Combinators

pollcoro provides a rich set of stream combinators for transforming and composing async streams. Most combinators support both function-style and pipe-style (|) syntax.

pollcoro::take

Take the first N elements from a stream.

// Function style
auto first_five = pollcoro::take(my_stream, 5);

// Pipe style
auto first_five = my_stream | pollcoro::take(5);

// Usage
while (auto value = co_await pollcoro::next(first_five)) {
    std::cout << *value << " ";
}

pollcoro::skip

Skip the first N elements from a stream.

// Function style
auto after_five = pollcoro::skip(my_stream, 5);

// Pipe style
auto after_five = my_stream | pollcoro::skip(5);

pollcoro::take_while

Take elements while a predicate returns true, then stop.

// Take numbers less than 100
auto s = my_stream | pollcoro::take_while([](auto n) { return n < 100; });

pollcoro::skip_while

Skip elements while a predicate returns true, then emit all remaining elements.

// Skip until we find a number >= 100
auto s = my_stream | pollcoro::skip_while([](auto n) { return n < 100; });

pollcoro::chain

Chain multiple streams together sequentially. When the first stream ends, continues with the second, and so on.

// Function style
auto combined = pollcoro::chain(stream_a, stream_b, stream_c);

// Pipe style
auto combined = stream_a | pollcoro::chain(stream_b) | pollcoro::chain(stream_c);

// Example: chain fibonacci with a counter
auto s = fibonacci() | pollcoro::take(3) | pollcoro::chain(async_counter(5));
// Yields: 1 1 2 0 1 2 3 4

pollcoro::zip

Combine multiple streams element-wise into tuples. Ends when any stream ends.

// Zip two streams together
auto zipped = pollcoro::zip(stream_a, stream_b);

while (auto value = co_await pollcoro::next(zipped)) {
    auto [a, b] = *value;
    std::cout << "(" << a << ", " << b << ") ";
}

// Zip with enumerate for indexed iteration
auto indexed = pollcoro::zip(my_stream, pollcoro::enumerate());
while (auto value = co_await pollcoro::next(indexed)) {
    auto [item, index] = *value;
    std::cout << index << ": " << item << std::endl;
}

pollcoro::enumerate

Create an infinite stream of sequential indices (0, 1, 2, …​). Useful with zip for indexed iteration.

// Standalone infinite index stream
auto indices = pollcoro::enumerate();

// Common pattern: zip with enumerate for indexed iteration
auto indexed = pollcoro::zip(my_stream, pollcoro::enumerate());
while (auto value = co_await pollcoro::next(indexed)) {
    auto [item, index] = *value;
    std::cout << index << ": " << item << std::endl;
}

// Can also wrap a stream directly (returns pair<index, value>)
auto enumerated = pollcoro::enumerate(my_stream);

pollcoro::flatten

Flatten a stream of streams into a single stream.

// Given a stream that yields streams...
pollcoro::stream<pollcoro::stream<int>> nested = /* ... */;

// Flatten into a single stream of ints
auto flat = nested | pollcoro::flatten();

// Common pattern: map to streams, then flatten
auto s = my_stream
    | pollcoro::map([](auto n) { return pollcoro::repeat(n) | pollcoro::take(n); })
    | pollcoro::flatten();
// If my_stream yields 1, 2, 3: outputs 1, 2, 2, 3, 3, 3

pollcoro::window

Group elements into fixed-size windows (batches). Returns std::array<T, N>.

// Group into windows of 3
auto batched = my_stream | pollcoro::window<3>();

while (auto value = co_await pollcoro::next(batched)) {
    auto [a, b, c] = *value;
    std::cout << "(" << a << ", " << b << ", " << c << ") ";
}
// Stream 0,1,2,3,4,5,6,7,8 yields: (0,1,2) (3,4,5) (6,7,8)

pollcoro::fold

Reduce a stream to a single value using an accumulator function. Optionally supports early termination.

// Sum all elements (void return = no early exit)
auto sum = co_await pollcoro::fold(my_stream, 0, [](int& acc, int n) {
    acc += n;
});

// Sum with early exit (return false to stop)
auto partial_sum = co_await pollcoro::fold(my_stream, 0, [](int& acc, int n) {
    acc += n;
    return acc < 100;  // Stop when sum reaches 100
});

pollcoro::last

Get the last element of a stream. Returns std::optional<T> (empty if stream was empty).

auto last_value = co_await pollcoro::last(my_stream);
if (last_value) {
    std::cout << "Last: " << *last_value << std::endl;
}

pollcoro::nth

Get the Nth element from a stream (1-indexed). Returns std::optional<T>. Takes stream by reference, allowing repeated calls for every Nth element.

// Get the 3rd element
auto s = my_stream;
auto third = co_await pollcoro::nth(s, 3);

// Get every 2nd element
auto s = my_stream;
while (auto value = co_await pollcoro::nth(s, 2)) {
    std::cout << *value << " ";  // 1st, 3rd, 5th, ...
}

pollcoro::iter / pollcoro::iter_move

Create a stream from iterators or containers.

// From iterator pair (copies values)
std::vector<int> vec = {1, 2, 3};
auto s = pollcoro::iter(vec.begin(), vec.end());

// From range/container (copies values)
auto s = pollcoro::iter(vec);
auto s = pollcoro::iter(std::array{1, 2, 3});

// Move values instead of copying
auto s = pollcoro::iter_move(vec.begin(), vec.end());
auto s = pollcoro::iter_move(vec);

pollcoro::range

Create a stream that emits integers in a range.

// Range from 0 to 9
auto s = pollcoro::range(10);

// Range from 5 to 9
auto s = pollcoro::range(5, 10);

pollcoro::repeat

Create an infinite stream that repeats a value.

// Infinite stream of 42s
auto s = pollcoro::repeat(42);

// Useful with take
auto five_zeros = pollcoro::repeat(0) | pollcoro::take(5);

pollcoro::empty

Create an empty stream that immediately completes.

auto s = pollcoro::empty<int>();  // Immediately done

pollcoro::sync_iter

Synchronously iterate over a stream using range-based for loops. Only works with non-blocking streams.

// Use range-based for loop
auto s = pollcoro::range(10);
for (auto value : pollcoro::sync_iter(std::move(s))) {
    std::cout << value << " ";
}

pollcoro::awaitable_state<T>

The return type of poll(). Represents either a ready result or pending state.

// Creating states
auto ready = pollcoro::awaitable_state<int>::ready(42);
auto pending = pollcoro::awaitable_state<int>::pending();

// For void
auto void_ready = pollcoro::awaitable_state<void>::ready();

// Checking state
if (state.is_ready()) {
    int value = state.take_result();
}

// Mapping results
auto mapped = std::move(state).map([](int n) { return n * 2; });

pollcoro::waker

A callback that signals readiness. Store it and invoke when your awaitable becomes ready.

class my_awaitable {
    pollcoro::waker stored_waker_;

public:
    pollcoro::awaitable_state<void> poll(const pollcoro::waker& w) {
        if (is_ready()) {
            return pollcoro::awaitable_state<void>::ready();
        }
        stored_waker_ = w;
        return pollcoro::awaitable_state<void>::pending();
    }

    void signal_ready() {
        stored_waker_.wake();
    }
};

You can create your own waker types by implementing the wake method.

class my_waker_t {
    // Wake the waker with ownership transfer
    void wake() {
        // implementation
    }
}

auto my_waker = my_waker_t();
auto some_task = /* ... */;

auto result = some_task.poll(pollcoro::waker(my_waker));
// do something with the result

Comparing Wakers

You can compare two wakers to check if they would wake the same target:

pollcoro::waker w1 = /* ... */;
pollcoro::waker w2 = /* ... */;

if (w1.will_wake(w2)) {
    // Both wakers point to the same wake target
    // Useful to avoid redundant waker updates
}

This is useful for optimization — if the new waker would wake the same target as the stored one, you can skip updating it.

Waker Lifetimes

A waker is a lightweight handle (function pointer + data pointer) — it doesn’t own the underlying wake mechanism. The executor owns the actual wake implementation and passes a waker to your awaitable on each poll.

The executor must keep its waker valid until the awaitable has completed or been canceled (i.e., until the awaitable’s destructor returns). This ensures the awaitable can safely call the waker during cleanup if needed.

When an awaitable stores a waker to call later, the awaitable is responsible for ensuring the waker isn’t called after the awaitable is canceled. This matters when external code (another thread, a callback) might try to trigger a wake after the awaitable is gone.

The single_event implementation demonstrates a safe pattern: a shared_ptr connects the awaitable and the setter. When the awaitable is destroyed, it clears the stored waker. If another thread later calls setter.set(), it finds an empty waker and does nothing.

// Safe pattern: clear waker on destruction
~my_awaitable() {
    std::lock_guard lock(shared_->mutex);
    shared_->waker = pollcoro::waker();  // Clear to prevent stale calls
}

Build Options

Option Default Description

POLLCORO_INSTALL

${PROJECT_IS_TOP_LEVEL}

Generate install targets

POLLCORO_TESTS

${PROJECT_IS_TOP_LEVEL}

Build test suite

POLLCORO_EXAMPLES

${PROJECT_IS_TOP_LEVEL}

Build example programs

Writing Custom Awaitables

Any type can be awaited if it implements poll().

Blocking Traits

When composing awaitables, pollcoro tracks whether they might block (wait for external events) or are guaranteed to complete synchronously. This enables:

  • sync_iter — synchronous iteration only works with non-blocking streams

  • Zero-cost optimization — when an entire stream pipeline is non-blocking, the compiler can optimize away the coroutine machinery entirely. No heap allocations, no coroutine frames — just straight-line code as efficient as hand-written loops.

For example, pollcoro::range(100) | pollcoro::take(10) | pollcoro::map(square) compiles down to a simple loop since range, take, and map are all non-blocking.

Inherit from one of these base classes to declare your awaitable’s blocking behavior:

// Always blocks - waits for external events (timers, I/O, etc.)
class my_timer : public pollcoro::awaitable_always_blocks {
    // ...
};

// Never blocks - always completes synchronously
class my_ready : public pollcoro::awaitable_never_blocks {
    // ...
};

// Blocking depends on wrapped awaitables
template<typename Inner>
class my_wrapper : public pollcoro::awaitable_maybe_blocks<Inner> {
    // is_blocking_v will be true if Inner::is_blocking_v is true
};

// Multiple dependencies
template<typename A, typename B>
class my_combinator : public pollcoro::awaitable_maybe_blocks<A, B> {
    // is_blocking_v will be true if either A or B might block
};

If you don’t inherit from any of these, your awaitable is assumed to be blocking by default.

You can check blocking status at compile time:

static_assert(pollcoro::is_blocking_v<my_timer>);        // true
static_assert(!pollcoro::is_blocking_v<my_ready>);       // false

Example: Timer Awaitable

Here’s a complete example of a custom awaitable that waits for a timer:

class timer_awaitable : public pollcoro::awaitable_always_blocks {
    std::chrono::steady_clock::time_point deadline_;
    struct shared {
        std::mutex mutex;
        pollcoro::waker waker;
    };
    std::shared_ptr<shared> shared_;
    bool started_ = false;

    void reset() {
        if (shared_ && started_) {
            std::lock_guard lock(shared_->mutex);
            shared_->waker = pollcoro::waker();
        }
        shared_ = nullptr;
        started_ = false;
    }

public:
    explicit timer_awaitable(std::chrono::milliseconds duration)
        : deadline_(std::chrono::steady_clock::now() + duration),
         shared_(std::make_shared<shared>()) {}

    ~timer_awaitable() {
        reset();
    }

    timer_awaitable(const timer_awaitable& other) = delete;
    timer_awaitable& operator=(const timer_awaitable& other) = delete;
    timer_awaitable(timer_awaitable&& other) noexcept {
        shared_ = std::move(other.shared_);
        started_ = other.started_;
        other.shared_ = nullptr;
        other.started_ = false;
    };
    timer_awaitable& operator=(timer_awaitable&& other) noexcept {
        reset();
        shared_ = std::move(other.shared_);
        started_ = other.started_;
        other.shared_ = nullptr;
        other.started_ = false;
        return *this;
    };

    pollcoro::awaitable_state<void> poll(const pollcoro::waker& w) {
        if (std::chrono::steady_clock::now() >= deadline_) {
            return pollcoro::awaitable_state<void>::ready();
        }

        std::lock_guard lock(shared_->mutex);
        shared_->waker = w;
        if (!started_) {
            started_ = true;
            some_cool_timer_api.register_callback([shared = shared_]() {
                std::lock_guard lock(shared->mutex);
                shared->waker.wake();
            });
        }

        return pollcoro::awaitable_state<void>::pending();
    }
};

auto sleep_for(std::chrono::milliseconds ms) {
    return timer_awaitable(ms);
}

Pay attention to how the waker is stored and accessed, when the timer_awaitable is destroyed it unsets the waker so that when the timer fires, the waker is not called. In this example I also use a mutex since awaitables need to be thread-safe. I don’t know how the timer api is implemented, however if it is not on the same thread as the awaitable, you need to use a mutex to ensure thread-safety.

Performance Tips

Coroutines vs Custom Awaitables

Every pollcoro::task<T> or pollcoro::stream<T> allocates a coroutine frame on the heap. Custom awaitables that implement poll() directly are just regular objects with no heap allocation.

Suboptimal — coroutine allocates heap memory:

pollcoro::task<int> add_one(int x) {
    co_return x + 1;
}

Better — no allocation:

auto add_one(int x) {
    return pollcoro::ready(x + 1);
}

However, coroutines have a significant advantage: automatic lifetime management. Local variables in a coroutine are stored in the coroutine frame and stay alive across suspension points. With custom awaitables, you must manually manage state and be careful about captures:

// DANGEROUS: captures reference to local variable
auto bad_awaitable(std::vector<int> data) {
    return pollcoro::iter(data);  // data may be destroyed!
}

// SAFE: coroutine keeps locals alive
pollcoro::stream<int> safe_stream(std::vector<int> data) {
    for (auto& item : data) {  // data lives in coroutine frame
        co_yield item;
    }
}

When to use coroutines (task<T>, stream<T>):

  • Complex control flow with multiple suspension points

  • When local variables need to persist across co_await

  • Yielding sequences with co_yield

  • Prototyping — easier to write correctly

When to use custom awaitables:

  • Simple transformations (use pollcoro::ready, pollcoro::map)

  • Wrapping external async APIs

  • Hot paths where allocation matters

  • Building reusable combinators

Non-Blocking Pipelines

Fully non-blocking pipelines can be optimized away entirely:

// This compiles to a simple loop — no coroutine overhead
auto sum = co_await pollcoro::fold(
    pollcoro::range(1000) | pollcoro::map(square) | pollcoro::take(10),
    0,
    [](int& acc, int n) { acc += n; }
);

Not Yet Implemented

The following features are planned but not yet available:

  • I/O operations — file reading/writing, sockets, networking

  • Multi-threaded executors — running tasks across multiple threads

  • Detached tasks — tasks that get polled to completion without explicitly waiting for them

  • More bindings examples — interoperability with Rust, Python, and other languages.

Contributions welcome!

Requirements

  • C+20 compiler with modules support (Clang 16, MSVC 19.34+)

  • CMake 3.28+

Warning

GCC has very poor C++20 modules support and may or may not compile pollcoro. We recommend using Clang for the best experience.

License

MIT License — see LICENSE for details.