Reduce excessive task switching

Task switching is important. But don't overdue it.

Task switching is what is all about. However, too much of it may spoil the stew for the following reasons:

  1. It adds RTOS overhead
  2. It increases power drain
  3. It reduces responsiveness, especially of low priority tasks
  4. It adds complexity to debugging
  5. It can cause bugs
  6. It can crimp future expansion

Because a good RTOS achieves about 50,000 task switches per second on a low-end, 50 MHz Cortex-M3, the first problem isn't likely to be significant for most systems. However, for battery-powered devices, even small efficiency improvements are important. Reduced responsiveness of low-priority human interface tasks may become noticeable to device users. Needless task switching obfuscates the already complex debugging process and is thus undesirable, if for no other reason.

The fifth reason is particularly important. If you don't realize that a task switch could occur, then things may not always happen in the expected order. This could result in a sporadic bug that's difficult to find. Finally, idle time may become dear as new features are added, especially if processor performance were marginal to begin with. There are ample good reasons to reduce excessive task switching. Let's look at a few.

Priority boost

One popular approach is to automatically raise a task's priority when it's selected to run. This is shown in the following example:


void sys_init(void) {
    TCB_PTR t2a;      // task a of priority 2

    t2a = smx_TaskCreate(t2a_main, PRI_2, 500, SMX_FL_NONE, "t2a");
    smx_TaskHook(t2a, t2a_entry, t2a_exit);
    smx_TaskStart(t2a);
}

void t2a_entry(void) {
    smx_TaskBump(self, 3);
}
void t2a_exit(void) {
    smx_TaskBump(self, 2);
}

void t2a_main(void) {
    while (1) {
       // perform t2a function at priority 3
    }
}

In this example, t2a_enter() is transparently called by the scheduler, before t2a is resumed and t2a_exit() is similarly called after t2a is suspended. t2a_enter() moves t2a to the front of ready queue level 3 and boosts its priority to 3. t2a_exit() moves t2a to the end of ready queue level 2 and reduces its priority to 2. Thus, t2a can't be preempted by a priority 3 task while it's running. But a task with greater priority can preempt t2a. In that case, t2a will drop back to priority 2, thus allowing any waiting priority 3 tasks to run next.

This approach is appealing because once t2a starts running, it should be allowed to finish. However, you might wonder why t2a isn't made a priority 3 task to begin with. The advantage of priority boosting is that all priority 3 tasks will run before t2a is allowed to start. Then it becomes one of them. This makes sense in three situations:

  • t2a is a short task
  • t2a is a heavy switcher
  • t2a is a power user

If t2a is a long task, then boosting it to priority 3 would not be a good idea because it would impact the responsiveness of bonafide priority 3 tasks. So, t2a should be short.

An example of a heavy switcher is a floating-point task, which must save and restore an extra 32 registers verses a fixed-point task. Another example is tasks executing from slow memory via cache. Then, heavy switching manifests itself as cache misses and reloads due to the new task using different code and variables. Even in a cache environment, boosted tasks should be short.

In the event that t2a runs at a higher power level than other tasks (e.g., it turns on a peripheral while it runs) it clearly is beneficial to minimize its run time, assuming power savings is important. This is a good use for priority boosting.

Apart from priority inversion, another downside to priority boosting is that it adds to the switching overhead of the tasks using it. For the above example, task switches drop from 50,000 to 26,000 per second, which isn't a problem.

Task locking

Task locking is another means to prevent unnecessary task switches. It's illustrated by the following example:


TCB_PTR t2a, t3a;      // tasks
SCB_PTR sa;	         // semaphore
void t3a-main(void) {
    smx_SemTest(sa);
    // do something
}

void t2a-main(void) {
    smx_SemSignal(sa);
}

This example does nothing useful other than to illustrate a hidden task switch. Since t3a has higher priority, it runs first, then waits on semaphore, sa. Then t2a runs and signals sa. Rather than stopping, as might be expected, t2a is immediately preempted by t3a, which does something, then stops. Then, t2a runs again and does nothing but stop. You can now see that this is a wasted task switch. It can and should be prevented as follows:


void t2a-main(void) {
    smx_TaskLock();
    smx_SemSignal(sa);
}

Now, t3a can't preempt until t2a stops and releases its lock. Note that this is actually scheduler locking, but I prefer to call it task locking because that's more descriptive of what it does.

Fewer priorities

If you find your system doing too much task switching, it may be best to simply reduce the number of priority levels. To do this, you obviously need an RTOS that permits tasks to share priority levels. Assuming you have such an RTOS, the next step is to rethink relative task priorities.

When a task is kept waiting, its urgency increases. Reducing priority levels means that tasks, which might have preempted it, thus keeping it waiting even longer, will now run after it. Less priority levels may actually achieve smoother operation, as well as reducing unnecessary task switches.

It's a common impulse to try to over-control activities rather than letting them happen naturally. Less forceful control is likely to be more adaptable to unforeseen circumstances and thus may produce a more rugged solution. It's worth a try.

Round-robin scheduling

In the process of reducing priority levels, it may be helpful to introduce round-robin scheduling of tasks at the same priority level. This can be done as follows:


void t2a_main(void) {
    while (smx_TestSem(sa)) {
        // do function a
        smx_TaskBump(self, NO_PRI_CHG);
    }
}

void t2b_main(void) {
    while (smx_TestSem(sb)) {
        // do function b
        smx_TaskBump(self, NO_PRI_CHG);
    }
}

When each task finishes some work, it bumps itself to the end of ready queue level 2 so the other task can run, if it has work. If neither task has work, both will be suspended on their respective semaphores and lower priority tasks can run. Of course, higher-priority tasks can preempt t2a and t2b, at any time. In this example, both tasks may spend most of their lives waiting for work. But when their workloads pick up, they will alternate doing work, thus giving fair attention to all clients.

Non-preemptible tasks

Often one-shot tasks (which don't have internal loops like normal tasks) are very short and it makes sense to make them non-preemptible, as follows:


TCB_PTR t2a;

t2a = smx_TaskCreate(t2a_main, PRI_2, 0, SMX_FL_LOCK, "t2a");
smx_TaskStart(t2a);

void t2a_main(void) {
    // do something simple and stop
}

Because t2a is created with its start-locked flag set, it will be locked when it starts running and thus be non-preemptible until it stops or unlocks itself. Tasks of this sort are good for doing things like changing critical control structures, so you don't not want them to be preempted anyway. If such a task were to wait on something, it would lose its lock and thus become preemptible.

Priority boosting is useful in a number of cases to improve system performance. However, simpler methods such as task locking, reducing priority levels, round-robin scheduling, and using non-preemptible tasks are also effective in certain cases.

More information on this design methodology is available in the smx User's Guide.

Ralph Moore, President and Founder of Micro Digital, graduated with a degree in Physics from Caltech. He spent his early career in computer research, then moved into mainframe design and consulting.

Topics covered in this article