Tasks#
pw_async2: Cooperative async tasks for embedded
Tasks are the top-level “thread” primitives of pw_async2. This guide
provides detailed usage information about the Task API
of pw_async2.
Background#
For a detailed conceptual explanation of tasks, see Informed poll. For a hands-on introduction to using tasks, see Codelab.
Task implementations#
Tasks are created by deriving from pw::async2::Task. See Implementing tasks for details.
Pigweed also provides several Task implementations for specific scenarios.
Use these with caution. Systems should be built around composable tasks. Avoid
using tasks as callback-style constructions. To realize the full benefits of
pw_async2, implement your system’s logic within pw_async2 tasks that
interact with one another through async primitives like futures and channels.
FuncTask: Use a function as a task#
FuncTask is a convenient way to write a simple
task. FuncTask implements Poll<> Task::DoPend(Context&) with a function or lambda. Like any other task,
FuncTask task supports suspending and resuming and must return either
Pending() or Ready().
pw::async2::FuncTask task(
[&float_provider](pw::async2::Context& cx) -> pw::async2::Poll<> {
while (true) {
// Read a raw sensor sample when one is available.
PW_TRY_READY_ASSIGN(int raw_sample, ReadSensor(cx));
// Convert the raw values to standard units.
float sensor_value = ConvertSensorReadingToValue(raw_sample);
float_provider.Resolve(sensor_value);
}
});
dispatcher_.Post(task);
FuncTask can create tasks that run a class member function in a task. This is helpful when the class spawns multiple tasks, so cannot inherit from Task itself.
pw::async2::FuncTask task(
[this](pw::async2::Context& cx) { return RunCalibration(cx); });
dispatcher.Post(task);
Do not overuse
Tasks usually need to store futures that represent ongoing operations, and a
FuncTask that wraps a lambda function is not
well suited for that. If a FuncTask must store futures or other data,
convert it to a regular task.
FutureTask: Run a future in a task#
FutureTask is a task that runs a single future to
completion in a task. The value produced by the future is accessible by calling
Wait() or, if the task has been joined, value().
FutureTask can take ownership of a future:
int FutureTaskOwnsTheFuture(pw::async2::Dispatcher& dispatcher,
pw::async2::ValueFuture<int>&& future) {
pw::async2::FutureTask task(std::move(future));
dispatcher.Post(task);
return task.Wait(); // Join the FutureTask and return its value
}
or use a reference to a future:
int FutureTaskReferencesTheFuture(pw::async2::Dispatcher& dispatcher,
pw::async2::ValueFuture<int>& future) {
pw::async2::FutureTask task(future);
dispatcher.Post(task);
return task.Wait(); // Join the FutureTask and return its value
}
Do not overuse
FutureTask is intended for test and occasional
production use. A FutureTask does not contain logic, and relying too much
on FutureTasks could push logic out of async tasks, which nullifies the
benefits of pw_async2. Creating a task for each future is also less
efficient than having one task work with multiple futures.
RunOnceTask: Run an arbitrary function in a task#
RunOnceTask is a task that invokes a function once then returns Ready. The function’s return value can optionally be stored in the task.
// Calculate the 8th fibonacci number on the dispatcher thread.
pw::async2::RunOnceTask task([] {
int a = 0, b = 1;
for (int i = 3; i <= 8; i++) {
a = std::exchange(b, a + b);
}
return b;
});
dispatcher_.Post(task);
int result = task.Wait(); // returns 13
Use rarely
RunOnceTask should be used rarely, such as in tests, truly one-off
cases, or as a last resort for sync-async interop. pw_async2 should not
be used as a work queue. Overuse of RunOnceTask forfeits the benefits of
pw_async2, scattering logic across a mess of callbacks instead of
organizing it linearly in a task.
CallbackTask: Bridge between sync and async#
CallbackTask invokes a callback after a future is ready. This can be used to bridge between async and non-async code. and See Callbacks for details.
CoroOrElseTask: Execute a coroutine#
CoroOrElseTask delegates to a
provided coroutine and executes an or_else handler function on failure.
See Coroutines for information about coroutines.
Callbacks#
In a system gradually or partially adopting pw_async2, there are often
cases where existing code needs to run asynchronous operations built with
pw_async2. To facilitate this, pw_async2 provides
CallbackTask. This task invokes a future, forwarding its result to a provided callback on
completion.
Example#
#include "pw_async2/callback_task.h"
#include "pw_log/log.h"
#include "pw_result/result.h"
// Assume the async2 part of the system exposes a function to post tasks to
// its dispatcher.
void PostTaskToDispatcher(pw::async2::Task& task);
// The async2 function we'd like to call.
ValueFuture<pw::Result<int>> ReadValue();
// Non-async2 code.
void ReadAndPrintAsyncValue() {
pw::async2::CallbackTask task(
[](pw::Result<int> result) {
if (result.ok()) {
PW_LOG_INFO("Read value: %d", result.value());
} else {
PW_LOG_ERROR("Failed to read value: %s", result.status().str());
}
},
ReadValue());
PostTaskToDispatcher(task);
// In this example, the code allocates the task on the stack, so we would
// need to wait for it to complete before it goes out of scope. In a real
// application, the task may be a member of a long-lived object, or you
// might choose to statically allocate it.
}
Considerations for callback-based integration#
While the CallbackTask helper is convenient, each instance of it is a
distinct Task in the system which will compete for execution with other
tasks running on the dispatcher.
If an asynchronous part of the system needs to expose a robust, primary API
based on callbacks to non-pw_async2 code, a more integrated solution is
recommended. Instead of using standalone CallbackTask objects, the Task
that manages the operation should natively support registering and managing a
list of callbacks. This provides a clearer and more efficient interface for
external consumers.
Memory#
The memory for a Task object itself is managed by the user. This provides
flexibility in how tasks are allocated and stored. Common patterns include:
Static or Member Storage: For tasks that live for the duration of the application or are part of a long-lived object, they can be allocated statically or as class members. This is the most common and memory-safe approach. The user must ensure the
Taskobject is not destroyed while it is still registered with aDispatcher. Calling Task::Deregister() before destruction guarantees safety.Dynamic Allocation: For tasks with a dynamic lifetime,
pw_async2provides the AllocateTask() helper. See Dynamically allocating tasks.
Dynamically allocating tasks#
AllocateTask() creates a concrete subclass of
Task, but the created task is dynamically allocated using a provided
pw::Allocator. Upon completion the associated memory is automatically
freed by calling the allocator’s Delete method. This simplifies memory
management for “fire-and-forget” tasks.
// This task will be deallocated from the provided allocator when it's done.
Task* task = AllocateTask<MyFunction>(my_allocator, arg1, arg2);
dispatcher.Post(*task);
Implementing tasks#
The following sections provide more guidance about subclassing Task
yourself.
Pend() and DoPend(), the core interfaces#
A dispatcher drives a task forward by
invoking the task’s Pend() method. Pend() is
a non-virtual wrapper around the task’s DoPend() method. DoPend() is where the core logic of a
task should be implemented.
Communicating completion state#
When a task is incomplete but can’t make any more progress, its DoPend()
method should return Pending. The dispatcher will
sleep the task.
When a task has completed all work, it should return Ready.
Note
How does a task wake back up? Tasks are not directly involved in this process. The task invokes one or more asynchronous operations that return futures, which are values that may not be complete yet. When invoking the async operation, the task provides its context. The future grabs a waker from the context. When the future’s value is ready, the asynchronous operation invokes the waker to inform the dispatcher that the task can make more progress. See Futures: The basic async primitive and Wakers: Progress updates for the dispatcher for further explanation.
Cleaning up complete tasks#
The behavior of a task after returning Ready is implementation-specific.
For a one-shot operation, it may be an error to poll it again. For a
stream-like operation (e.g. reading from a channel), polling again after a
Ready result is the way to receive the next value. This behavior should be
clearly documented.
Passing data between tasks#
See Channels.
Debugging#
You can inspect tasks registered to a dispatcher by calling :Dispatcher::LogRegisteredTasks(), which logs information for each task in the dispatcher’s pending and sleeping queues.
Sleeping tasks will log information about their assigned wakers, with the wait reason provided for each.
If space is a concern, you can set the module configuration option
PW_ASYNC2_DEBUG_WAIT_REASON to 0 to disable wait reason storage
and logging. Under this configuration, the dispatcher only logs the waker count
of a sleeping task.