Skip to content

Submit Functions and Data

Goal

Capture the inputs required by finite work in a lambda, then pass it to submit_auto(lambda). Understand value capture, move capture, and shared ownership, and keep every required object alive until the task finishes.

Default: submit_auto(lambda)

Use a value-capturing lambda for the normal path. The runnable tutorial starts here, then shows multi-value and move captures:

cpp
#include <atomic>
#include <iostream>
#include <memory>
#include <string>
#include <utility>

#include <executor/executor.hpp>

namespace {

struct SensorFrame {
    int id;
    int samples;
};

int score_frame(SensorFrame frame, int weight) {
    return frame.samples * weight;
}

class Planner {
public:
    explicit Planner(std::string name) : name_(std::move(name)) {}

    std::string make_plan(SensorFrame frame) const {
        return name_ + "-frame-" + std::to_string(frame.id);
    }

private:
    std::string name_;
};

}

int main() {
    auto& executor = executor::Executor::instance();
    SensorFrame frame{7, 21};

    auto score = executor.submit_auto([frame] {
        return score_frame(frame, 2);
    });

    int offset = 5;
    auto adjusted = executor.submit_auto([frame, offset]() noexcept {
        return frame.samples + offset;
    });

    auto payload = std::make_unique<int>(9);
    auto owned = executor.submit_auto([payload = std::move(payload)]() mutable noexcept {
        return *payload;
    });

    auto planner = std::make_shared<Planner>("local");
    auto plan = executor.submit_auto([planner, frame] {
        return planner->make_plan(frame);
    });

    auto processed = std::make_shared<std::atomic<int>>(0);
    auto counted = executor.submit_auto([processed] {
        processed->fetch_add(1);
    });

    std::cout << "score=" << score.get() << ", plan=" << plan.get()
              << ", adjusted=" << adjusted.get() << ", owned=" << owned.get()
              << '\n';
    counted.get();
    std::cout << "processed=" << processed->load() << '\n';

    executor.shutdown();
    return 0;
}

The closures respectively own frame, offset, and moved payload; their futures represent completion or exception. Mutating the submitter's later frame does not change the copy held by the task. submit_auto(lambda) safely selects the default asynchronous backend; it does not infer a GPU, lock-free, or real-time route from the callable. get_last_routing_decision() can explain the selected path but does not reserve it or replace the future.

Submit a member function

A member function follows the same rule: capture a stable owner and inputs by value in a lambda. Prefer std::shared_ptr so the object survives until execution finishes:

cpp
#include <atomic>
#include <iostream>
#include <memory>
#include <string>
#include <utility>

#include <executor/executor.hpp>

namespace {

struct SensorFrame {
    int id;
    int samples;
};

int score_frame(SensorFrame frame, int weight) {
    return frame.samples * weight;
}

class Planner {
public:
    explicit Planner(std::string name) : name_(std::move(name)) {}

    std::string make_plan(SensorFrame frame) const {
        return name_ + "-frame-" + std::to_string(frame.id);
    }

private:
    std::string name_;
};

}

int main() {
    auto& executor = executor::Executor::instance();
    SensorFrame frame{7, 21};

    auto score = executor.submit_auto([frame] {
        return score_frame(frame, 2);
    });

    int offset = 5;
    auto adjusted = executor.submit_auto([frame, offset]() noexcept {
        return frame.samples + offset;
    });

    auto payload = std::make_unique<int>(9);
    auto owned = executor.submit_auto([payload = std::move(payload)]() mutable noexcept {
        return *payload;
    });

    auto planner = std::make_shared<Planner>("local");
    auto plan = executor.submit_auto([planner, frame] {
        return planner->make_plan(frame);
    });

    auto processed = std::make_shared<std::atomic<int>>(0);
    auto counted = executor.submit_auto([processed] {
        processed->fetch_add(1);
    });

    std::cout << "score=" << score.get() << ", plan=" << plan.get()
              << ", adjusted=" << adjusted.get() << ", owned=" << owned.get()
              << '\n';
    counted.get();
    std::cout << "processed=" << processed->load() << '\n';

    executor.shutdown();
    return 0;
}

Do not capture a raw this pointer or address of a local object: a worker dereferences a dangling object if its owner is destroyed before execution begins. Even a service-owned object needs shutdown order that stops new submissions, waits for its tasks, then destroys the owner.

Organize inputs with a lambda

Use a lambda to combine inputs at the submission point, do a small amount of preprocessing, or select an overload. Capture by value by default; the score and adjusted submissions above respectively show one and multiple captured values. [frame, offset] copies both values into the closure, so the task does not depend on the submitting function's stack frame. Avoid casually using [&]: an asynchronous task commonly runs after the current scope ends, and reference captures can dangle or race with later mutations.

For large inputs, first establish that copying is actually a bottleneck. Typical alternatives are moving an exclusively owned resource or sharing an immutable object:

cpp
auto model = std::make_shared<const Model>(load_model());
auto result = executor.submit_auto([model, frame] {
    return infer(*model, frame);
});

Move exclusive ownership into a task

To transfer a std::unique_ptr, buffer handle, or other exclusive resource, use a move capture:

cpp
#include <atomic>
#include <iostream>
#include <memory>
#include <string>
#include <utility>

#include <executor/executor.hpp>

namespace {

struct SensorFrame {
    int id;
    int samples;
};

int score_frame(SensorFrame frame, int weight) {
    return frame.samples * weight;
}

class Planner {
public:
    explicit Planner(std::string name) : name_(std::move(name)) {}

