Skip to content

Multi-tasking under Zephyr OS

Introduction

Multi-tasking Programs

In this codelab, we will learn basic principle for creating multi-tasking application using Zephyr RTOS, namely:

  • How to define threads for specific tasks of a program.
  • How to synchronize multiple threads and control access to shared resources.
  • Study the influence of shared resources on task scheduling.

Ultimately, we will transform the BikeComputer program into a multi-tasking concurrent application.

What you’ll build

In this codelab, you’re going to program simple programs using various multi-tasking primitives. Programs will be presented as exercices that run independently from each other.

What you’ll learn

  • How to use threads for performing different tasks in a program.
  • How to share resources among different threads.
  • How to synchronize tasks based on resources or events, including interrupt-based events.
  • What the influence of shared resources can have on task scheduling.

What you’ll need

Threads in Zephyr RTOS

Zephyr RTOS runs on single-microcontroller embedded systems. In this context, a thread is an independent segment of a program that executes within the single process running on the microcontroller. Threading enables multiple tasks to run concurrently using a scheduler. It is important to point out that, while threads provide developers with flexibility, they require resources for themselves and the scheduler.

Your application (main function) starts execution in the main thread, but it’s not the only thread running in Zephyr RTOS. When starting an Zephyr RTOS application, there are other threads running system services:

  • Main: The default thread that executes the application’s main function. The main thread has 1KiB of stack space by default. The application can configure it in “prj.conf” by defining the MAIN_STACK_SIZE parameter, as follows:
multi_tasking/prj.conf
CONFIG_MAIN_STACK_SIZE=2046 // stack size in bytes
  • Idle: The scheduler runs this thread when there is no other activity in the system (for example, when all other threads are waiting for an event). This ensures that the program does not burn empty processor cycles and is put to sleep for as long as possible.

  • Logging: The thread that’s run to log all information. It is is created only when CONFIG_LOG is enabled.

Using the Utils Helper Class for Logging Thread Info

The Utils class is available in the zpp_lib repository. You may use this class in your BikeSystem implementation to log statistics related to each thread running in your application.

If you add a call to the zpp_lib::Utils::logThreadsSummary() method at the start of the BikeComputer::start() method, you may observe the three different threads, as shown below:

Thread statistics logging
00:00:00.252,166] <inf> zpp_rtos: ---+----------------+------------+-----------+------+-------------------
[00:00:00.252,502] <inf> zpp_rtos:  1 |     0x20000170 | logging    | queued    |   14 |   48 / 768
[00:00:00.252,593] <inf> zpp_rtos:  2 |     0x20000848 | idle       | ready     |   15 |   48 / 320
[00:00:00.253,082] <inf> zpp_rtos:  3 |     0x20000918 | main       | queued    |   11 |  704 / 4096
[00:00:00.253,082] <inf> zpp_rtos: ---+----------------+------------+-----------+------+-------------------

Zephyr RTOS Scheduling Principles

Under Zephyr RTOS, threads are either cooperative or preemptible threads:

  • Cooperative threads run until they either complete their task or perform an action that renders them unready. Under Zephyr RTOS, these threads have a negative priority value.
  • Preemptible threads may become the running thread and may be preempted by a cooperative thread or another preemptible thread with a higher or equal priority. Under Zephyr RTOS, these threads have a non-negative priority value.

Scheduling Based on Priority

For the purposes of this lecture, we will only use preemptible threads. With these threads, the scheduler selects the highest-priority ready thread to be the current running thread. When there are multiple threads with the same priority that are ready, the scheduler chooses the one that has been waiting the longest. Thread priorities are established at creation time and they may be modified using the zpp_lib::Thread::setPriority() method.

With Zephyr RTOS, the queue of ready threads can be implemented using different mechanisms. The choice depends on various criteria. However, given the limited number of threads we will use, the simplest mechanism, CONFIG_SCHED_SIMPLE, is appropriate.

Preemptive Time Slicing

Once a preemptive thread becomes the current thread, it remains so until a thread with a higher priority becomes ready or the current thread performs an action that renders it unready. Consequently, if a preemptive thread performs lengthy computations, it may cause unacceptable delays in scheduling other threads, including those with the same priority. This is the reason why most schedulers, including the Zephyr RTOS scheduler, implement round-robin or time slicing. This allows threads with the same priority to run concurrently.

In time slicing, the scheduler divides time into time slices. At the end of each time slice, the scheduler checks if there are other threads with the same priority as the current one. If so, it preempts the current preemptible thread and dispatches another one.

Under Zephyr RTOS, time slicing can be configured as follows:

  • CONFIG_TIMESLICING enables or disables time slicing among preemptible threads of equal priority. Time slicing is enabled by default.

  • CONFIG_TIMESLICE_SIZE specifies the amount of time (in milliseconds) that a thread can execute before being preempted by a thread of equal priority. The default value is 20 milliseconds.

  • CONFIG_TIMESLICE_PRIORITY specifies the thread priority level at which time slicing takes effect. Time slicing is not applied on threads with a higher priority than this ceiling. The default ceiling value is 0.

Priorities Values

It is important to recall that with Zephyr RTOS, higher priority means a smaller priority value. This is made explicit in zpp_lib, where priorities are defined as enum class PreemptableThreadPriority.

Main Thread Default Priority

The main thread is created by the system with a default priority that can be configured using CONFIG_MAIN_THREAD_PRIORITY. The default value is 0, meaning that by default the main thread has the highest priority among preemptible threads. Using zpp_lib, the default main thread priority must be configured because zpp_lib::PreemptableThreadPriority::PriorityRealtime has a value that is higher than 0 in any case. The default value should be set to zpp_lib::PreemptableThreadPriority::PriorityNormal, which means a value of CONFIG_NUM_PREEMPT_PRIORITIES - 4 (by default 11).

