Hash Server Tutorial
This tutorial builds a TCP server that reads data from clients, computes a
hash on a thread pool, and sends the result back. You’ll learn how to combine
an io_context for network I/O with a thread_pool for CPU-bound work,
switching between them mid-coroutine with capy::run().
|
Code snippets assume:
|
Overview
Most servers spend their time waiting on the network. When the work between
reads and writes is cheap, a single-threaded io_context handles thousands
of connections. But some operations — cryptographic
hashes, compression, image processing — consume real CPU time. Running those
inline blocks the event loop and starves every other connection.
The solution is to keep I/O on the io_context and offload heavy computation
to a thread_pool. Capy’s run() function makes this seamless. A single
co_await runs the work on the pool and resumes the coroutine on the original
executor when the work finishes.
This tutorial demonstrates:
-
Accepting connections with
tcp_acceptor -
Starting independent session coroutines with
run_async -
Switching executors with
capy::run()for CPU-bound work -
The trampoline that returns the coroutine to its home executor
The Hash Function
We use FNV-1a as a stand-in for any CPU-intensive operation. In production you would substitute a cryptographic hash, a compression pass, or whatever work justifies leaving the event loop.
std::uint64_t
compute_fnv1a(char const* data, std::size_t len)
{
constexpr std::uint64_t basis = 14695981039346656037ULL;
constexpr std::uint64_t prime = 1099511628211ULL;
std::uint64_t h = basis;
for (std::size_t i = 0; i < len; ++i)
{
h ^= static_cast<unsigned char>(data[i]);
h *= prime;
}
return h;
}
This is a plain function. It knows nothing about coroutines or executors, so any code can reuse it. The session decides where it runs.
Session Coroutine
Each client connection is handled by a single coroutine:
capy::task<>
do_session(corosio::tcp_socket sock, capy::thread_pool& pool)
{
char buf[4096];
// Read data from client (on io_context)
auto [ec, n] =
co_await sock.read_some(capy::mutable_buffer(buf, sizeof(buf)));
if (ec)
{
sock.close();
co_return;
}
// Switch to thread pool for CPU-bound hash computation,
// then automatically resume on io_context when done
auto hash = co_await capy::run(pool.get_executor())(
[](char const* data, std::size_t len) -> capy::task<std::uint64_t> {
co_return compute_fnv1a(data, len);
}(buf, n));
// Send hex result back to client (on io_context)
auto result = to_hex(hash) + "\n";
[[maybe_unused]] auto [wec, wn] = co_await capy::write(
sock, capy::const_buffer(result.data(), result.size()));
sock.close();
}
Three things happen in sequence, but on two different executors:
-
Read — runs on the
io_contextthread. The socket awaitable suspends the coroutine until data arrives from the kernel. -
Hash —
capy::run( pool.get_executor() )posts a task that callscompute_fnv1ato the thread pool. The coroutine stays suspended while a pool thread runs the task. When the task completes, a trampoline posts the coroutine back to theio_context. -
Write — back on the
io_contextthread, the hex result is sent to the client.
The executor switch is invisible at the call site — it reads like straight-line code.
How run() Switches Executors
When you write:
auto hash = co_await capy::run(pool.get_executor())(
[](char const* data, std::size_t len) -> capy::task<std::uint64_t> {
co_return compute_fnv1a(data, len);
}(buf, n));
run() takes a task, so a coroutine lambda wraps the plain function. The
lambda gets buf and n as parameters, not captures. The lambda object lives
until the end of the full expression, which includes the co_await, so
captures would also be safe here. Parameters are still the habit the
lifetime rules teach, because they live in
the coroutine frame rather than in the lambda object. buf stays valid
because do_session stays suspended until the hash completes.
Behind the scenes:
-
run()creates an awaitable that stores the pool executor. -
On
co_await, the awaitable’sawait_suspendposts the inner task throughpool_executor.post(task_handle)— the task is queued for a worker thread. -
The calling coroutine suspends (the
io_contextis free to process other connections). -
A pool thread picks up the task and runs it to completion.
-
The task’s
final_suspendresumes a trampoline, which callsio_context_executor.post(caller_handle)to post the caller back to theio_context. -
The caller resumes on the
io_contextthread with the hash result.
The key insight: the caller’s executor is captured before the switch and restored automatically after. You never need to manually post back.
Accept Loop
The accept loop creates a socket per connection and starts a session:
capy::task<>
do_accept(
corosio::io_context& ioc,
corosio::tcp_acceptor& acc,
capy::thread_pool& pool)
{
for (;;)
{
corosio::tcp_socket peer(ioc);
auto [ec] = co_await acc.accept(peer);
if (ec)
break;
// Fire-and-forget: each session runs independently
capy::run_async(ioc.get_executor())(do_session(std::move(peer), pool));
}
}
run_async is fire-and-forget — each session runs independently on the
io_context. The accept loop immediately continues waiting for the next
connection.
Main Function
int
main(int argc, char* argv[])
{
if (argc != 2)
{
std::cerr << "Usage: hash_server <port>\n"
"Example:\n"
" hash_server 8080\n";
return EXIT_FAILURE;
}
int port_int = std::atoi(argv[1]);
if (port_int <= 0 || port_int > 65535)
{
std::cerr << "Invalid port: " << argv[1] << "\n";
return EXIT_FAILURE;
}
auto port = static_cast<std::uint16_t>(port_int);
corosio::io_context ioc;
capy::thread_pool pool(4);
// Convenience ctor: open + SO_REUSEADDR + bind + listen
corosio::tcp_acceptor acc(ioc, corosio::endpoint(port));
std::cout << "Hash server listening on port " << port << "\n";
capy::run_async(ioc.get_executor())(do_accept(ioc, acc, pool));
ioc.run();
pool.join();
return EXIT_SUCCESS;
}
The io_context drives all network I/O on the main thread. The thread pool
runs four worker threads for hash computation. pool.join() waits for any
in-flight pool work after the event loop exits.
run_async vs run
These two functions serve different purposes:
| Function | Context | Purpose |
|---|---|---|
|
Called from outside a coroutine (e.g., |
Fire-and-forget: dispatches the task onto the executor |
|
Called from inside a coroutine |
Switches executors: runs the task on |
In this example, run_async starts the accept loop from main, and
run switches individual hash computations to the thread pool from within
a session coroutine.
Testing
Start the server:
$ ./hash_server 8080
Hash server listening on port 8080
Send data with netcat:
$ echo "hello world" | nc -q1 localhost 8080
782e1488cd5a68b7
$ echo "test data 123" | nc -q1 localhost 8080
daf63590896c6e23
Each request reads one chunk, hashes it on the thread pool, and returns the 16-character hex digest.
Next Steps
-
I/O Context Guide — Deep dive into event loop mechanics
-
Acceptors Guide — Acceptor options and multi-port binding
-
Sockets Guide — Socket operations in detail
-
Composed Operations — Understanding
write()