Communicating Between Event Loops

A worker can calculate a result while your main loop continues responding to input. The useful boundary is ownership: each component updates its state on its own loop, and sends other components commands or results that own their data. This page develops a request and reply exchange, then handles input that replaces a request before its reply arrives.

This example builds on worker creation in Processing Work on Event Threads, recipient lifetime in Keeping Callbacks and Their Owners Alive, and the return path introduced in Simplifying Event Code with Bindings. Here the main loop stays running throughout the exchange; it is the receiver as well as the producer of requests.

State Belongs to a Loop

Choose one loop to own each component’s mutable state. A preview controller, for example, can own the selected input, latest request identifier, and displayed result on main. A worker receives a copy of the input and produces a separate result. The worker never reads the controller’s changing selection and never writes its displayed result directly.

Events::invoke() queues a callback on the receiving loop. Calling an ordinary method does not move execution to another thread, even when the object owns an event target. To call a worker-owned component, capture its independent input and queue the method call through that component’s Events target, retained as an EventsPtr. Only the callback executing there accesses its mutable state.

--- config: theme: default fontFamily: Lato, proxima-nova, "Helvetica Neue", Arial, sans-serif flowchart: padding: 2 --- sequenceDiagram participant Main as Main loop / preview state participant Worker as Worker loop / calculation Main->>Worker: invoke(command with input and bound reply) Note over Main: Return to dispatch, other input can run Worker->>Worker: Calculate from owned input Worker->>Main: Call bound reply with result Main->>Main: Check identifier and update preview

This arrangement needs no mutex around preview state because one thread performs all its reads and writes. Capturing a shared pointer does not provide the same guarantee: if two loops mutate its pointee, you still need synchronization or a different ownership design. Likewise, references and views into the sender’s mutable buffer are not independent input values.

A Running Main as the Reply Target

The text-preview application transforms text on one managed worker and receives the result on main. Uppercasing a short string makes the exchange easy to inspect; a real application could put an expensive parser or image conversion at the same worker boundary. The sample does not need another thread for the cost of this particular transformation.

The application object is the controller and outlives the exchange and worker shutdown. Its retained members make ownership explicit:

event/TextPreview/TextPreviewApp.hpp

/// Keep preview state on main while a worker returns independently owned text values.
class TextPreviewApp final : public el::Application {
public:
    using Application::Application;

protected: // implement Application
    void initialize() override;
    void registerCommandLineOptions(const el::OptionsPtr &options) override;
    [[nodiscard]] auto main() -> el::ExitCode override;
    void cleanup() noexcept override;

private:
    void begin();
    void requestPreview(el::String text);
    void receivePreview(std::uint64_t requestId, el::String preview);

private:
    el::ManagedEventThreadPtr _worker; ///< Retained on main until application shutdown joins it.
    el::EventTimerPtr _deadline;       ///< Main-loop timer bounds the wait for replies.
    el::String _scenario;              ///< Configured before dispatch begins; read only on main.
    std::uint64_t _requestId{0};       ///< Latest request identity, owned by main.
    int _received{0};                  ///< All replies, including obsolete ones, owned by main.
    int _accepted{0};                  ///< Replies allowed to update the preview, owned by main.
    el::String _preview;               ///< Display state, owned by main.
};

The derived main() starts the worker, posts begin() to main, and enters runEventLoop(). It checks completion after dispatch ends. The worker uses FailApplication so an exception cannot silently remove a required reply; see the failure path below. The worker handle stays in _worker until application shutdown has joined it.

For one request, begin() calls requestPreview() from a callback on main. That context matters: currentEvents() identifies the loop currently executing on the calling thread. Here bindEvent captures that reply address before dispatch crosses to the worker:

event/TextPreview/TextPreviewApp.cpp

void TextPreviewApp::requestPreview(el::String text) {
    const auto requestId = ++_requestId;
    // Bind the reply on main before sending work to another loop.
    auto reply =
        el::bindEvent([this, requestId](el::String preview) -> void { receivePreview(requestId, std::move(preview)); });
    _worker->events()->invoke([reply = std::move(reply), text = std::move(text)]() -> void {
        // Worker uses only captured input; main owns every application-state update.
        reply(el::toUppercase(text));
    });
}
$ event/text_preview --scenario single
Accepted preview: FIRST DRAFT

The worker callback owns the input and the bound reply. toUppercase() derives a new string value without changing the captured input. Calling reply transfers the computed preview into a delivery for main. The binding’s handler retains the request identifier, so receivePreview() can associate that value with its input. Only dispatch on main dereferences this to update controller state.

Creating the binding inside the worker callback would capture the worker loop instead. Create it at the sending end, or pass the intended target explicitly when creating it outside a running loop. The function cannot infer which component should receive a result.

Target Lifetime and Recipient Lifetime

The bound reply retains its event target. It does not retain the controller addressed by this, keep its thread running, or promise continued dispatch. The application’s lifetime supplies the recipient guarantee here: the stack-owned application survives run(), including managed-thread shutdown, while worker callbacks can refer to it.