Using zpp_lib::Events For Waiting For an Event

It is very common in a multi-tasking environment to wait for a specific event to happen before performing a given task. An obvious example is waiting for a user to press for a button. For this purpose, Zephyr RTOS provides the Events API, that is encapsulated in the zpp_lib::Events class.

For demonstrating the use of this API, we create a WaitOnButton class defined as follows:

wait_on_button.hpp
multi_tasking/src/wait_on_button.hpp
// Copyright 2025 Haute école d'ingénierie et d'architecture de Fribourg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/****************************************************************************
 * @file main.cpp
 * @author Serge Ayer <serge.ayer@hefr.ch>
 *
 * @brief Declaration of the WaitOnButton class
 *
 * @date 2025-07-01
 * @version 1.0.0
 ***************************************************************************/

#pragma once

// stl
#include <chrono>

// zpp_lib
#include "zpp_include/events.hpp"
#include "zpp_include/interrupt_in.hpp"
#include "zpp_include/thread.hpp"

namespace multi_tasking {

class WaitOnButton {
 public:
  explicit WaitOnButton(const char* threadName);

  [[nodiscard]] zpp_lib::ZephyrResult start();
  void wait_started();
  void wait_exit();

 private:
  void waitForButtonEvent();
  void buttonPressed();

  static constexpr uint8_t kPressedEvent = BIT(0);
  static constexpr uint8_t kStartedEvent = BIT(1);

  zpp_lib::Thread _thread;
  std::chrono::microseconds _pressedTime;
  zpp_lib::Events _events;
  zpp_lib::InterruptIn<zpp_lib::PinName::BUTTON1> _pushButton;
};

}  // namespace multi_tasking
wait_on_button.cpp
multi_tasking/src/wait_on_button.cpp
// Copyright 2025 Haute école d'ingénierie et d'architecture de Fribourg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/****************************************************************************
 * @file main.cpp
 * @author Serge Ayer <serge.ayer@hefr.ch>
 *
 * @brief Implementation of the WaitOnButton class
 *
 * @date 2025-07-01
 * @version 1.0.0
 ***************************************************************************/
#include "wait_on_button.hpp"

// zephyr
#include <zephyr/logging/log.h>

// zpp_lib
#include "zpp_include/time.hpp"

LOG_MODULE_REGISTER(wait_on_button, CONFIG_APP_LOG_LEVEL);

namespace multi_tasking {

WaitOnButton::WaitOnButton(const char* threadName)
    : _thread(zpp_lib::PreemptableThreadPriority::PriorityNormal, threadName),
      _pressedTime(std::chrono::microseconds::zero()) {
  _pushButton.fall(std::bind(&WaitOnButton::buttonPressed, this));
  LOG_DBG("WaitOnButton initialized");
}

zpp_lib::ZephyrResult WaitOnButton::start() {
  auto res = _thread.start(std::bind(&WaitOnButton::waitForButtonEvent, this));
  if (!res) {
    LOG_ERR("Failed to start thread: %d", (int)res.error());
    return res;
  }
  LOG_DBG("Thread started successfully");
  return res;
}

void WaitOnButton::wait_started() { _events.wait_any(kStartedEvent); }

void WaitOnButton::wait_exit() {
  auto res = _thread.join();
  if (!res) {
    LOG_ERR("join() failed: %d", (int)res.error());
  }
}

void WaitOnButton::waitForButtonEvent() {
  LOG_DBG("Waiting for button press");
  _events.set(kStartedEvent);

  while (true) {
    _events.wait_any(kPressedEvent);
    std::chrono::microseconds time    = zpp_lib::Time::getUpTime();
    std::chrono::microseconds latency = time - _pressedTime;
    LOG_DBG("Button pressed with response time: %lld usecs", latency.count());
    LOG_DBG("Waiting for button press");
  }
}

void WaitOnButton::buttonPressed() {
  _pressedTime = zpp_lib::Time::getUpTime();
  _events.set(kPressedEvent);
}

}  // namespace multi_tasking

In the WaitOnButton class, we create and start an additional thread in the WaitOnButton::start() method. This thread then executes the WaitOnButton::waitForButtonEvent() method, which waits in an infinite loop for a specific event to occur. This event is triggered by a button press (Button 1) in the WaitOnButton::buttonPressed() method.

To experiment the WaitOnButton and the following multi-tasking examples, create a new “multi_tasking” application and use the code below. Ensure that the application configuration includes all necessary configuration parameters.

main.cpp
multi_tasking/src/main.cpp
// Copyright 2025 Haute école d'ingénierie et d'architecture de Fribourg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/****************************************************************************
 * @file main.cpp
 * @author Serge Ayer <serge.ayer@hefr.ch>
 *
 * @brief Main function of the Multi-Tasking program
 *
 * @date 2025-07-01
 * @version 1.0.0
 ***************************************************************************/

// zephyr
#include <zephyr/kernel.h>
#include <zephyr/logging/log.h>

// zpp_lib
#include "zpp_include/utils.hpp"
#include "zpp_include/this_thread.hpp"

// local
#include "wait_on_button.hpp"

LOG_MODULE_REGISTER(main, CONFIG_APP_LOG_LEVEL);

