The next task is Tasks

The next task is Tasks

April 21, 2024
Musings

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!