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. |
Table of Contents
- Features
- Polling vs Resume-Based Coroutines
- Installation
- Quick Start
- Examples
- Core Concepts
- API Reference
pollcoro::task<T>pollcoro::stream<T>pollcoro::nextpollcoro::pendingpollcoro::readypollcoro::stream_awaitable_state<T>pollcoro::block_onpollcoro::sleep_for/pollcoro::sleep_untilpollcoro::wait_allpollcoro::wait_firstpollcoro::single_event<T>pollcoro::to_pollablepollcoro::to_resumable- ASIO Integration
pollcoro::mappollcoro::refpollcoro::genericpollcoro::yieldpollcoro::allocator
- Stream Combinators
pollcoro::takepollcoro::skippollcoro::take_whilepollcoro::skip_whilepollcoro::chainpollcoro::zippollcoro::enumeratepollcoro::flattenpollcoro::windowpollcoro::foldpollcoro::lastpollcoro::nthpollcoro::iter/pollcoro::iter_movepollcoro::rangepollcoro::repeatpollcoro::emptypollcoro::sync_iterpollcoro::awaitable_state<T>pollcoro::waker
- Build Options
- Writing Custom Awaitables
- Performance Tips
- Not Yet Implemented
- Requirements
- License
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 streams —
stream<T>withco_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_packagesupport
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 (Recommended)
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)
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_allto 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_awaitableandgeneric_stream_awaitable -
wait_combinators.cc — Concurrent operations with
wait_allandwait_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_untilwith a custom timer implementation -
interop_cppcoro.cc — Bidirectional interop with cppcoro using
to_pollableandto_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
refto 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:
-
Direct awaitable — if the type itself has a
poll()method, use it directly -
Dereferenceable — if the type supports
*t(likestd::shared_ptr,std::unique_ptr, raw pointers), dereference to get the awaitable -
.get()method — if the type has a.get()method (likestd::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 |
|---|---|---|
|
|
Generate install targets |
|
|
Build test suite |
|
|
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
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!