int main(void) {
  using namespace std::literals;

  LOG_DBG("Multi-tasking program started");
  // log thread statistics
  zpp_lib::Utils::logThreadsSummary();

  // check which button is pressed
  zpp_lib::InterruptIn<zpp_lib::PinName::BUTTON1> button1;
  if (button1.read() == zpp_lib::kPolarityPressed) {
    LOG_DBG("Starting WaitOnButton demo");
    // create the WaitOnButton instance and start it
    multi_tasking::WaitOnButton waitOnButton("ButtonThread");
    auto res = waitOnButton.start();
    if (!res) {
      LOG_ERR("Cannot start waitOnButton: %d", static_cast<int>(res.error()));
      return -1;
    }

    // wait that the WaitOnButton thread started
    LOG_DBG("Calling wait_started()");
    waitOnButton.wait_started();
    LOG_DBG("wait_started() unblocked");

    // log thread statistics
    zpp_lib::Utils::logThreadsSummary();

    // wait for the thread to exit (will not because of infinite loop in WaitOnButton)
    // waitOnButton.wait_exit();
    // or do busy waiting
    while (true) {   
    }
  }

  return 0;
}

In this example, we can observe the following:

  • The use of the WaitOnButton instance in the main function is self-documented. Make sure that you understand it in detail.
  • After starting the WaitOnButton instance, the main thread waits for the thread to exit. This will never happen, so the main thread will wait forever in the waitOnButton.wait_exit() method. Recall that the main thread must never exit the main() function for a Zephyr RTOS program to behave properly.
  • Each time the button is pressed, the kPressedEvent event is set on the _events instance in the WaitOnButton::buttonPressed() method. When this event is set, the “ButtonThread” thread transitions move from the Waiting to the Ready state. The kPressedEvent event is reset on the _events instance and the “ButtonThread” thread starts waiting again. You can observe this behavior by pressing the button multiple times.

To better understand the program, compile and run it. When you start the program, you should see the following in the console:

  • Before the WaitOnButton instance is created and started, the application runs the three “main”, “idle” and “logging” threads, as illustrated above. Note that if the “CONFIG_LOG_IMMEDIATE` option is set, then the “logging” thread is not created.
  • After the WaitOnButton instance is started, the application creates and runs an additional thread named “ButtonThread”.
  • While the program is running, the “ButtonThread” thread will transition from the “Waiting” state to the “Ready” state each time the button is pressed. Then, it will immediately return to the “Waiting” state after printing its message on the console.

Note that it is possible to wait for multiple events at once, as documented in the Events API. This is a very useful mechanism in some situations where a task must be executed when multiple events arise. It is also possible to wait for a specific event as demonstrated in the WaitOnButton class.

Exercice Multi-tasking under Zephyr OS/1

It is also useful to measure the interrupt latency time. The WaitOnButton class implements this mechanism by registering the press time in the ISR method, and by computing the time interval until the waitForButtonEvent thread is signaled. With this mechanism, we can measure the interrupt latency.

You may notice that the interrupt latency varies depending on some multi-tasking behaviors. Test the following scenarios:

  • The main thread is in a Waiting state, waiting for the WaitOnButton thread to terminate using the Thread::join() method. This is the default behavior as documented above. Try to change the priority of the WaitOnButton thread.
  • Replace the call to waitOnButton.wait_exit() in the main function with a busy infinite wait while (true) {}.
  • Using the busy wait, modify the priority of the WaitOnButton thread to PriorityAboveNormal (in the constructor)
  • Using the busy wait, modify the priority of the WaitOnButton thread to PriorityBelowNormal (in the constructor).

Report the interrupt latency times for each scenario and explain the possible reasons for the observed values.

Shared Resources and Mutual Exclusion

Multiple threads can run concurrently on a uniprocessor with interleaved or time shared execution. On multiple processor systems, multiple threads can also run simultaneously. In both cases, resource management like access to shared memory/variables by multiple threads is an issue. A multitasking or multithreaded program must make sure that resources are accessed properly, meaning in the correct sequence as if the program was executed by a single thread. In other words, in some cases, the program must enforce a mutually exclusive access to the shared resource to only one thread at a time. For this purpose, most _RTOS_es provide a so-called mutex mechanism that can be implemented both at the hardware or software level.

We first illustrate this problem with an example that simulates a clock keeping track of the current time. For updating the clock, a ticker with 1s interrupt interval is used. The structure for defining the time and the ISR for updating it are given below:

clock.hpp
clock.hpp
// Copyright 2022 Haute école d'ingénierie et d'architecture de Fribourg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/****************************************************************************
 * @file clock.hpp
 * @author Serge Ayer <serge.ayer@hefr.ch>
 *
 * @brief Clock class used for demonstrating race condition/critical section
 * @date 2022-09-01
 * @version 0.1.0
 ***************************************************************************/

#pragma once

#include "mbed.h"

namespace multi_tasking {

class Clock {
public:
    struct DateTimeType {
        uint32_t day;
        uint32_t hour;
        uint32_t minute;
        uint32_t second;
    };

    Clock() = default;

    void start() {
        // start a ticker thread for dispatching events that are queued in the tickerUpdate() method
        _tickerThread.start(callback(&_tickerQueue, &EventQueue::dispatch_forever));
        // call the tickerUpdate() method every second, for queueing an event to be dispatched by the ticker thread
        _ticker.attach(callback(this, &Clock::tickerUpdate), clockUpdateTimeout);

        // schedule an event every second for displaying the time on the console
        _clockDisplayQueue.call_every(clockDisplayTimeout, 
                                      callback(this, &Clock::getAndPrintDateTime));
        // dispatch events from the thread calling the start() method (main thread)
        _clockDisplayQueue.dispatch_forever();
    }

private:
    void getAndPrintDateTime() {
        DateTimeType dt = {0};

        dt.day = _currentTime.day;
        dt.hour = _currentTime.hour;

        dt.minute = _currentTime.minute;
        dt.second = _currentTime.second;

        printf("Day %u Hour %u min %u sec %u\n", dt.day, dt.hour, dt.minute, dt.second);
    }

    void tickerUpdate() {
        _tickerQueue.call(callback(this, &Clock::updateCurrentTime));
    }

    void updateCurrentTime() {
        _currentTime.second += std::chrono::duration_cast<std::chrono::seconds>(
          clockUpdateTimeout).count();

        if (_currentTime.second > 59) {
            _currentTime.second = 0;
            _currentTime.minute++;
            if (_currentTime.minute > 59) {
                _currentTime.minute = 0;
                _currentTime.hour++;
                if (_currentTime.hour > 23) {
                    _currentTime.hour = 0;
                    _currentTime.day++;
                }
            }
        }
    }

    EventQueue _clockDisplayQueue;
    Ticker _ticker;
    EventQueue _tickerQueue;
    Thread _tickerThread;
    DateTimeType _currentTime{ .day = 0, .hour = 10, .minute = 59, .second = 59};
    static constexpr std::chrono::milliseconds clockUpdateTimeout = 1000ms;
    static constexpr std::chrono::milliseconds clockDisplayTimeout = 1000ms;
};

} // namespace multi_tasking

It is important to point out that the update of the current time (method updateCurrentTime()) is not executed within the ISR but it is rather deferred to the _tickerQueue event queue - so it is executed by the _tickerThread thread. The main reason is that blocking behaviors are forbidden in ISRs - so acquiring a mutex in an ISR is not possible. Acquiring the mutex in the updateCurrentTime() method is not currently done, but we will show below that it is required.

The main program used for demonstrating the use of the Clock class is shown below:

main_clock.cpp
main_clock.cpp
// Copyright 2022 Haute école d'ingénierie et d'architecture de Fribourg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/****************************************************************************
 * @file main.cpp
 * @author Serge Ayer <serge.ayer@hefr.ch>
 *
 * @brief main function for demonstrating Clock
 * @date 2022-09-01
 * @version 0.1.0
 ***************************************************************************/

#include "clock.hpp"
#include "mbed.h"
#include "mbed_trace.h"
#if MBED_CONF_MBED_TRACE_ENABLE
#undef TRACE_GROUP
#define TRACE_GROUP "main"
#endif  // MBED_CONF_MBED_TRACE_ENABLE

int main() {
    // use trace library for console output
    mbed_trace_init();

    tr_debug("Clock update program started");

    // create and start a clock
    multi_tasking::Clock clock;
    clock.start();

    return 0;
}

If you execute the program, you should get the following output:

Clock times

Clock update program started
Day 0 Hour 10 min 59 sec 59
Day 0 Hour 11 min 0 sec 0
Day 0 Hour 11 min 0 sec 1
Day 0 Hour 11 min 0 sec 2
Day 0 Hour 11 min 0 sec 3
Day 0 Hour 11 min 0 sec 5
Day 0 Hour 11 min 0 sec 6
...

So everything looks fine and it seems that the above program will always execute correctly.

There is however a race condition issue in this program: the problem is that an interrupt at the wrong time may lead to half-updated values in the getAndPrintDateTime() method. The problem can be detailed as follows:

  • Say the current time is day: 0, hour: 10, minute: 59, second: 59 or {0, 10, 59, 59}.
  • The first call to getAndPrintDateTime() is executed from the main() function (as an event in the queue). The first two instructions of the function are executed and thus { 0, 10 } are copied to dt.day/hour.
  • A timer interrupt occurs, which updates the current time to {0, 11, 0, 0}.
  • Later, the getAndPrintDateTime() function resumes executing and copies the remaining current_time fields to dt, thus dt.minute/second = {0, 0}.
  • dt contains the values {0, 10, 0, 0} and the program believes that the time just jumped backwards one hour !

Although the failure case described above can happen, it is unlikely to happen. For making it easily happen, it is enough to have our getAndPrintDateTime() method to go to sleep for \(1\,\mathsf{s}\) after the execution of the instruction updating the hour. In this case, the ISR will for sure execute before the execution of getAndPrintDateTime() resumes and the failure will happen. If you modify the getAndPrintDateTime() function as follows:

clock_with_wait.hpp
clock_with_wait.hpp
...
void getAndPrintDateTime() 
{
    DateTimeType dt = {0};

    dt.day = _currentTime.day;
    dt.hour = _currentTime.hour;

    static constexpr std::chrono::microseconds waitTime = 1s;
    wait_us(waitTime.count());

    dt.minute = _currentTime.minute;  
    dt.second = _currentTime.second;

    printf("Day %d Hour %d min %d sec %d\n", dt.day, dt.hour, dt.minute, dt.second);
  }
...

you should observe an output on the console similar to the one below. This shows a jump in the clock time:

Clock times

Clock update program started
Day 0 Hour 10 min 0 sec 0
Day 0 Hour 11 min 0 sec 2
Day 0 Hour 11 min 0 sec 3
Day 0 Hour 11 min 0 sec 4
Day 0 Hour 11 min 0 sec 5
Day 0 Hour 11 min 0 sec 6
Day 0 Hour 11 min 0 sec 7

The output above exhibits a typical race condition problem. Preemption enables the ISR to interrupt the execution of another code and to possibly overwrite data. To resolve this problem, one must ensure atomic or indivisible access to the object, in our case the _currentTime object. In this case, and for uniprocessor systems, one solution would be to disable or turn off interrupts at the beginning of the getAndPrintDateTime() function and turn it on later. This solution solves the problem but it may lead to missing interrupt events - in this particular case, the current displayed time becomes incorrect.

The solution that disables interrupts during the atomic access is :

clock_with_irq_disabled.hpp
clock_with_irq_disabled.hpp
void getAndPrintDateTime() {
    ...

    uint32_t m = __get_PRIMASK();
    __disable_irq();

    dt.day = _currentTime.day;
    dt.hour = _currentTime.hour;
    dt.minute = _currentTime.minute;  
    dt.second = _currentTime.second;

    __set_PRIMASK(m);

    ...
  }

In the code above, since we know that an ISR can write to our shared data object, we disable the interrupt, after saving the current interrupt masking state. We then restore the masking state at the end of the function. The same code needs to be inserted in the updateCurrentTime() method for protecting access to _currentTime. Again, although this solves the problem, disabling interrupts should be avoided since it may affect other processing requests.

Another way of protecting the critical section is with the use of mutual exclusion objects or mutex. The goal of a mutex is to ensure that only one thread is executing in a critical section at the same time. Mutex can thus synchronize the execution of threads and protect access to a shared resource as illustrated below:

Mutex and threads

Mutex and threads

Exercice Multi-tasking under Zephyr OS/2

Remove the code for disabling interrupts and modify the Clock class for implementing a thread safe class using a Mutex instance.

Note that using a Mutex in our example is possible only because the execution of updateCurrentTime() is deferred to the _tickerThread thread.

Indeed, Mutex objects cannot be used from ISR context, since acquiring a mutex is a blocking operation.

Mutex not allowed from ISR context

For experiencing the unallowed use of the Mutex mechanism in an ISR, you may modify the callback used by the ticker as follows:

clock_unallowed_ISR.hpp
...
_ticker.attach(callback(this, &Clock::updateCurrentTime), clockUpdateTimeout);
...

With this change, the updateCurrentTime() method becomes the ISR of the ticker and is thus executed from ISR context. In this case, your program should halt with a message stating that the use of a Mutex is not allowed from ISR context.

Using the ConditionVariable API for waiting or signaling state changes

It is worth noting that Mbed OS provides a mechanism called ConditionVariable for safely waiting for or signaling state changes. When protecting shared resources with a mutex and then releasing that mutex to wait for a change of that data, a condition variable provides a safe solution by handling the wait for a state change, along with releasing and acquiring the mutex automatically during this waiting period. However, unlike Events, ConditionVariable does not let you wait on multiple events at the same time.

Deadlock

Deadlocks can arise in situations where two or more threads wait for each other to complete the task. A deadlock is thus an infinite wait and it occurs when a thread enters a wait state because a shared resource that was requested is being held by another waiting thread, which in turn is waiting for another resource held by another waiting thread.

A number of solutions have been developed for analyzing and preventing deadlock situations. For analyzing situations where deadlocks may arise, Coffman proved that deadlock situation on a resource can arise if and only if all of the following conditions illustrated in the figure below hold simultaneously:

  1. Mutex: exclusive resource, non-shareable (Resource A and B in the figure below)
  2. Resource holding: request additional resources while holding one (Requesting resource B while holding Resource A, Resource B held by another process)
  3. No preemption: resource can not be de-allocated or forcibly removed (Resource B cannot be deallocated)
  4. Circular wait: circular dependency or a closed chain of dependency (A vs B, Process 1 vs Process 2).

Deadlock conditions

Deadlock conditions

Deadlock prevention works by preventing one of the four Coffman conditions from occurring:

  1. Removing the mutual exclusion condition means that no process will have exclusive access to a resource (called non-blocking synchronization algorithms).
  2. The hold and wait or resource holding conditions are changed by making processes ask for all the resources they will need before starting up (difficult to implement).
  3. Allowing the preemption condition will cause difficulty and the processing outcome may be inconsistent or thrashing may occur.
  4. The final condition is the circular wait condition. Approaches that avoid circular waits include disabling interrupts during critical sections.

A very simple example for demonstrating a deadlock condition is shown below:

deadlock.hpp
deadlock.hpp
// Copyright 2022 Haute école d'ingénierie et d'architecture de Fribourg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/****************************************************************************
 * @file deadlock.hpp
 * @author Serge Ayer <serge.ayer@hefr.ch>
 *
 * @brief Deadlock class used for demonstrating deadlock
 * @date 2022-09-01
 * @version 0.1.0
 ***************************************************************************/

#pragma once

#include "mbed.h"

#include "mbed_trace.h"
#if MBED_CONF_MBED_TRACE_ENABLE
#define TRACE_GROUP "Deadlock"
#endif // MBED_CONF_MBED_TRACE_ENABLE

namespace multi_tasking {

class Deadlock {
public:
    Deadlock(int index, const char *threadName) :
        _index(index),
        _thread(osPriorityNormal, OS_STACK_SIZE, nullptr, threadName) {
    }

    void start() {
        osStatus status = _thread.start(callback(this, &Deadlock::execute));
        tr_debug("Thread %s started with status %d", _thread.get_name(), status);
    }

    void wait() {
        _thread.join();
    }

private:
    void execute() {
        // enter the first critical section
        _mutex[_index].lock();
        tr_debug("Thread %d entered critical section %d", _index, _index);

        // perform some operations
        wait_us(kProcessingWaitTime.count());
        tr_debug("Thread %d processing in mutex %d done", _index, _index);

        // enter the second critical section
        int secondIndex = (_index + 1) % kNbrOfMutexes;
        tr_debug("Thread %d trying to enter critical section %d", _index, secondIndex);
        _mutex[secondIndex].lock();
        tr_debug("Thread %d entered critical section %d", _index, secondIndex);

        // perform some operations
        wait_us(kProcessingWaitTime.count());
        tr_debug("Thread %d processing in mutex %d and %d done", 
                 _index, _index, secondIndex);

        // exit the second critical section
        _mutex[secondIndex].unlock();

        // perform some operations
        wait_us(kProcessingWaitTime.count());
        tr_debug("Thread %d processing in mutex %d done", 
                 _index, _index);

        // exit the first critical section
        _mutex[_index].unlock();
    }

    // time that the threads should spend processing (e.g. wait in our case)
    static constexpr std::chrono::microseconds kProcessingWaitTime = 1000000us;
    static constexpr int kNbrOfMutexes = 2;
    int _index;
    Thread _thread;
    // the mutex must be declared as static for being a class instance
    static Mutex _mutex[kNbrOfMutexes];
};

} // namespace multi_tasking
main_deadlock.cpp
multi-tasking/main_deadlock.cpp
// Copyright 2022 Haute école d'ingénierie et d'architecture de Fribourg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/****************************************************************************
 * @file main.cpp
 * @author Serge Ayer <serge.ayer@hefr.ch>
 *
 * @brief main function for demonstrating Deadlock
 * @date 2022-09-01
 * @version 0.1.0
 ***************************************************************************/

#include "deadlock.hpp"
#include "mbed.h"
#include "mbed_trace.h"
#if MBED_CONF_MBED_TRACE_ENABLE
#undef TRACE_GROUP
#define TRACE_GROUP "main"
#endif  // MBED_CONF_MBED_TRACE_ENABLE

// declare static variables
Mutex multi_tasking::Deadlock::_mutex[kNbrOfMutexes];

int main() {
    // use trace library for console output
    mbed_trace_init();

    tr_debug("Deadlock program started");

    // create a first deadlock instance
    multi_tasking::Deadlock deadlock0(0, "Thread0");
    deadlock0.start();

    // create a second deadlock instance
    multi_tasking::Deadlock deadlock1(1, "Thread1");
    deadlock1.start();

    // wait for both threads to terminate (will not because of deadlock)
    deadlock0.wait();
    deadlock1.wait();

    return 0;
}

In this example, we use two Mutex instances for synchronizing the execution of two threads. The program implements the circular wait as described in the conditions and in the figure above. The output of the program is shown below. As you can observe Thread 0 is blocked waiting for mutex 1 (owned by Thread 1) and Thread 1 is blocked waiting for mutex 0 (owned by Thread 0), since no thread can enter the second critical section.

Deadlock program

[DBG ][main]: Deadlock program started
[DBG ][Deadlock]: Thread Thread0 started with status 0
[DBG ][Deadlock]: Thread Thread1 started with status 0
[DBG ][Deadlock]: Thread 0 entered critical section 0
[DBG ][Deadlock]: Thread 1 entered critical section 1
[DBG ][Deadlock]: Thread 0 processing in mutex 0 done
[DBG ][Deadlock]: Thread 0 trying to enter critical section 1
[DBG ][Deadlock]: Thread 1 processing in mutex 1 done
[DBG ][Deadlock]: Thread 1 trying to enter critical section 0

Shared Data and Semaphore

Is a Mutex a Binary Semaphore?

Like a mutex, a semaphore can manage and control access to shared resources. Unlike a mutex, it does not have the concept of an owner (a mutex is owned by the thread that entered the critical section). On the contrary, a semaphore defines the maximum number of threads that are allowed to use a particular resource at the same time. A mutex is a locking mechanism while a semaphore is a signaling mechanism.

It is important to mention that mutex and semaphore are different concepts and that it is incorrect to think of a mutex as a binary semaphore. As explained in Mutexes and Semaphores Demystified, a mutex is meant to be taken and released, always in that order, by each task that uses the shared resource. By contrast, tasks that use semaphores either signal or wait - not both. In such, they are often related to producer-consumer problems. Also, mutex objects cannot be used within an ISR, while semaphores can be used to signal from ISR context to another thread - signaling is not a blocking mechanism. This makes semaphores a powerful mechanism for signaling from ISR context.

Semaphore for Access to Shared Resources

For illustrating the use of semaphores, we demonstrate a typical producer-consumer problem. The problem can be solved using different Mbed OS concurrency primitives and we will also evaluate their advantages and disadvantages of each approach.

  • The problem is stated as follows (with references to methods implemented in the Buffer, Consumer, and Producer classes (given below):
  • The scenario is executed by two separate tasks/threads: the producer thread implemented in the Producer class (_producerThread) and the consumer thread implemented in the Consumer class (_consumerThread).
  • Both threads share a FILO buffer implemented in the Buffer class (_buffer), with a single index for accessing the buffer (_index).
  • One producer will repeatedly produce an integer ranging from 0 to 19 at a random rate (Producer::produce() method). It will then append the produced value to the FILO buffer (Buffer::append() method).
  • One consumer will repeatedly consume an integer from the FILO buffer (Buffer::extract() method) and consume it (Consumer::consume() method).
  • The FILO buffer has no mutual exclusion, under- or overflow protection. This means that the index for accessing it can be negative or exceed the buffer size. It can also be accessed concurrently by the consumer and producer threads. In this example, the size of the buffer was arbitrarily chosen and does not reflect any required value.
  • For simulating computing time required for consuming or producing data, wait_us() calls are added in the Consumer::consume() and Producer::produce() methods. The same applies for simulating the time required for appending and extracting data to/from the buffer (in the Buffer::append() and Buffer::extract() methods).
  • The green led is used for making append to the buffer visible, while the yellow led is used for making extract from the buffer visible. Both leds are on when one or the other event happens.

The basic implementation of the Buffer class is given below:

buffer.hpp
buffer.hpp
// Copyright 2022 Haute école d'ingénierie et d'architecture de Fribourg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/****************************************************************************
 * @file buffer.hpp
 * @author Serge Ayer <serge.ayer@hefr.ch>
 *
 * @brief Buffer class used for demonstrating consumer/producer
 * @date 2022-09-01
 * @version 0.1.0
 ***************************************************************************/

#pragma once

#include "mbed.h"

namespace multi_tasking {

#if defined(TARGET_DISCO_H747I)
#define GREEN_LED LED1
#define BLUE_LED LED4
static constexpr uint8_t kLedOn = 0;
static constexpr uint8_t kLedOff = 1;
#endif

class Buffer {
public:
    Buffer() :
        _producerLed(GREEN_LED),
        _consumerLed(BLUE_LED) {
        // initialize random seed
        srand(time(NULL));

        _producerLed = kLedOff;
        _consumerLed = kLedOff;
    }

    void append(uint32_t datum) {
        _producerLed = kLedOn;
        _buffer[_index] = datum;
        _index++;
        wait_us(computeRandomWaitTime(kApppendWaitTime));
        _producerLed = kLedOff;
    }

    uint32_t extract(void) {
        _consumerLed = kLedOn;
        _index--;
        wait_us(computeRandomWaitTime(kExtractWaitTime));
        int datum = _buffer[_index];
        _consumerLed = kLedOff;
        return datum;
    }

    int computeRandomWaitTime(const std::chrono::microseconds &waitTime) {
        return rand() % waitTime.count() + waitTime.count();
    }

    int count() {
        return _index;
    }

private:
    static constexpr uint8_t kBufferSize = 10;
    static const std::chrono::microseconds kApppendWaitTime;
    static const std::chrono::microseconds kExtractWaitTime;
    DigitalOut _producerLed;
    DigitalOut _consumerLed;
    uint32_t _buffer[kBufferSize] = {0};
    int _index = 0;
};

} // namespace multi_tasking

The code of the producer that uses the Buffer class is:

producer.hpp
producer.hpp
// Copyright 2022 Haute école d'ingénierie et d'architecture de Fribourg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/****************************************************************************
 * @file producer.hpp
 * @author Serge Ayer <serge.ayer@hefr.ch>
 *
 * @brief Producer class used for demonstrating producer/consumer
 * @date 2022-09-01
 * @version 0.1.0
 ***************************************************************************/

#pragma once

#include "mbed_trace.h"

#include "buffer.hpp"

#if MBED_CONF_MBED_TRACE_ENABLE
#undef TRACE_GROUP
#define TRACE_GROUP "Producer"
#endif // MBED_CONF_MBED_TRACE_ENABLE

namespace multi_tasking {

class Producer {
public:
    explicit Producer(Buffer &buffer) :
        _buffer(buffer),
        _producerThread(osPriorityNormal, OS_STACK_SIZE, nullptr, "ProducerThread") {
        // initialize random seed
        srand(time(NULL));
    }

    void start() {
        _producerThread.start(callback(this, &Producer::producerMethod));
    }

    void wait() {
        _producerThread.join();
    }

private:
    int produce(void) {
        wait_us(_buffer.computeRandomWaitTime(kProduceWaitTime));
        // Produce a random number ranging from 0 to kMaxRandomValue - 1
        return rand() % kMaxRandomValue;
    }

    void producerMethod() {
        while (true) {
            int producerDatum = produce();
            _buffer.append(producerDatum);
            tr_debug("Producer datum is %d (index %d)", producerDatum, _buffer.count());
        }
    }

private:
    static const std::chrono::microseconds kProduceWaitTime;
    static constexpr uint8_t kMaxRandomValue = 20;
    Buffer &_buffer;
    Thread _producerThread;
};

} // namespace multi_tasking

The code of the consumer that uses the Buffer class is:

consumer.hpp
consumer.hpp
// Copyright 2022 Haute école d'ingénierie et d'architecture de Fribourg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/****************************************************************************
 * @file consumer.hpp
 * @author Serge Ayer <serge.ayer@hefr.ch>
 *
 * @brief Consumer class used for demonstrating producer/consumer
 * @date 2022-09-01
 * @version 0.1.0
 ***************************************************************************/

#pragma once

#include "mbed_trace.h"

#include "buffer.hpp"

#if MBED_CONF_MBED_TRACE_ENABLE
#undef TRACE_GROUP
#define TRACE_GROUP "Consumer"
#endif // MBED_CONF_MBED_TRACE_ENABLE

namespace multi_tasking {

class Consumer {
public:
    explicit Consumer(Buffer &buffer) :
        _buffer(buffer),
        _consumerThread(osPriorityNormal, OS_STACK_SIZE, nullptr, "ConsumerThread") {
    }

    void start() {
        _consumerThread.start(callback(this, &Consumer::consumerMethod));
    }

    void wait() {
        _consumerThread.join();
    }

private:

    void consume(int datum) {
        // does nothing
        wait_us(_buffer.computeRandomWaitTime(kConsumeWaitTime));
    }

    void consumerMethod(void) {
        while (true) {
            int consumerDatum = _buffer.extract();
            consume(consumerDatum);
            tr_debug("Consumer datum is %d (index %d)", consumerDatum, _buffer.count());
        }
    }

    int computeRandomWaitTime(const std::chrono::microseconds &waitTime) {
        return rand() % waitTime.count() + waitTime.count();
    }

private:
    static const std::chrono::microseconds kConsumeWaitTime;
    Buffer &_buffer;
    Thread _consumerThread;
};

} // namespace multi_tasking

The main program that uses both the Producer and Consumer classes is:

main_producer_consumer.cpp
main_producer_consumer.cpp
// Copyright 2022 Haute école d'ingénierie et d'architecture de Fribourg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/****************************************************************************
 * @file main.cpp
 * @author Serge Ayer <serge.ayer@hefr.ch>
 *
 * @brief main function for demonstrating Producer/Consumer
 * @date 2022-09-01
 * @version 0.1.0
 ***************************************************************************/

#include "buffer.hpp"
#include "consumer.hpp"
#include "mbed.h"
#include "mbed_trace.h"
#include "producer.hpp"
#if MBED_CONF_MBED_TRACE_ENABLE
#undef TRACE_GROUP
#define TRACE_GROUP "main"
#endif  // MBED_CONF_MBED_TRACE_ENABLE

// declare static variables
const std::chrono::microseconds multi_tasking::Buffer::kApppendWaitTime   = 500000us;
const std::chrono::microseconds multi_tasking::Buffer::kExtractWaitTime   = 500000us;
const std::chrono::microseconds multi_tasking::Producer::kProduceWaitTime = 500000us;
const std::chrono::microseconds multi_tasking::Consumer::kConsumeWaitTime = 500000us;

int main() {
    // use trace library for console output
    mbed_trace_init();

    tr_debug("Consumer producer program started");
    multi_tasking::Buffer buffer;
    multi_tasking::Producer producer(buffer);
    multi_tasking::Consumer consumer(buffer);

    producer.start();
    consumer.start();

    // wait for threads to terminate (will not)
    producer.wait();
    consumer.wait();

    return 0;
}

The steps for a full understanding of this program are the following:

  • Start the program with the producer running only (comment the call to consumer.start() in the main function). Make sure to run the program, describe what you observe and give an explanation of your observations. In particular, answer the following questions:

    1. Which led is blinking?
    2. How is the _index value of the Buffer class changing?
    3. What happens if you run the program long enough? (Note: make the time constant small enough for producing/consuming fast enough).
    4. Can any buffer underflow or overflow issue happen and what are the possible consequences?
  • Do the opposite and start the program with the consumer running only. Answer the same questions.

  • Then, start the program with both the producer and consumer running. Answer the same questions again. Experiment with different time values, simulating both cases where production is faster than consumption and where consumption is faster than production, by making kApppendWaitTime or kExtractWaitTime much smaller than the other waiting times.

Exercice Multi-tasking under Zephyr OS/3

Answer all questions for each different scenario above.

From the experiments and observations made above, one should be able to make the following conclusions:

  • The access to the buffer should be protected and one should ensure that only one thread can execute append() or extract() at the same time. This mechanism can be implemented with a mutex as seen in a previous section of this codelab.
  • There should be a mechanism for handling buffer under- and overflow. In other words, the consumer should not be able to consume an element when the buffer is empty and the producer should not be able to produce an element when the buffer is full. This control mechanism should move the threads in a waiting state until the requested operation becomes possible. This is where semaphores enter the game!

The Semaphore`` mechanism allows the producer thread to signal when an element is added to the buffer and the consumer thread to wait for at least one element to be present in the buffer. TheSemaphore` also implements a counting mechanism.

For implementing the changes documented above, proceed with the following steps:

  • Add a Mutex in the Buffer class and make sure that each access to _buffer and _index is protected.

    1. Run the program after implementing this change and observe whether the buffer under- or overflow issues that you encountered in the previous version still arise.
    2. Observe the led for checking that the access to the critical sections is exclusive - this can be observed more easily only when using large constant time values.
  • Implement a mechanism for making sure that there will be at least one element in the buffer before the consumer can consume it. This can be done by using a Semaphore with the initial value corresponding to no element produced. It is important to place the code for signaling and waiting for the semaphore in the correct places:

    1. The producer and consumer must either signal or wait but not both.
    2. The semaphore calls must be placed correctly with the respect to the mutex lock/unlock calls.
  • Modify the waiting times again and verify whether the buffer underflow and overflow issues have been solved.

  • You should have observed that the underflow issue is solved - no consumption possible when the buffer is empty. For solving the overflow issue, we should make sure that no element can be produced when the buffer is full. This can also be implemented with another Semaphore`` initialized at the buffer capacity and with reversed roles - the producer waits for the semaphore while the consumer signals it. Add another semaphore in theBuffer` class and make sure that both under- and overflow problems are resolved.

Exercice Multi-tasking under Zephyr OS/4

Implement a fully functional Buffer class, based on the instructions above.

Exercice Multi-tasking under Zephyr OS/5

Once you have implemented a fully functional Buffer class, with the correct use of semaphores, consider removing the call to Semaphore::release() or Semaphore::acquire(). Explain what would happen in each case.

Built-in mechanism: Queue and Mail

At this point, it is worth mentioning that RTOS usually provide other built-in mechanisms for consumer-producer related problems. With Mbed OS, these mechanisms are the Queue and the Mail mechanisms. In the next step, we will implement the solution to the consumer-producer problem with the Queue mechanism.

As a side note, it is interesting to mention that all changes required for making the implementation correct were made solely in the Buffer class. This shows that encapsulation of a functionality with a well designed API facilitates the development, debugging and maintenance of a well designed program.

Shared Data and Queue

As stated before, RTOS usually provide higher level abstractions for implementing buffers and producer/consumer scenarios. Here, we implement the same Buffer class as in the previous step - it provides the same API. The class must use a Queue data field rather than all buffer, mutex and semaphore related fields. The size of the Queue must be the same as in the previous step.

The declaration of the Queue data field must thus be

...
  Queue<int, kBufferSize> _producerConsumerQueue;
...

Exercice Multi-tasking under Zephyr OS/6

Once you replace the mentioned fields with the definition shown above, you need to modify the append(), extract() and count() methods accordingly (e.g. using try_put_for(), try_get_for() and count() methods from the Queue class). Make the changes, compile and run the program. By modifying the waiting times appropriately, you should notice that the consumer never consumes an element when the queue is empty and that no element is produced beyond the queue capacity.

The Mail mechanism is very similar to the Queue mechanism, with the added functionality of being able to allocate blocks of memory for exchanging data between a consumer and a producer. The Queue mechanism provided in Mbed OS allows to exchange only integer or pointer values, which is sufficient in our example. The Mail mechanism is not demonstrated in this codelab.