How to Integrate ASIO with Custom Event Loops: Two Proven Patterns
You can integrate ASIO with custom event loops by either polling the io_context inside your own loop using poll_one() or run_one(), or by implementing a custom Executor that forwards ASIO completion handlers to your scheduler.
The chriskohlhoff/asio library decouples asynchronous I/O operations from the mechanism that runs them, making it possible to embed its networking and timer facilities into existing applications. To integrate ASIO with custom event loops, you interact with the io_context class defined in include/asio/io_context.hpp and leverage the lightweight Executor concept found in include/asio/executor.hpp. This architecture allows ASIO to coexist with game engines, GUI frameworks, or proprietary reactors without forcing you to abandon your existing threading model.
Poll-in-Your-Loop Pattern
The simplest way to integrate ASIO with custom event loops is to embed an io_context inside your existing event loop and manually drive its reactor.
When to Use This Approach
Use this pattern when you already have a main loop—such as a game loop or a GUI message pump—and you want ASIO to cooperate without taking control of the thread. Because the loop is exposed as ordinary member functions, you can call io_context::poll_one() or io_context::run_one() at each iteration to process ready handlers and immediately return to your surrounding code.
Implementation Details
The io_context class in include/asio/io_context.hpp provides several non-blocking methods:
poll()– executes all ready handlers without blocking.poll_one()– executes at most one ready handler without blocking.run_one()– blocks until at least one handler executes, then returns.
For a cooperative loop, poll_one() is typically preferred because it returns immediately if no work is ready, allowing your loop to continue processing other events.
Example: Embedding ASIO in a Game Loop
// custom_loop.cpp
#include <asio.hpp>
#include <iostream>
int main() {
asio::io_context ctx;
// Example async timer that fires after 1 s
asio::steady_timer t(ctx, std::chrono::seconds(1));
t.async_wait([](const asio::error_code& ec) {
if (!ec) std::cout << "Timer expired!\n";
});
// Your own event loop (e.g. a game loop)
for (int frame = 0; frame < 100; ++frame) {
// ...do your per-frame work here...
// Process any ready ASIO handlers without blocking
ctx.poll_one(); // or ctx.run_one(); if you prefer blocking a little
}
}
In this example, the call to ctx.poll_one() in include/asio/io_context.hpp drains any ready ASIO handlers (the timer in this case) and immediately returns control to the surrounding loop, ensuring your frame processing continues uninterrupted.
Custom Executor Pattern
For deeper integration, you can implement a custom Executor that satisfies the Executor concept defined in include/asio/executor.hpp. This allows ASIO to post completion handlers directly onto your own scheduler, such as a task queue or thread pool.
Implementing the Executor Concept
According to the ASIO source code in include/asio/executor.hpp, a type satisfies the Executor concept if it provides an execute(F) function that accepts a callable, along with optional context() and blocking traits. ASIO uses this interface via asio::any_io_executor defined in include/asio/any_io_executor.hpp to type-erase and forward work.
Thread Pool Executor Example
// custom_executor.cpp
#include <asio.hpp>
#include <queue>
#include <mutex>
#include <thread>
#include <iostream>
// A very simple thread-pool executor
class thread_pool_executor {
public:
thread_pool_executor(std::size_t n) : stop_(false) {
for (std::size_t i = 0; i < n; ++i)
workers_.emplace_back([this] { this->worker_thread(); });
}
~thread_pool_executor() {
{
std::lock_guard<std::mutex> lk(m_);
stop_ = true;
}
cv_.notify_all();
for (auto& th : workers_) th.join();
}
// Executor requirement
template<class F>
void execute(F f) const {
{
std::lock_guard<std::mutex> lk(m_);
tasks_.push(std::function<void()>(f));
}
cv_.notify_one();
}
private:
void worker_thread() {
while (true) {
std::function<void()> work;
{
std::unique_lock<std::mutex> lk(m_);
cv_.wait(lk, [this] { return stop_ || !tasks_.empty(); });
if (stop_ && tasks_.empty()) return;
work = std::move(tasks_.front());
tasks_.pop();
}
work();
}
}
mutable std::mutex m_;
mutable std::condition_variable cv_;
std::queue<std::function<void()>> tasks_;
std::vector<std::thread> workers_;
bool stop_;
};
Here, thread_pool_executor satisfies the Executor concept by providing the execute(F) method. The asio::post function in include/asio/post.hpp and asio::bind_executor use this interface to forward work to your custom scheduler.
Binding Custom Executors to ASIO Operations
Once you have a custom executor, you can bind it to specific operations using asio::bind_executor:
int main() {
asio::io_context ctx;
thread_pool_executor exec(4); // 4-thread pool
// Bind the custom executor to all subsequent async operations
asio::post(exec, []{ std::cout << "Hello from custom executor!\n"; });
// Example async read using the bound executor
asio::steady_timer t(ctx, std::chrono::seconds(1));
t.async_wait(
asio::bind_executor(exec,
[](const asio::error_code& ec) {
if (!ec) std::cout << "Timer fired on custom executor\n";
})
);
// Run the ASIO context (it will only drive the timer)
ctx.run();
}
In this pattern, ASIO still requires an io_context to drive the underlying reactor (timers, sockets), but the completion handlers execute on your custom thread pool instead of the io_context's internal threads.
Co-Spawn with Coroutines
If you use C++20 coroutines, you can launch asio::awaitable tasks directly onto your custom executor using asio::co_spawn, defined in include/asio/awaitable.hpp:
#include <asio.hpp>
#include <iostream>
template<class Executor>
asio::awaitable<void> periodic_task(Executor exec) {
auto timer = asio::steady_timer(co_await asio::this_coro::executor);
while (true) {
timer.expires_after(std::chrono::seconds(2));
co_await timer.async_wait(asio::use_awaitable);
std::cout << "Tick on custom executor\n";
}
}
int main() {
asio::io_context ctx;
thread_pool_executor exec(2);
// Co-spawn the coroutine onto the custom executor
asio::co_spawn(exec,
periodic_task(exec),
asio::detached); // fire-and-forget
ctx.run(); // drives the timer's internal reactor
}
Because asio::co_spawn accepts any executor satisfying the concept, the coroutine’s resumption—including its timer waits—will be scheduled onto the thread_pool_executor.
Key ASIO Components for Custom Integration
Understanding these core components from the chriskohlhoff/asio source code is essential for successful integration:
io_context(include/asio/io_context.hpp): The central hub that owns the reactor and stores work. It providesrun(),run_one(),poll(), andpoll_one()to drive the event loop.any_io_executor(include/asio/any_io_executor.hpp): A type-erased wrapper that holds any executor satisfying the Executor concept, used by most ASIO APIs to schedule work.- Executor Concept (
include/asio/executor.hpp): Defines theexecute(F)requirement and associated traits that allow custom schedulers to participate in ASIO's handler dispatch mechanism. postanddispatch(include/asio/post.hpp): Helper functions that forward callables to the associated executor, respecting the executor's scheduling guarantees.awaitableandco_spawn(include/asio/awaitable.hpp): Coroutine support that allows asynchronous operations to be suspended and resumed on a custom executor.
Summary
- Poll-in-your-loop: Call
io_context::poll_one()orrun_one()inside your existing event loop to process ASIO handlers cooperatively without yielding thread control. - Custom Executor: Implement an
execute(F)method satisfying the Executor concept to forward ASIO handlers to your own thread pool or task queue, usingasio::bind_executororasio::postto route work. - Coroutine Support: Use
asio::co_spawnwith your custom executor to run C++20 coroutines on your scheduler while lettingio_context::run()drive the underlying reactor.
Frequently Asked Questions
What is the difference between poll_one() and run_one()?
poll_one() checks for ready handlers and executes at most one, returning immediately if none are available, making it ideal for non-blocking integration. run_one() blocks the calling thread until at least one handler becomes ready and executes it, which is useful when your loop can tolerate brief waits for ASIO events.
How do I integrate ASIO with a GUI framework like Qt or GLFW?
Use the poll-in-your-loop pattern. In your GUI's main event loop (e.g., QApplication::processEvents() or the GLFW render loop), call io_context::poll_one() each iteration. This processes network events and timers without blocking the UI thread, ensuring responsive interfaces while ASIO performs I/O in the background.
Can I use C++20 coroutines with a custom executor?
Yes. The asio::co_spawn function in include/asio/awaitable.hpp accepts any type satisfying the Executor concept. When you spawn a coroutine onto a custom executor, every suspension point (such as co_await on an async operation) will resume on that executor, allowing you to control which thread pool handles the coroutine's logic.
What are the minimum requirements for a custom Executor type?
According to include/asio/executor.hpp, a custom executor must provide an execute(F) function that accepts a callable and invokes it. Optionally, you can define context() to return an execution context and blocking to specify blocking behavior, though ASIO provides reasonable defaults via the executor traits. The asio::any_io_executor class in include/asio/any_io_executor.hpp type-erases these requirements for use in ASIO APIs.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →