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:

#include <boost/corosio/io_context.hpp>
#include <boost/corosio/tcp_acceptor.hpp>
#include <boost/corosio/tcp_socket.hpp>
#include <boost/capy/buffers.hpp>
#include <boost/capy/ex/run_async.hpp>
#include <boost/capy/ex/run.hpp>
#include <boost/capy/ex/thread_pool.hpp>
#include <boost/capy/task.hpp>
#include <boost/capy/write.hpp>

namespace corosio = boost::corosio;
namespace capy    = boost::capy;

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:

  1. Read — runs on the io_context thread. The socket awaitable suspends the coroutine until data arrives from the kernel.

  2. Hash — capy::run( pool.get_executor() ) posts a task that calls compute_fnv1a to 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 the io_context.

  3. Write — back on the io_context thread, 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:

  1. run() creates an awaitable that stores the pool executor.

  2. On co_await, the awaitable’s await_suspend posts the inner task through pool_executor.post(task_handle) — the task is queued for a worker thread.

  3. The calling coroutine suspends (the io_context is free to process other connections).

  4. A pool thread picks up the task and runs it to completion.

  5. The task’s final_suspend resumes a trampoline, which calls io_context_executor.post(caller_handle) to post the caller back to the io_context.

  6. The caller resumes on the io_context thread 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

run_async( ex )( task )

Called from outside a coroutine (e.g., main)

Fire-and-forget: dispatches the task onto the executor

co_await run( ex )( task )

Called from inside a coroutine

Switches executors: runs the task on ex, then resumes the caller on its original executor

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