The next task is Tasks
April 21, 2024
As mentioned in my previous Threads vs Async I/O musings, threads are no longer the main concurrency mechanism I’m after, tasks are. Threads are still present in JeeH (and they actually work), but I’m not so keen on having to allocate stacks for each thread, nor on deciding up front how large they need to be.
So what is a task? #
Tasks are a bit different. I’m using the same sys::send
and sys::recv
mechanism for them as threads and device drivers, but they run as part of the
thread which created them. Sending a message to a task behaves as if the
message is sent to its owning thread and then forwarded to that specific task.
In C++ terms, a task is implemented as a class (or struct) derived from Task
,
with a virtual void process (Message& m)
method.
The idea is that tasks run to completion, i.e. sys::call
to a task is similar
to a function call. If this call comes from the owning thread, it is in fact
essentially a function call. No stack / context switching is involved. If the
call comes from another thread, then yes: there will be a context switch to the
owning thread.
A task can be activated from any thread (using sys::send
or sys::call
).
A task may block (via sys::call
), in which case it blocks its owner thread
as well. But a task is not allowed to block via sys::recv
, because it would
not know what to do with an incoming message meant for some other task or
thread.
This means that tasks always “run to completion”: they may block during a
sys::call
, but they will always resume once that specific reply comes back.
One way to look at tasks, is that they act like service threads with a top-level
“event loop” doing a sys::recv
to wait for the next action:
while (true) {
auto& m = sys::recv();
process(m);
}
In threads, the above loop needs to be included in its code. In tasks, the loop
is implied, with the task only supplying an object with that process
method.
The key point here is that a task’s process
can only use sys::send
and
sys::call
, never sys::recv
.
Task are “poor man’s coroutines”: the state between calls to process
can be
kept in the task object. The process()
method will probably often be set up as
a dispatcher for a Finite State Machine.
Why all the fuss? #
So what’s the point? Isn’t this simply trading multiple stack contexts for a
mechanism which is much harder to apply in day-to-day use? Yes, it is. Tasks are
more tedious to implement than threads. While writing a network protocol stack,
I noticed that all its logic is event driven: a new packet is received, a packet
has been sent and its buffer has become available again, or some timer has just
expired. In JeeH, these events are all managed via messages, and the code acts
when receiving these messages in sys::recv
.
As it turns out, the entire network stack consists of “run-to-completion” code.
Each TCP “session” is in fact an instance of such a … Task
. Keeping each
session state in a thread would not scale, particularly on a µC. By treating
these as tasks / coroutines / async calls / you-name-it, they can all run in
sequence as needed, while borrowing the owner threads’s stack during the
process
call.
As expected, adapting the network stack implementation to run as a task was a piece of cake.
Why allow sys::call
inside a task?
#
This came as an afterthought, and was the reason I decided to completely
overhaul JeeH v5 and work on a new v6 version. The practical reason is that I
want to be able to do a printf
inside a task. For debugging purposes, but
later also for things like dumping data over a serial port (or a network link).
For this to work, given that communication links will throttle such output
streams at some point, means that I need a blocking sys::call
way of sending
data. Note that the special property of sys::call
is that it sends a message
out and then waits for its reply, during which time every other incoming
message gets queued.
With the new design, this is no longer an issue: if a task blocks, it does so on
behalf of its owner thread. As soon as the reply comes back, the task resumes.
Any other messages for that thread (including other tasks it owns) are saved up
in some queue, and processed when the thread ends up calling sys::recv
.
There is a complication … #
The tricky bit is that while a task is blocked, it cannot be called afresh. A
message sent to the task while blocked must be queued if it is not the
unblocking one. And the same holds for the owner thread: it cannot proceed as
long as it is inside a task’s process
method.
Threads will block when a task blocks on their behalf.
Tasks can sys::call
anything: other threads, other tasks, and device drivers.
But tasks can only run to completion. If a task calls a task which is “busy”
(i.e. in process
), this will generate an error.
Tasks vs device drivers #
In a way, tasks and device drivers are very similar: you activate them by sending them a message, and they can then at some point in time send a reply. The difference is that tasks are synchronous in behaviour: while the task runs, no other messages (events?) can be processed. Whereas device drivers are asynchronous: they can initiate a hardware activity, which then ends up generating an interrupt request to signal their completion. That interrupt leads tp a reply, but with one major difference: if the waiting thread has a higher priority than the current thread, it will pre-emptively suspend the current thread and resume.
Tasks can’t - by themselves - lead to interruptions and context switches. But calls to device drivers can!
Just to be clear: tasks can lead to context switches (e.g. a call to
sys::wait
inside the task, which is implemented as a sys::call
to the
SysTick
device driver).
This difference may turn out to be very useful later on: an IRQ-based UART interface has to be a device driver in JeeH, whereas a polled UART interface can be implemented as a task. For serial output, this will make it very easy to switch between either aproach, depending on the application’s needs.
A thread is also a task #
With the new design, the core datastructure is the Task
. Threads are
internally implemented as an elaborate version of a task (with their own stack).
Threads are optional, in the sense that thread support is not required in all
applications: sys::init
only needs to be called if sys::fork
is used to
create threads (although, confusingly, main
is also a thread).
Device drivers also do not depend on thread support. It is perfectly fine for an application to only use tasks and device drivers, all running on a single stack.
Current status #
As of this writing, all the mechanisms described above are in place.
The main open issue remaining is the nested use and blocking of tasks. Right
now, tasks will block their owner threads as intended, but the unblocking
message can end up in the wrong place, causing the task to run again instead
of resuming (see test t21
). Nested task calls will need more testing anyway
…
The test setup has been simplified, with the same tests passing on a few Cortex
M3, M4, and M7 boards from STM (see builds/
). These tests are loaded into RAM
instead of flash memory, as this is faster and avoids wearing out the flash
memory cells.
There is a Fixed
object which can be used to prevent the context switcher from
pre-emptively suspending a task when a higher-priority task could be resumed
due to a device driver reply. This has been used to implement a Lock
mechanism, with which threads (not tasks) can wait in line to restrict access
to a resource, i.e. a semaphore / mutex (there’s no “inversion of control”
in JeeH’s current priority scheme).
The DMA-based UART driver works on all device families so far, and so does the
EXTI pin interrupt handler. The RTC interface needs to be turned into a driver,
so that its interrupt can be used for waking from (deep) sleep modes. The
DMA-based low-level Ethernet driver works, but the network layer on top (now
turned into a Net
task) is still minimal.
GPIO is fine, but several parts of v5 have not yet made it into v6, such as the SPI and I2C interfaces.
And there’s no documentation. Only these ramblings musings …
Onwards!