AIStatefulTask ‐ Asynchronous, Stateful Task Scheduler library.

Threads-like task objects evolving through user-defined states.

Writing a task

A typical Task will look like,

class MyTask final : public AIStatefulTask // Use final unless other tasks are allowed
{ // to derive from this task.
protected:
using direct_base_type = AIStatefulTask; // The immediate base class of this task.
// The different states of the task.
enum my_task_state_type {
MyTask_start = direct_base_type::state_end, // The first state.
...a list of all states...
MyTask_done // The last state.
};
public:
static state_type constexpr state_end = MyTask_done + 1; // The last state plus one.
// In debug mode, passing `true` to the constructor of a task causes
// debug output to dc::statefultask regarding running the state machine.
MyTask(CWDEBUG_ONLY(bool debug = false)) : AIStatefulTask(CWDEBUG_ONLY(debug))
{
DoutEntering(dc::statefultask(mSMDebug), "MyTask() [" << (void*)this << "]");
}
...
protected:
~MyTask() override;
char const* task_name_impl() const override { return "MyTask"; }
char const* state_str_impl(state_type run_state) const override;
void multiplex_impl(state_type run_state) override;
// Optional:
void initialize_impl() override; // Default starts with first state.
void abort_impl() override; // Default does nothing.
void finish_impl() override; // Default does nothing.
};
char const* MyTask::state_str_impl(state_type run_state) const
{
// Use the following two lines when this class is not final.
// If this fails then a derived class forgot to add an AI_CASE_RETURN for this state.
ASSERT(run_state < state_end);
switch (run_state)
{
// A complete listing of my_task_state_type.
AI_CASE_RETURN(MyTask_start);
...a list of all states...
AI_CASE_RETURN(MyTask_done);
}
AI_NEVER_REACHED // Use this when this task is derived from AIStatefulTask.
return direct_base_type::state_str_impl(run_state); // Use this instead when it is derived from a task that it extents.
}
void MyTask::multiplex_impl(state_type run_state)
{
switch (run_state)
{
// A complete listing of my_task_state_type.
case MyTask_start:
// Handle state.
break;
...a list of all states...
case MyTask_done:
finish();
break;
}
}
Declaration of base class AIStatefulTask.
Definition: AIStatefulTask.h:96
virtual char const * task_name_impl() const =0
This can be used to get a human readable name of the most-derived class. It must be guaranteed to alw...
AIStatefulTask(bool debug)
Definition: AIStatefulTask.h:352
virtual void multiplex_impl(state_type run_state)=0
Called for base state bs_multiplex.
virtual void abort_impl()
Called for base state bs_abort.
Definition: AIStatefulTask.cxx:1287
virtual void finish_impl()
Called for base state bs_finish.
Definition: AIStatefulTask.cxx:1292
virtual char const * state_str_impl(state_type run_state) const
Called to stringify a run state for debugging output. Must be overridden.
Definition: AIStatefulTask.cxx:1273
virtual void initialize_impl()
Called for base state bs_initialize.
Definition: AIStatefulTask.cxx:1280

Then in multiplex_impl each state need to be implemented. Here are a few examples.

Changing state

It is simply the last call to set_state that is used to determine what state to run the next invocation of multiplex_impl. Also, as might be intuitively correct, it is not really necessary to return from multiplex_impl to change state; you are allowed to simply fall-through to the next state (even without calling set_state).

case MyTask_state10:
set_state(MyTask_state11); // By default run state11 next.
if (something)
{
set_state(MyTask_state12); // Continue with state12.
break;
}
if (foobar)
{
// Optional code here.
break; // Continue with state11.
}
// Optional code here.
[[fallthrough]]; // Continue with state11 without even
// returning from multiplex_impl.
case MyTask_state11:
...

Yielding

If in the above code you'd have used a break instead of falling through, then the program would have almost acted in the same way: upon return from multiplex_impl the engine sees that the task is still running and will therefore immediately reenter multiplex_impl.

In other words, doing a break is not the same as a yield.

Even if a task runs in an engine with a max_duration, and it would go over that time limit then doing a break still doesn't do anything but immediately reentering multiplex_impl to continue with the next state. The test that looks if the engine did run for too long only is done once we actually return to the mainloop() of the engine which only happens when either wait or yield is called.

Hence, if you want this time check to take place, or if you simply want other tasks in the same engine to get a chance to run too while this task is working, call yield*(). For example,

case MyTask_state10:
set_state(MyTask_state11); // By default run state11 next.
if (something)
{
set_state(MyTask_state12); // Continue with state12.
break;
}
if (foobar)
do_computation_A();
else
do_computation_B();
yield(); // Make sure other tasks and/or the
// mainloop get CPU time too.
break; // Continue with state11.

Waiting

Finally there are a couple of typical ways to go idle while waiting for some event to happen. Under the hood all of those use the same mechanism: you call wait(condition) and the task goes idle until something else calls task.signal(condition).

Here condition is simply a uint32_t bit mask. Normally you will just use 1. In order not to wake up when some old signal happens for a condition that you are no longer waiting for, each task has up to 32 different possible values. If you were waiting for mask 1 and it didn't come or might still be coming (again) but now you want to wait for something else, then simply wait for condition 2, 4 or 8 etc so that you will automatically ignore an (old and lagging behind) signal on 1. It is possible to wait and/or signal multiple conditions at the same time however: a call to wait(condition1) is woken up by a call to signal(condition2) when condition1 & condition2 is non-zero.

For example,

void some_event()
{
task.signal(1);
}
...
case MyTask_state20:
set_state(MyTask_state21); // Continue with state21.
wait(1); // Go idle until some_event() is called.
break;

Often you want to wait for a real condition however, for example x > y, and it is not really possible to call signal when that happens and then still be sure that this condition is still true once the task starts running again.

In general, you will only have events that when they happen make it possible, preferably likely that the condition that you are waiting for is true.

Code that needs this will typically look like this:

void some_event()
{
++x; // Now x might have become larger than y.
task.signal(1);
}
...
case MyTask_state20:
wait_until([&](){ return x > y;}, 1, MyTask_state21);
// Continue with state21 as soon as x > y.
break;

Simply running another task and waiting until it is finished:

case MyTask_state30:
m_task = new MyChildTask;
m_task->run(handler, this, 2); // 2 is just the condition bit to be used.
set_state(MyTask_state31); // Continue with state31...
wait(2); // ...once m_task has finished.
break;

Running some computational extensive function in another thread (see AIPackagedTask):

class MyTask : AIStatefulTask
{
AIPackagedTask<int(double, double)> m_calculate;
static condition_type constexpr calculate_condition = 4;
// The condition bit to be used.
public:
MyTask(int calculate_handle) :
m_calculate(this, calculate_condition, &func, calculate_handle) { }
// m_calculate will call func(double, double).
...
};
...
case MyTask_state20:
m_calculate(3.0, 4.1); // Copy parameters to m_calculate.
set_state(MyTask_dispatch);
case MyTask_dispatch:
if (!m_calculate.dispatch()) // Put m_calculate in the queue.
{
yield_frames(1); // Yield because the queue was full.
break;
}
set_state(MyTask_state21); // Continue with state21 once the
break; // function finished executing.
case MyTask_state21:
{
int result = m_calculate.get(); // Get the result.

Note that upon a successful queue by dispatch, wait_until was already called on the current task; no additional call to wait is necessary here.

AIPackagedTask also has a constructor that allows using a member function of some object to be used as callback.