For a shorter-lived controller owned by a shared pointer, a member binding can retain it strongly, or bindWeakEvent can suppress delivery after it disappears. A weak reply also needs a completion policy: another component waiting for the result must be able to cancel its wait or observe a deadline. Simplifying Event Code with Bindings demonstrates optional weak delivery separately from required operation completion.

Replacing a Request Before Its Reply

Suppose the user edits the input while conversion is pending. A reply can be valid for its original request and still be wrong for the currently selected input. Assign an identifier on the controller’s loop and return it unchanged with the result. The reply may update the preview only if its identifier is still current.

The replacement scenario submits both inputs in the same main-loop callback:

event/TextPreview/TextPreviewApp.cpp

// Submit initial input, then optionally replace it before main can dispatch a reply.
requestPreview("First draft"_el);
if (_scenario == "replacement"_el) {
    requestPreview("Revised draft"_el);
}

Even if the worker finishes the first input immediately, main cannot dispatch that reply until begin() returns. By then _requestId identifies the revised input. This creates an obsolete reply deterministically, without sleeps or assumptions about relative thread speed. The old result arrives after replacement, even though this single worker returns the two results in submission order.

event/TextPreview/TextPreviewApp.cpp

void TextPreviewApp::receivePreview(const std::uint64_t requestId, el::String preview) {
    if (el::currentEvents() != events()) {
        throw el::LogicFailure{"Preview delivered to the wrong loop."_el};
    }
    ++_received;
    if (requestId == _requestId) {
        _preview = std::move(preview);
        ++_accepted;
        el::io::printLine("Accepted preview: "_el, _preview);
    } else {
        el::io::printLine("Ignored obsolete preview."_el);
    }
    if (_received == static_cast<int>(_requestId)) {
        _deadline->stop();
        quit();
    }
}
$ event/text_preview --scenario replacement
Ignored obsolete preview.
Accepted preview: REVISED DRAFT

_preview changes only for the current identifier. _received counts both replies because this bounded demo verifies the complete exchange before quitting. An obsolete reply is ignored for display but remains part of the demo’s completion accounting. The post-loop checks require both expected replies, one accepted result, and the expected preview text.

Waiting for every obsolete reply is a demonstration choice, not a requirement for an interactive controller. A real controller can retire old request state as soon as its protocol permits, and keep running for further input. For success, cancellation, and timeout races within one operation, also check whether that operation is still pending. A matching identity alone does not establish that a result is still needed.

The identity check remains useful with multiple workers, where results can arrive in a different order from requests. Independent loops and competing producers do not establish a global execution order. If operations must run in a particular sequence, encode the dependency in messages or keep them on one owner loop.

Completion, Failure, and Responsiveness

The sample retains a one-second timer on main while replies are outstanding. A missing reply therefore ends the exchange with an error instead of leaving the application running indefinitely. When all replies arrive, it stops that timer and requests application shutdown. The missing scenario deliberately submits no work after arming the deadline, verifying that the wait fails. The failure scenario queues a throwing worker callback, verifying managed failure propagation. Both exit with failure; single and replacement verify their expected results and exit successfully.

The main loop’s error handler ends dispatch on a callback exception, and the code after runEventLoop() retrieves and rethrows the stored error for application diagnostics. On the worker, FailApplication reports the failure to the application and starts managed shutdown. Neither path assumes that continuing dispatch means a request succeeded. The exact policy and propagation contracts are in Queue Capacity and Error Policies.

Moving the conversion to a worker keeps main available for input and replies. The worker loop still executes one callback at a time: a long conversion delays its later commands, including queued cancellation requests. A deadline on main can detect missing progress, but cannot interrupt that conversion or make its eventual join finish. For cooperative cancellation and teardown ordering, continue with Cancelling Work and Shutting Down.

Main remains responsive because it returns to dispatch after submitting each request. Blocking there to wait for a reply would prevent the reply callback from running on that same loop. The procedural file hasher in Processing Work on Event Threads can wait because a separate, running receiver owns its entire completion path. If the worker needs dependencies, readiness reporting, or asynchronous resource closure, the same message flow can connect application parts; Using Event Loops in Application Parts explains that lifecycle.

Complete demo sources

event/TextPreview/TextPreviewApp.hpp

// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#pragma once

#include <erbsland/all_core.hpp>
#include <erbsland/all_event.hpp>
#include <erbsland/all_options.hpp>
#include <erbsland/all_text.hpp>
#include <erbsland/Application.hpp>
#include <erbsland/EventTimer.hpp>
#include <erbsland/ExitCode.hpp>
#include <erbsland/ManagedEventThread.hpp>
#include <erbsland/Options.hpp>
#include <erbsland/String.hpp>

#include <cstdint>

