La programmation multi-tâches avec Mbed OS
Les threads
Mbed OS est un RTOS pour la famille des processeurs Cortex-M. Il s’exécute donc sur un microcontrôleur avec un seul cœur. Dans ce contexte, un thread est une entité d’exécution indépendante qui s’exécute sur la même unité de calcul de manière concurrente aux autres tâches et à l’aide d’un ordonnanceur. L’utilisation du mécanisme de thread permet au développeur de concevoir, développer et tester des parties d’un programme qui s’exécuteront dans leur propre contexte et pourront échanger de l’information et se synchroniser avec les autres tâches d’un même programme.
Le mécanisme a toutefois un coût puisque chaque thread sera exécuté sur son propre stack. Le changement d’exécution d’un thread à un autre nécessite également un changement de contexte (context switching) qui nécessite un certain temps d’exécution.
L’API thread permet au développeur de créer et de démarrer une instance de thread. Au démarrage, le programme doit spécifier un callback qui représente la fonction ou la méthode que le thread doit exécuter. Comme tous les mécanismes de callback utilisé avec Mbed OS, le programme peut spécifier un objet et ainsi exécuter le thread dans le contexte de cet objet.
Il est important de noter les points suivants:
- Mbed OS crée par défaut plusieurs threads au démarrage du RTOS. En particulier, le système crée un thread nommé main, qui après quelques initialisations exécutera la fonction main() écrite par le développeur de l’application.
- Les threads de Mbed OS ne peuvent être démarré qu’une seule fois. La durée de vie d’un thread correspondra donc dans la majorité des cas à la durée de vie de l’application. Cela signifie que la création de threads doit ainsi plutôt être conçue de manière statique et que le nombre de threads et l’utilisation des ressources associées d’un programme Mbed OS doivent être connus à la conception de l’application. La raison principale est de pouvoir garantir la stabilité du système et de l’utilisation de la mémoire.
L’exemple ci-dessous illustre l’utilisation de l’API permettant de créer et démarrer une deuxième instance de thread afin de faire clignoter une deuxième LED, en supplément du thread main qui fait clignoter une première LED.
#include "mbed.h"
void led2_thread()
{
DigitalOut led2(LED2);
while (true) {
led2 = !led2;
ThisThread::sleep_for(1000ms);
}
}
int main()
{
// run one thread for blinking LED2
Thread thread;
thread.start(led2_thread);
// main thread runs blinking on LED1
DigitalOut led1(LED1);
while (true) {
led1 = !led1;
ThisThread::sleep_for(500ms);
}
}
L’état des threads
Chaque thread du système Mbed OS possède un état, parmi les états:
- running: est en cours d’exécution. Un seul thread du système est en cours d’exécution à un moment donné.
- ready: est prêt à être exécuté. Lorsque le thread en cours d’exécution a terminé, se met en attente ou a utilisé le temps CPU à disposition, l’ordonnanceur va choisir le prochain thread à exécuter parmi ceux-ci.
- waiting: attend sur un événement du système pour passer en mode ready ou running.
- inactive ou terminated:
Les différents états ainsi que les transitions d’état sont affichés dans le diagramme ci-dessous:
L’API EventQueue
Dans l’exemple précédent, deux instances de thread sont créées afin de faire clignoter deux LEDs. Étant donné le coût associé à la création d’un thread et lorsque les besoins du programme consistent à ordonnancer des tâches simples qui sont indépendantes les unes des autres, un mécanisme plus simple est mis à disposition par Mbed OS. Il s’agit de l’API EventQueue. Une EventQueue est un mécanisme qui permet simplement d’ordonnancer des événements qui seront exécutés par un thread. La manière la plus simple d’utiliser une EventQueue est démontrée dans l’exemple ci-dessous. Cet exemple réalise le même comportement que l’exemple ci-dessous avec deux threads:
#include "mbed.h"
void toggleLED(DigitalOut* led)
{
*led = !*led;
}
int main()
{
DigitalOut led1(LED1);
DigitalOut led2(LED2);
EventQueue eventQueue;
eventQueue.call_every(500ms, callback(toggleLED, &led1));
eventQueue.call_every(1000ms, callback(toggleLED, &led2));
eventQueue.dispatch_forever();
}
Une utilisation très fréquente de l’API EventQueue est le traitement de routine ISR déféré. Ce mécanisme permet de déplacer le traitement d’événements d’interruption de la routine ISR vers un contexte non ISR. Lorsque la routine ISR devrait exécuté du code dont le temps d’exécution sera long ou qui fait appel à des méthodes qui ne sont pas interrupt safe, ce mécanisme est très utile et très facile à mettre en œuvre. L’exemple ci-dessous démontre une telle mise en œuvre permettant d’afficher un message lorsque l’utilisateur presse sur le bouton.
#include "mbed.h"
EventQueue eventQueue;
void fall_handler_deferred(void)
{
// this is executed in thread context
printf("fall handler deferred in context %p\n", ThisThread::get_id());
}
void fall_handler(void)
{
// this is executed in ISR context
eventQueue.call(fall_handler_deferred);
}
int main()
{
InterruptIn button(PA_0);
// attach the event
button.fall(&fall_handler);
// serve the events on the queue
printf("Main thread running in context %p\n", ThisThread::get_id());
eventQueue.dispatch_forever();
}
Accès aux ressources partagées
Jusqu’ici, nous avons essentiellement considéré que les tâches constituant un programme étaient indépendantes les unes des autres et qu’elles ne partageaient pas de ressource. En réalité, ce cas de figure est plutôt rare et, dans un programme multi-tâches, il est souvent requis que les tâches partagent des ressources. Ceci a bien sûr des conséquences sur l’ordonnancement des tâches et, sans aller dans les détails, nous pouvons mentionner les problèmes d’inversion de priorité, d’interblocage (deadlock) et de famine (starvation).
Dans la suite, nous ne traitons pas ces problèmes qui sont des problèmes très importants, mais dont le traitement sort du cadre de ce cours. Nous présentons par contre les différents outils mis à disposition par Mbed OS permettant d’aisément programmer des scénarios producteur - consommateur.
L’API Queue et Mail
Afin de réaliser facilement des scénarios producteur - consommateur, Mbed OS met à disposition dans son API les mécanismes de Queue et de Mail. Les deux API sont similaires et réalisent un mécanisme de file (queue) permettant un échange de données de threads produisant des données vers des threads consommant des données. L’API Queue permet d’échanger des pointeurs ou des valeurs entières alors que l’API Mail permet d’échanger des structures de données. L’API Mail est construite à l’aide d’une Queue et d’une instance de MemoryPool. L’utilisation d’un MemoryPool est importante, car elle permet d’allouer un espace de mémoire fixe pour l’échange de données sans allocation dynamique sur le tas (heap). Minimiser les allocations sur le tas est un critère important de qualité pour les programmes embarqués.
Les ressources partagées et l’exclusion mutuelle
Sur une plateforme ARM Cortex-M, plusieurs threads peuvent être exécutés de façon concurrente en partageant le temps du CPU. Sur un système multi-cœurs, plusieurs threads peuvent également être exécutés simultanément. On pourrait penser qu’un système mono-cœur n’a pas besoin de protéger l’accès aux variables puisqu’un seul thread peut être exécuté à un moment donné. Cela n’est pourtant pas le cas, et aussi bien pour les systèmes mono-cœur et multi-cœurs, l’accès à certaines variables par des threads concurrents est un problème. Un système multi-tâches fonctionnant sur un CPU mono-cœur doit ainsi également s’assurer que l’accès à certaines ressources partagées n’est possible que par un seul thread. Cette protection est en principe réalisée à l’aide d’un Mutex, mécanisme mis à disposition par la plupart des RTOS.
Nous illustrons ce problème à l’aide de l’exemple d’une classe Clock
dont le but est de délivrer le temps courant. Pour mettre à jour
l’horloge, une instance de Ticker
avec un intervalle d’interruption de
1 seconde est utilisé. La classe réalisant le comportement de l’horloge
est donnée ci-dessous:
#ifndef CLOCK_HPP_
#define CLOCK_HPP_
#include "mbed.h"
static constexpr std::chrono::milliseconds TICKER_INTERVAL = 1000ms;
class Clock
{
public:
struct DateTimeType {
uint32_t day;
uint32_t hour;
uint32_t minute;
uint32_t second;
};
Clock() {
// initialize current time
currentTime_.day = 0;
currentTime_.hour = 10;
currentTime_.minute = 59;
currentTime_.second = 59;
}
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), TICKER_INTERVAL);
// schedule an event every second for displaying the time on the console
clockDisplayQueue_.call_every(
TICKER_INTERVAL,
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 %ld Hour %ld min %ld sec %ld\n",
dt.day, dt.hour, dt.minute, dt.second);
}
void tickerUpdate()
{
tickerQueue_.call(callback(this, &Clock::updateCurrentTime));
}
void updateCurrentTime()
{
currentTime_.second++;
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_;
};
#endif /* CLOCK_HPP_ */
Il est important de noter que la mise à jour du temps courant n’est pas
exécutée directement par la routine ISR mais qu’elle est confiée
(deferred) au thread tickerThread_
par l’intermédiaire de
tickerQueue_
. La raison principale de procéder ainsi est que
l’acquisition d’un Mutex dans un contexte ISR n’est pas possible.
Pour l’instant, aucun Mutex n’est utilisé, mais nous allons justement
démontrer la nécessité de protéger certains accès dans la classe
Clock
.
Le programme principal démontrant l’utilisation de l’horloge est démontré ci-dessous:
#include "mbed.h"
#include "clock.hpp"
int main() {
printf("Clock update program started\n");
// create and start a clock
Clock clock;
clock.start();
return 0;
}
Si vous exécutez ce programme sur votre cible, vous devriez observer les
valeurs de l’horloge s’affichant dans la console, avec comme première
valeur Day 0 Hour 10 min 59 sec 59
suivi de Day 0 Hour 11 min 00 sec
00
. Ce sont bien les valeurs attendues et nous pourrions donc penser
que le programme ne souffre d’aucun problème d’accès concurrent. Et
pourtant, il y a bel et bien un problème de race condition.
Exercice La programmation multi-tâches avec Mbed OS/1
Imaginez que l’exécution de la méthode getAndPrintDateTime
soit
interrompue alors que les valeurs de la variable locale dt
ont été
partiellement mises à jour. Dans ce cas, décrivez un problème qui
pourrait mener à une mise à jour erronée de l’horloge.
Il suffit d’ajouter une attente active de 5ms
à un endroit donné
afin de reproduire le problème de manière systématique: décrivez
ce changement.
Solution
Le problème qui peut survenir est le suivant:
- Supposons que le temps courant soit
day: 0, hour: 10, minute: 59, second: 59
- Le premier appel à la méthode
getAndPrintDateTime
est effectué par le thread principal par l’intermédiaire d’un événement sur l’instance deEventQueue
clockDisplayQueue_
. Supposons que les deux premières instructions C++ soient exécutées et que les valeurs{0, 10}
soient donc copiées dansdt.day/hour
. - Supposons qu’à ce moment-là, un changement de contexte soit ordonné par le
système et que le thread mettant à jour l’horloge soit exécuté.
Le nouveau temps sera donc
day: 0, hour: 11, minute: 0, second: 0
. - Plus tard, la méthode
getAndPrintDateTime
reprend son cours d’exécution et copie donc les champs suivants dans la variabledt
(copiant donc{0, 0}
dans les champsdt.minute/second
). La valeur des champs de la variabledt
sont donc{0, 10, 0, 0}
. Le temps a donc reculé de 1h !
La probabilité de l’occurrence d’un tel événement est bien sûr très faible.
Mais il y a un moyen simple de le provoquer: ajouter un appel à une fonction
d’attente active entre la modification de dt.hour
et de dt.minute
,
de la façon suivante:
dt.hour = m_currentTime.hour;
wait_us(5000);
dt.minute = m_currentTime.minute;
En forçant une attente et en sachant que le Quantum de l’ordonnanceur
round-robin de Mbed OS est de 5 ms
(voir RTX System
Configuration
et RTX_Config.h),
une attente de 5 ms
génère à chaque fois un changement de contexte
et permet ainsi de reproduire l’erreur systématiquement.
L’erreur décrite dans la solution de l’exercice ci-dessus peut être évitée en identifiant correctement les sections critiques. Vous pouvez empêcher l’accès concurrent à ces sections critiques en déactivant les interruptions à l’entrée de la section critique et en les réactivant à la sortie de la section critique, de la façon suivante:
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);
Le fait de déactiver les interruptions empêche le système de préempter
la tâche. Cette approche ne fonctionne toutefois que sur un système
mono-cœur et sur un système multi-cœurs, un accès concurrent à la
section critique est encore possible. Pour cette raison (et aussi parce
que la désactivation des interruptions peut avoir des effets secondaires
en dehors de la protection de la section critique), une réalisation avec
un Mutex
est préférée.
Exercice La programmation multi-tâches avec Mbed OS/2
Modifiez la réalisation de la classe Clock
afin de protéger les
accès concurrents aux sections critiques à l’aide d’un Mutex
.
Solution
Vous devez tout d’abord déclarer une instance de Mutex
comme
attribut de la classe:
Mutex mutex_;
Vous devez ensuite protéger la section critique dans la méthode
getAndPrintDateTime
mutex_.lock();
dt.day = currentTime_.day;
dt.hour = currentTime_.hour;
dt.minute = currentTime_.minute;
dt.second = currentTime_.second;
mutex_.unlock();
Vous devez faire de même dans la méthode updateCurrentTime
en
protégeant les accès à currentTime_
.
Une dernière remarque est utile pour conclure avec cet exemple. La
réalisation faite utilise un Mutex
dans un contexte non ISR. Afin de
démontrer le fait qu’un Mutex
ne peut pas être utilisé dans un
contexte ISR, il suffit de modifier la ligne de code qui attache la
routine appelée par le Ticker
.
// call the tickerUpdate() method every second, for queueing an event
// to be dispatched by the ticker thread
ticker_.attach(callback(this, &Clock::updateCurrentTime), TICKER_INTERVAL);
Avec ce changement, la mise à jour de l’horloge n’est plus confiée
(deferred) au thread tickerThread_
, mais est bien exécutée dans le
contexte de la routine ISR appelée par le Ticker
. En effectuant ce
changement, vous devriez constater que votre programme interrompt son
exécution avec une erreur fatale (lorsqu’un Mutex
est utilisé).
Les ressources partagées et les sémaphores
Comme les mutex, les sémaphores permettent de contrôler l’accès à des ressources partagées. Les sémaphores sont en effet très utiles pour réaliser des scénarios de producteurs et consommateurs. Ils servent ainsi à définir le nombre maximum de ressources utilisables et ainsi à contrôler l’accès à ces ressources.
Un exemple d’utilisation des sémaphores est la réalisation d’une Queue partagé par plusieurs threads. Si le Queue fournit une méthode pour ajouter des données et une autre pour en consommer (selon un mécanisme FIFO ou LIFO), il est nécessaire de pouvoir contrôler que les clients de la Queue ne produisent pas plus de données qu’autorisées et ne puissent pas consommer de données lorsqu’aucune donnée n’est disponible. Dans un contexte de programmation concurrente, les threads doivent pouvoir attendre sur certains événements si la Queue est pleine ou vide. Le code ci-dessous démontre le principe pour éviter la production excessive d’éléments dans la Queue.
class Queue {
public:
...
void put(int datum)
{
inSemaphore_.acquire();
// insert element in buffer
}
int get(void)
{
// pick element from buffer
inSemaphore_.release();
return datum;
}
private:
...
int buffer_[QUEUE_SIZE] = {0};
...
Semaphore inSemaphore_ {QUEUE_SIZE};
};
Exercice La programmation multi-tâches avec Mbed OS/3
Comme expliqué, la réalisation d’une Queue nécessite également de
contrôler le mécanisme de consommation et non seulement de
production. Est-ce que la réalisation ci-dessus garantit le fait
qu’un consommateur ne pourra pas consommer de données si aucune
donnée n’est disponible dans la Queue ? Si non, est-ce que le
sémaphore inSemaphore_
peut être utilisée dans ce but ? Si non,
quel mécanisme faut-il mettre en place ?
Solution
Il est nécessaire de mettre en place un deuxième sémaphore qui contrôle le flux de sortie de la Queue, selon la solution ci-dessous.
class Queue {
public:
...
void put(int datum)
{
inSemaphore_.acquire();
// insert element in buffer
outSemaphore_.release();
}
int get(void)
{
outSemaphore_.acquire();
// pick element from buffer
inSemaphore_.release();
return datum;
}
private:
...
int buffer_[QUEUE_SIZE] = {0};
...
Semaphore inSemaphore_ {QUEUE_SIZE};
Semaphore outSemaphore_ {0};
};
Est-ce que les mutex sont des sémaphores binaires ?
Une remarque très importante concernant les sémaphores et les mutex
est qu’il est faux de considérer un mutex comme étant un sémaphore
binaire. L’exemple de la Queue ci-dessus permet de démontrer la
première différence: contrairement au mutex pour laquelle on peut dire
qu’elle a un propriétaire (le thread qui a fait un lock()
sur le
mutex), un sémaphore n’a pas de propriétaire. Un mutex est un
mécanisme de protection (lock) alors qu’un sémaphore est un mécanisme
de signalisation. En d’autres termes, alors qu’un mutex est toujours
verrouillé puis déverrouillé (dans cet ordre) par le même thread, les
threads utilisant un sémaphore vont soit la signaler (appel à
release()
) ou attendre (appel à acquire()
), en fonction de leur
rôle.
Une autre différence importante est que les mutex ne peuvent pas être
utilisées dans un contexte ISR, au contraire des sémaphores qui
peuvent être signalés ou attendus de façon non bloquante (avec la
méthode try_acquire()
par exemple). Les sémaphores peuvent donc servir
à signaler des threads en attente depuis un contexte ISR.
Il est encore utile de mentionner qu’au contraire des sémaphores, les mutex sont réentrants avec Mbed OS. Cela signifie qu’un mutex peut être acquis plusieurs fois par le même thread, sans créer de deadlock. Il est utile de démontrer ce point pour une classe utilisant un mécanisme de composition avec une classe réalisant un mécanisme de verrouillage:o
class A
{
void lock()
{
mutex_.lock();
}
void unlock()
{
mutex_.unlock();
}
private:
//
Mutex mutex_;
}
class B
{
void method1()
{
a_.lock();
...
method2();
...
a_.unlock();
}
void method2()
{
a_.lock();
...
a_.unlock();
}
private:
// owns an instance of A
A a_;
};
Exercice La programmation multi-tâches avec Mbed OS/4
Dans l’exemple ci-dessus, il est admis que les méthodes method1()
et method2()
de la classe B
doivent acquérir l’instance de A
afin d’effectuer un ensemble d’opérations qui doivent être atomiques
sur cette instance.
Expliquer pourquoi un deadlock survient si le mutex n’est pas réentrant.
Solution
Dans method1()
, a_
est acquis puis method2()
est appelée.
Dans method2()
, a_
est acquis à nouveau. Si le mutex n’est pas
réentrant, alors un deadlock survient.
Vous trouverez encore un complément d’information afin de comparer mutex et sémaphores dans cet article.
L’API ConditionVariable
Comme le concept de sémaphore et de mutex, le concept de variable conditionnelle, important pour la programmation concurrente se retrouve également dans Mbed OS comme documenté sous ConditionVariable
Vous remarquerez bien évidemment que la variable conditionnelle
ConditionVariable
est liée à un Mutex
déjà vu au paragraphe précédent. Nous
vous renvoyons à la théorie du cours Systèmes Concurrents sur les moniteurs
et les variables conditionnelles, en effet vous remarquez que le Mutex
est
libéré avec les méthodes wait
le temps de la reprise du moniteur à la suite
d’une notification (release, notify, signal, etc.
).
Exercice
Vous devez synchroniser à l’aide d’un moniteur (ConditionVariable
), une
tâche qui met à jour une donnée dans un objet partagé avec une tâche qui
reprend cette donnée mise à jour pour l’afficher. Il s’agit d’un problème
classique de producteur/consommateur.
Exercice La programmation multi-tâches avec Mbed OS/5
Créer un objet partagé entre un thread
producer et un thread
consumer. La donnée partagée est mise à jour par la méthode void
setData(int value)
et relue par la méthode int getData()
de l’objet
partagé.
- Le moniteur est caché aux deux
thread
dans l’objet partagé. - La méthode
getData
bloque tant que la donnée n’est pas mise à jour. - La méthode
setData
débloque la méthodegetData
.
Proposez une solution qui fonctionne avec plusieurs consommateurs.
Proposez deux options de solutions où les consommateurs affichent les données soit alternativement, soit tous en simultané à chaque mise à jour.
Solution
#include "mbed.h"
class SharedData {
public:
SharedData() : mutex_(), condition_(mutex_) {}
void setData(int value){
mutex_.lock();
value_ = value;
// condition_.notify_all(); // simultaneously
condition_.notify_one(); // alternatively
mutex_.unlock();
}
int getData(){
mutex_.lock();
condition_.wait();
int value = value_;
mutex_.unlock();
return value;
}
private:
int value_ = 0;
Mutex mutex_;
ConditionVariable condition_;
};
void consumerTask(SharedData* data)
{
while (true) {
printf("%s receives %d \n", ThisThread::get_name(), data->getData());
}
}
int main() // also the producer !
{
SharedData data;
Thread consumeThread1(osPriorityNormal,OS_STACK_SIZE,NULL,"Consumer1");
Thread consumeThread2(osPriorityNormal,OS_STACK_SIZE,NULL,"Consumer2");
consumeThread1.start(callback(consumerTask, &data));
consumeThread2.start(callback(consumerTask, &data));
int value = 0;
while (true) {
printf("Produces %d\n", value);
data.setData(value++);
ThisThread::sleep_for(2s);
}
}
Thread safety
Bien entendu, les composants de Mbed OS abordés dans ce chapitre doivent être thread safe. À ce propos - si vous ne l’avez pas déjà fait, vous pouvez lire le paragraphe Thread safety de la documentation de Mbed OS.