    std::string make_plan(SensorFrame frame) const {
        return name_ + "-frame-" + std::to_string(frame.id);
    }

private:
    std::string name_;
};

}

int main() {
    auto& executor = executor::Executor::instance();
    SensorFrame frame{7, 21};

    auto score = executor.submit_auto([frame] {
        return score_frame(frame, 2);
    });

    int offset = 5;
    auto adjusted = executor.submit_auto([frame, offset]() noexcept {
        return frame.samples + offset;
    });

    auto payload = std::make_unique<int>(9);
    auto owned = executor.submit_auto([payload = std::move(payload)]() mutable noexcept {
        return *payload;
    });

    auto planner = std::make_shared<Planner>("local");
    auto plan = executor.submit_auto([planner, frame] {
        return planner->make_plan(frame);
    });

    auto processed = std::make_shared<std::atomic<int>>(0);
    auto counted = executor.submit_auto([processed] {
        processed->fetch_add(1);
    });

    std::cout << "score=" << score.get() << ", plan=" << plan.get()
              << ", adjusted=" << adjusted.get() << ", owned=" << owned.get()
              << '\n';
    counted.get();
    std::cout << "processed=" << processed->load() << '\n';

    executor.shutdown();
    return 0;
}

After submission, the original payload is empty and the closure exclusively owns the resource. This is easier to reason about than a raw pointer. Do not use the moved-from object again as a caller input.

Move capture makes the closure the explicit resource owner. If a business function needs a std::unique_ptr or T&& by value, decide where to std::move inside the lambda instead of lending the resource to asynchronous work.

Share and modify state

When a task must modify cross-thread state, capture an owner with an explicit synchronization contract instead of borrowing a reference from the submitter's stack:

cpp
#include <atomic>
#include <iostream>
#include <memory>
#include <string>
#include <utility>

#include <executor/executor.hpp>

namespace {

struct SensorFrame {
    int id;
    int samples;
};

int score_frame(SensorFrame frame, int weight) {
    return frame.samples * weight;
}

class Planner {
public:
    explicit Planner(std::string name) : name_(std::move(name)) {}

    std::string make_plan(SensorFrame frame) const {
        return name_ + "-frame-" + std::to_string(frame.id);
    }

private:
    std::string name_;
};

}

int main() {
    auto& executor = executor::Executor::instance();
    SensorFrame frame{7, 21};

    auto score = executor.submit_auto([frame] {
        return score_frame(frame, 2);
    });

    int offset = 5;
    auto adjusted = executor.submit_auto([frame, offset]() noexcept {
        return frame.samples + offset;
    });

    auto payload = std::make_unique<int>(9);
    auto owned = executor.submit_auto([payload = std::move(payload)]() mutable noexcept {
        return *payload;
    });

    auto planner = std::make_shared<Planner>("local");
    auto plan = executor.submit_auto([planner, frame] {
        return planner->make_plan(frame);
    });

    auto processed = std::make_shared<std::atomic<int>>(0);
    auto counted = executor.submit_auto([processed] {
        processed->fetch_add(1);
    });

    std::cout << "score=" << score.get() << ", plan=" << plan.get()
              << ", adjusted=" << adjusted.get() << ", owned=" << owned.get()
              << '\n';
    counted.get();
    std::cout << "processed=" << processed->load() << '\n';

    executor.shutdown();
    return 0;
}

This example uses shared_ptr<atomic<int>> to express both shared lifetime and atomic access. shared_ptr extends a lifetime only; it does not make an arbitrary object thread-safe. Non-atomic state still needs its own mutex, message-passing, or synchronization protocol.

Do not use [&] or a raw reference for asynchronous input: neither extends lifetime nor supplies thread safety. future.get() waits for completion but cannot repair a race that occurred while the task ran.

Choose ownership deliberately

NeedRecommended formWhat the task depends onMain risk
Small read-only inputsubmit_auto([value] { ... })Its own copyCopying cost
Transfer an exclusive resource[value = std::move(value)]Exclusive ownershipSubmitter cannot reuse the moved value
Share a large immutable objectCapture shared_ptr<const T>Shared lifetimeReference-count and residency cost
Invoke a member function[owner, value] { return owner->method(value); }Object survives completionA raw object pointer can dangle
Share mutable stateCapture a shared_ptr with a synchronization contractShared owner and synchronization rulesData race, shutdown order

API-specific input shapes

When the business explicitly requires priority, delay, periodic scheduling, batching, or dependencies, enter the matching explicit API. On every future path, bind stable inputs to a lambda by default. Longer delays and dependency chains make borrowed inputs more dangerous because execution begins later.

Periodic tasks and batches have different shapes: submit_periodic() takes repeatable void() work, and batches take independently bound void() callables. Real-time callbacks and queue entries also use pre-bound void() work. CPU/GPU routing requires the separate CPU and GPU callables of cpu_gpu_task(); it is not an ordinary argument-pack variant. Read the corresponding real-time, GPU, and communication contracts before using those paths.

Build and run

bash
cmake --build build --target tutorial_11_task_inputs
./build/examples/tutorial/tutorial_11_task_inputs

Expected output:

text
score=42, plan=local-frame-7, adjusted=26, owned=9
processed=1

Next, read return values and errors to see how these inputs return control through a future after success or failure; use Choose a Submission API when timing or result-model requirements change.