namespace demo {

/// Keep preview state on main while a worker returns independently owned text values.
class TextPreviewApp final : public el::Application {
public:
    using Application::Application;

protected: // implement Application
    void initialize() override;
    void registerCommandLineOptions(const el::OptionsPtr &options) override;
    [[nodiscard]] auto main() -> el::ExitCode override;
    void cleanup() noexcept override;

private:
    void begin();
    void requestPreview(el::String text);
    void receivePreview(std::uint64_t requestId, el::String preview);

private:
    el::ManagedEventThreadPtr _worker; ///< Retained on main until application shutdown joins it.
    el::EventTimerPtr _deadline;       ///< Main-loop timer bounds the wait for replies.
    el::String _scenario;              ///< Configured before dispatch begins; read only on main.
    std::uint64_t _requestId{0};       ///< Latest request identity, owned by main.
    int _received{0};                  ///< All replies, including obsolete ones, owned by main.
    int _accepted{0};                  ///< Replies allowed to update the preview, owned by main.
    el::String _preview;               ///< Display state, owned by main.
};

}

event/TextPreview/TextPreviewApp.cpp

// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#include "TextPreviewApp.hpp"

#include <erbsland/all_stream.hpp>
#include <erbsland/all_time.hpp>
#include <erbsland/ApplicationError.hpp>
#include <erbsland/Char.hpp>
#include <erbsland/EventLoopErrorAction.hpp>
#include <erbsland/EventLoopErrorPolicy.hpp>
#include <erbsland/EventLoopOptions.hpp>
#include <erbsland/ExitCode.hpp>
#include <erbsland/LogicFailure.hpp>
#include <erbsland/Options.hpp>
#include <erbsland/OptionType.hpp>
#include <erbsland/OptionValues.hpp>
#include <erbsland/StandardStreams.hpp>
#include <erbsland/String.hpp>
#include <erbsland/StringTransformations.hpp>

#include <exception>
#include <utility>

namespace demo {

using namespace el::text::literals;

void TextPreviewApp::initialize() {
    info().setApplicationName("Text Preview"_el);
}

void TextPreviewApp::registerCommandLineOptions(const el::OptionsPtr &options) {
    options->addOption({"--scenario"_el, "scenario"_el})
        .setType(el::OptionType::Text)
        .setDefaultValue("replacement"_el)
        .setHelp("Scenario: single, replacement, failure, or missing."_el);
}

auto TextPreviewApp::main() -> el::ExitCode {
    _scenario = optionValues()->getText("scenario"_el);
    if (_scenario != "single"_el && _scenario != "replacement"_el && _scenario != "failure"_el &&
        _scenario != "missing"_el) {
        throw el::ApplicationError{"Unknown preview scenario."_el};
    }
    _worker = createEventThread(el::EventLoopOptions{}.setErrorPolicy(el::EventLoopErrorPolicy::FailApplication));
    _worker->start();
    eventLoop().setErrorHandler(
        [](std::exception_ptr) -> el::EventLoopErrorAction { return el::EventLoopErrorAction::Stop; });
    events()->invoke([this]() -> void { begin(); });
    const auto exitCode = runEventLoop();
    if (const auto error = eventLoop().takeError()) {
        std::rethrow_exception(error);
    }
    // Missing completion must not look like a successful application exit.
    auto expectedReplies = 1;
    auto expectedPreview = "FIRST DRAFT"_el;
    if (_scenario == "replacement"_el) {
        expectedReplies = 2;
        expectedPreview = "REVISED DRAFT"_el;
    }
    if (_received != expectedReplies || _accepted != 1 || _preview != expectedPreview) {
        throw el::ApplicationError{"The preview exchange did not complete correctly."_el};
    }
    return exitCode;
}

void TextPreviewApp::begin() {
    _deadline = events()->createTimer(
        []() -> void { throw el::ApplicationError{"Timed out waiting for preview replies."_el}; });
    _deadline->startOnce(el::Seconds{1});
    if (_scenario == "failure"_el) {
        _worker->events()->invoke([]() -> void { throw el::ApplicationError{"Preview worker failed."_el}; });
        return;
    }
    if (_scenario == "missing"_el) {
        return;
    }
    // Submit initial input, then optionally replace it before main can dispatch a reply.
    requestPreview("First draft"_el);
    if (_scenario == "replacement"_el) {
        requestPreview("Revised draft"_el);
    }
}

void TextPreviewApp::requestPreview(el::String text) {
    const auto requestId = ++_requestId;
    // Bind the reply on main before sending work to another loop.
    auto reply =
        el::bindEvent([this, requestId](el::String preview) -> void { receivePreview(requestId, std::move(preview)); });
    _worker->events()->invoke([reply = std::move(reply), text = std::move(text)]() -> void {
        // Worker uses only captured input; main owns every application-state update.
        reply(el::toUppercase(text));
    });
}

void TextPreviewApp::receivePreview(const std::uint64_t requestId, el::String preview) {
    if (el::currentEvents() != events()) {
        throw el::LogicFailure{"Preview delivered to the wrong loop."_el};
    }
    ++_received;
    if (requestId == _requestId) {
        _preview = std::move(preview);
        ++_accepted;
        el::io::printLine("Accepted preview: "_el, _preview);
    } else {
        el::io::printLine("Ignored obsolete preview."_el);
    }
    if (_received == static_cast<int>(_requestId)) {
        _deadline->stop();
        quit();
    }
}

void TextPreviewApp::cleanup() noexcept {
    _deadline.reset();
}

}