Multithreading avec un ESP32

Bonjour à toutes et à tous,

Dans l'application que je souhaite réaliser : un routeur photovoltaïque, il y a plusieurs fonctions :

  1. acquisition de données,
  2. fonction de régulation,
  3. enregistrement/lecture de données.
  4. transfert de données par WiFi.

Faire du multithreading me semble approprié. Les fréquences de répétition de ces fonctions sont très différentes mais elles utilisent (1, 2 et 3) puis (3et 4) les mêmes données. D'où risque de collision.

J'ai vu qu’apparemment, il faut utiliser des sémaphores.

J'ai regardé un exemple dans ceux de ESP32, mais j'avoue que je n'ai pas bien compris.

Ce que j'en ai retenu est qu'un sémaphore, mis en place par un thread, empêcherait les autres threads d'avoir accès aux variables du thread qui a activé le sémaphore.

Bien, mais alors, que fait le thread demandeur d'accès. Il attend que le sémaphore soit désactivé pour prendre son tour ou bien il saute l'accès interdit ?

N'étant pas un grand spécialiste de la langue de Shakespeare, j'ai du mal à m'y retrouver.

Avez-vous des exemples expliquant comment mettre en œuvre ces sémaphores ?

Cordialement.

Pierre.

Oui, c'est ça, en gros tu utilises un verrou accessible par toutes tes threads.
En générale, on essaye de mettre ce verrou, sur des actions atomiques, rapide à s'exécuter, par exemple augmenter un compteur(mycounter++;).
Donc si une autre threads veux utiliser cette variable(lecture ou écriture), elle n'est pas bloquée très longtemps.
Mais l'idée est de rendre des actions de lecture/écriture atomique.

La thread qui demande un verrou, déjà tenu, va attendre jusqu'à l'expiration du délais demandé pendant la prise du verrou.
La fonction pour prendre le verrou, retournant si celui-ci à pu être tenu et donc qu'il faudra que tu le libère.
Tu peux tout a fait par exemple faire plusieurs tentative pour prendre le verrou, avec un délais assez faible, pour faire autre chose entre deux demande et garder de la réactivité.

Merci "terwal" pour cette confirmation de ce que je pensais.

Pour autant, je suis incapable de le mettre en œuvre dans une application. Voilà ce que je voudrais faire :

L'application comporte deux tâches :

Tâche 1 : acquisition de données : vE, iE, pA

  • dure 200 mS
  • lancée toutes les 300 mS

Tâche 2 : Traitement sur ces variables

  • prends les variables vE, iE, pA au moment où cette tâche est lancée
  • dure 1 S
  • lancée toutes les 5 secondes
  • ne doit pas affecter aucune des acquisitions

En m'inspirant de cet exemple, voilà le code que j'ai écrit :

xSemaphoreHandle xMutex;
double vE, iE, vEff, iEff, pAct;

void setup() {
  xMutex = xSemaphoreCreateMutex();
  xTaskCreatePinnedToCore(&MesureP, "MesureP", 2048, NULL, 1, NULL, 1); // Priorité haute (1), exécutée sur le noyau 1
  xTaskCreatePinnedToCore(&Traitement, "Traitement", 2048, NULL, 0, NULL, 0); // Priorité basse (0), exécutée sur le noyau 0
}

void loop() {
}

void MesureP(void *params)
{
  while (true)
  {
    printf("Déclenchement Mesure   ");
    if (xSemaphoreTake(xMutex, /*portMAX_DELAY*/200 / portTICK_PERIOD_MS) == pdTRUE)
    {
      for (int i = 0; i < 450; i++) { //La saisie et les calcules durent 200 mS
        vE = analogReadMilliVolts(34);
        iE = analogReadMilliVolts(35);
        /* Calcul des valeurs efficaces et de la puissance active :
           vEff = efficace(vE) {...}
           iEff = efficace(iE) {...}
           pAct = calculP(vE, iE) {...}*/
      }
      printf("Mesures faites\n");
      xSemaphoreGive(xMutex);
    } else {
      printf("  ???\n");
    }
    vTaskDelay(100 / portTICK_PERIOD_MS); // 200 mS de traitement + 100 mS d'attente = 300 mS de récurrence
  }
}

void Traitement(void *params)
{
  while (true)
  {
    printf("Déclenchement Traitement   ");
    if (xSemaphoreTake(xMutex, portMAX_DELAY) == pdTRUE)
    {
      for (int i = 0; i <10; i++) {
        printf("Traite ...");
        delay(100); // Simulation de la durée du traitement : 100 x 10 mS = 1 seconde
      }
      printf("Traite\n");
      printf("Traitement terminé\n");
      xSemaphoreGive(xMutex);
    } else {
      printf("   !!!\n");
    }
    vTaskDelay(4000 / portTICK_PERIOD_MS); // 1 S de traitement +  4 S d'attente = 5 S de récurrence
  }
}

Voilà ce que ça affiche dans le moniteur série lorsque j'ai if (xSemaphoreTake(xMutex, 200 / portTICK_PERIOD_MS) == pdTRUE) dans la tâche d'acquisition :

20:28:15.683 -> Déclenchement Mesure   Mesures faites
20:28:15.822 -> Déclenchement Mesure   Mesures faites
20:28:16.049 -> Déclenchement Mesure   Mesures faites
20:28:16.219 -> Déclenchement Mesure   Mesures faites
20:28:16.416 -> Déclenchement Mesure   Mesures faites
20:28:16.563 -> Déclenchement Mesure   Mesures faites
20:28:16.764 -> Déclenchement Mesure   Mesures faites
20:28:17.050 -> Déclenchement Traitement   Traite ...Déclenchement Mesure   Traite ...Traite ...  ???
20:28:17.383 -> Traite ...Déclenchement Mesure   Traite ...Traite ...  ???
20:28:17.651 -> Traite ...Déclenchement Mesure   Traite ...Traite ...  ???
20:28:17.784 -> Traite ...Déclenchement Mesure   Traite
20:28:17.784 -> Traitement terminé
20:28:17.885 -> Mesures faites
20:28:18.069 -> Déclenchement Mesure   Mesures faites
20:28:18.252 -> Déclenchement Mesure   Mesures faites
20:28:18.421 -> Déclenchement Mesure   Mesures faites
20:28:18.627 -> Déclenchement Mesure   Mesures faites
20:28:18.830 -> Déclenchement Mesure   Mesures faites
20:28:19.002 -> Déclenchement Mesure   Mesures faites
20:28:19.161 -> Déclenchement Mesure   Mesures faites
20:28:19.377 -> Déclenchement Mesure   Mesures faites
20:28:19.535 -> Déclenchement Mesure   Mesures faites
20:28:19.715 -> Déclenchement Mesure   Mesures faites
20:28:19.900 -> Déclenchement Mesure   Mesures faites
20:28:20.100 -> Déclenchement Mesure   Mesures faites
20:28:20.299 -> Déclenchement Mesure   Mesures faites
20:28:20.500 -> Déclenchement Mesure   Mesures faites
20:28:20.662 -> Déclenchement Mesure   Mesures faites
20:28:20.826 -> Déclenchement Mesure   Mesures faites
20:28:21.022 -> Déclenchement Mesure   Mesures faites
20:28:21.222 -> Déclenchement Mesure   Mesures faites
20:28:21.399 -> Déclenchement Mesure   Mesures faites
20:28:21.566 -> Déclenchement Mesure   Mesures faites
20:28:21.783 -> Déclenchement Mesure   Mesures faites
20:28:22.082 -> Déclenchement Traitement   Traite ...Déclenchement Mesure   Traite ...Traite ...  ???
20:28:22.389 -> Traite ...Déclenchement Mesure   Traite ...Traite ...  ???
20:28:22.686 -> Traite ...Déclenchement Mesure   Traite ...Traite ...  ???
20:28:22.786 -> Traite ...Déclenchement Mesure   Traite
20:28:22.817 -> Traitement terminé
20:28:22.886 -> Mesures faites
20:28:23.103 -> Déclenchement Mesure   Mesures faites

Les données arrivent à une cadence à peu près régulière, mais elles ne sont pas valides (???) lors de l'activité de la tâche de traitement.

Si je diminue le temps dans xSemaphoreTake, (200 --> 50) j’obtiens :

20:35:44.770 -> Déclenchement Mesure   Mesures faites
20:35:44.955 -> Déclenchement Mesure   Déclenchement Traitement   Mesures faites
20:35:45.067 -> Traite ...Déclenchement Mesure   Traite ...  ???
20:35:45.148 -> Traite ...Déclenchement Mesure     ???
20:35:45.271 -> Traite ...Déclenchement Mesure     ???
20:35:45.386 -> Traite ...Déclenchement Mesure     ???
20:35:45.517 -> Traite ...Déclenchement Mesure     ???
20:35:45.588 -> Traite ...Déclenchement Mesure     ???
20:35:45.703 -> Traite ...Déclenchement Mesure     ???
20:35:45.833 -> Traite ...Déclenchement Mesure     ???
20:35:45.918 -> Traite ...Déclenchement Mesure     ???
20:35:45.956 -> Traite
20:35:45.956 -> Traitement terminé
20:35:46.134 -> Déclenchement Mesure   Mesures faites
20:35:46.316 -> Déclenchement Mesure   Mesures faites

Le déclenchement est régulier, mais aucune mesure n'est valide.

Si maintenant je joue sur la durée de xSemaphoreTake dans l'autre tâche, cette fois il y a des blocages des acquisitions pendant la tâche de traitement.

Bien que je n'ai certainement pas été clair malgré la longueur de mon discours, je résume la situation de la manière suivante :

  • Je n'ai strictement rien compris au fonctionnement de xSemaphoreTake
  • Le code que j'ai écrit ne correspond peut-être pas à ce que je veux faire.

Merci d'avoir eu le courage d'être arrivé ici.

Cordialement.

Pierre.

Je ne sais pas si j'ai tout compris.
Déjà dans un premier temps, je pense qu'il serait intéressant d'ajouter un mutext, pour protéger les impressions série.

Je ne suis pas sûre d'avoir compris tes timeout des prise de mutex.
pourquoi 200/portTICK_PERIOD_MS et portMAX_DELAY de l'autre?

Je n'ai pas vraiment compris non plus ce que tu veux tester avec ton 10*delay(100)

Si tu veux faire des mesures d'un coté et les traiter d'un autre à un rythme différent, c'est que j'ai compris.
le plus simple est de protéger uniquement l'écriture des variables et leur lecture.
Lorsque je dis écriture, c'est uniquement la mise à jour de l'espace mémoire de la variable, donc je n'engloberais pas son acquisition.
Donc je passerais par une variable local pour l'acquisition et je protégerais uniquement la copie des variables locales dans les variables globales, pour minimiser la tenu du verrou à son minimum.

De même à quoi sert la boucle de 450 itération de la l'acquisition de vE et iE?

J'ai l'impression que tu avais un code non threadé, que tu essaye de passer en thread, mais en gardant la structure originel?

Tu pourrais redécrire ce que tu veux faire, j'ai compris que c'était sur ton projet de routage de ta production d'électricité solaire, mais je suis un peu perdue :frowning:

C'est ce qu j'ai dit à la fin de mon post : je n'ai pas compris l'utilisation de xSemaphoreTake et des temps qui y sont associés.

Ces délais et itérations ne sont là que pour simuler la durée dans le tâches.

Oui, mais comment ? mutex pas mutex, autre ?

Oui, autant que possible. Mais aujourd'hui, je n'ai codé que la fonction acquisition.

Pour l'instant, je pense que ce que j'ai dit au départ renseigne sur ce qu'il y à faire. Je sens que quelque part, je vais être confronté au partage de données entre les diverses tâches, notamment au niveau des écritures et lectures de la carte SD :

  • écriture d'une cinquantaine d'octets toutes les 10 secondes
  • lecture d'un fichier complet (peut-être 500 ko) arrivant n'importe quand pour transfert via le Wi-Fi

Ces deux tâches ne peuvent pas se faire en même temps.

Ce que j'envisage est, que lors de la lecture d'un fichier, celle-ci puis être interrompue par l'écriture de la cinquantaine d'octets. C'est-à dire qu'à chaque changement de tâche il y ait :

  • fermeture de la SD dans l'opération de transfert
  • ouverture de la SD pour l'écriture, fermeture de la SD
  • réouverture de la SD pour le transfert ...

Ainsi de suite.

Pour autant, et avant de me lancer dans ces fonctions, j'aimerais bien comprendre et savoir mettre en application le mulkti-tâche et et ses mutex, sémaphore avec l'ESP32.

Cordialement.

Pierre.

Bonjour ChPr

As tu essayé de poser tes questions à ChatGPT?
Il y a peut-être des pistes :wink:

Cordialement
jpbbricole

Oui, mais je voulais dire que je n'ai pas réussi a deviner ce que tu voulais faire, pour t'indiquer ce qu'il n'allait pas :slight_smile:

Oui, c'est ce que tu avais indiqué en commentaire, mais c'est comme précédemment, je ne vois pas bien comment tu veux le faire.

Oui, quand je dis protégé, c'est via ton mutex, tu prends le jeton avant de faire les affectations(xSemaphoreTake) et tu le redonne juste après(xSemaphoreGive)
Mais pendant que le verrou est pris(code entre les deux fonctions), le mieux est d'avoir le moins de code possible, car pendant ce temps, les threads qui auront fait aussi un xSemaphoreTake, seront arrêtée en attente de la libération du verrou.
Dans ton cas je ne sais pas si c'est rédhibitoire, mais c'est une bonne habitude à prendre.

Oui, c'est moi qui est dû mal à faire coller ta description avec ton code :frowning:

Si j'ai le temps ce midi, j'essayerais de faire un wokwi, avec quelque chose essayant de simuler ce que tu veux faire.

J'essaie de mettre en forme exactement ce que je veux faire et je ne suis pas sûr que les tâches soient la solution ... sans accommodation.

Si j'essaie de redéfinir une partie du besoin :

  • Une première tâche fait une action A qui dure 200 mS et se répète toutes les 300 mS.
  • Une deuxième tâche fait une action B qui dure 1000 mS et se répète toutes les 10000 mS.
  • Les deux actions jouent sur un même ensemble de variables.

Quand l'action B est en cours, elle a pris le sémaphore et ne le rendra pas avant 1000 mS. Or, pendant ces 1000 mS, la tâche A va demander la priorité (au moins trois fois), va s’exécuter mais sans avoir le sémaphore. Donc la partie Si sémaphore pris n'est pas remplie et la tâche A ne peux pas réaliser ce qui était prévu. Il faut que je vois comment gérer cela

Ne gâche pas tes fêtes sur ce problème !

Cordialement.

Pierre.

Bon je n'avais pas eu le temps :frowning:
J'ai fait modifier un exemple pour commencer avec un programme basique pour être sûre que l'on se comprends.
Dans le programme, j'utilise l'écriture dans le moniteur série en parallèle qui fait que certaine écriture se chevauche si le sémaphore n'est pas actif.

En faite ce qu'il faut définir, c'est si les deux actions peuvent s'imbriquer.
De ce que je comprends c'est que les actions sont parallèle, donc je ne pense pas qu'il faut que tu prennes les sémaphore pendant toutes la durée, car sinon, cela revient à sérialiser les actions, comme l'impréssion sur le moniteur série de mon exemple.
il faut que dans chaque actions, tu arrives a faire ressortir des actions atomiques.
tu peux décrire un peut plus ce que tu fais dans chaque actions, j'ai du mal à comprendre.

Ce que j'ai compris, dans l'action A tu récupère les valeurs d'un capteur, la lecture durant 02s, dans l'action B, tu te sert de ces valeurs pour faire quelque choses qui dure 1s?

J'ai la même interrogation que @terwal

Pour que B commence à travailler il lui faut les données de A, donc B ne peut commencer que après A.

Comme B prend 1 seconde, pendant ce temps là A va s'exécuter de nombreuses fois ➜ à quoi servent ces données ?

puis B s'endort pour 9 secondes et à la 10ème seconde recommence.

➜ sur quelles données B doit il s'appuyer ?

Si B travaille toujours sur la dernière version complète des données de A alors une approche possible serait :

A dispose d'un double buffer, un qui est celui des données en cours d'acquisition et une qui est celui des données déjà acquises. Il alterne entre ces deux buffers.

Ensuite B quand il a besoin de travailler demande à obtenir le droit d'accès au buffer des données déjà acquises. Quand il obtient ce droit, il effectue une copie pour avoir son propre buffer pour travailler de façon à perturber A le moins possible (juste le temps de la copie du buffer si d'aventure A arrivé au point où il alterne entre les deux buffers à ce moment là).

Un simple sémaphore binaire est généralement utilisé pour signaler un événement ou synchroniser deux tâches. Ils ne peut avoir que deux états, disponible ou pris ➜ ce serait suffisant pour le besoin.

Une autre approche serait sans sémaphores

A fait son acquisition de données, crée une copie des données, fork une tâche B qui travaille sur cette copie des données si c'est le moment (toutes les 10 secondes). puis reprend son acquisition. B s'exécute et meurt tranquillement une fois sa seconde de traitement effectuée.

Dans ce cas A est le chef et B tourne quand A le souhaite.

Au vu du timining en centaines de ms, je suppose que vous n'êtes pas à quelques microsecondes près.

@ChPr oubli mon charabia et mes questions, @J-M-L a très bien expliqué la problématique :slight_smile:

A noter que, comme les transferts sont à sens unique un simple FIFO permettrait de gérer le problème et B pourrait même prendre les données à la volée et peut-être même commencer son traitement avant que toutes les données soient acquises.
Je parle bien sur d'un FIFO software.

oui il faudrait en savoir plus sur A et B pour faire des propositions adaptées.

Le modèle Producer-Consumer peut être implémenté en utilisant divers design patterns tels que le pattern Buffer pour gérer une file d'attente partagée, le Thread Pool pour optimiser l'utilisation des threads, ou encore le Observer pour permettre au consommateur de réagir aux données produites.

plein de solutions :slight_smile:

@terwal, @ J-M-L et @fdufnews, je vous remercie pour ces analyses et propositions que vous me faites.

NOTA : j'en ai marre de ce site qui, lors de la rédaction de texte, tout d'un coup efface toutes le données parce que je dois certainement appuyer sur touche du clavier qui ne convient pas ; je ne sais pas laquelle et du coup, il faut que je reprenne tout mon texte. Bon, calmons-nous.

L'analyse du traitement multi-tâches m'a permis d'y voir un peu plus clair dans l'enchaînement de ce que je veux faire. Alors, voilà:

Acquisition / régulation
Cette tâche est prioritaire. Dans un but de filtrage, sur 10 périodes de 50 Hz, je calcule les valeurs efficaces de la tension et du courant ainsi que la puissance active qui en découle : d'où les 200 mS (L'ESP32 me permet de faire environ 1200 paquets d'acquisitions pendant ce temps). Ensuite, je gère un PID avec quelques conditions de fonctionnement : environ une vingtaine de mS. Je répète cette tâche toutes les 300 mS pour avoir une bonne réactivité. Cet ensemble d’acquisitions / calculs ne doit pas être sauté. Il peut toutefois s'accommoder d'un bruit temporel d'une vingtaine de mS.

Enregistrement et calculs annexes
Pour ne pas avoir trop de données à enregistrer sur une carte SD, je le fais avec une récurrence de 10 S. Dans cette tâche, je vais certainement faire des calculs annexes que je n'ai pas encore bien définis. NOTA : cette tâche peut potentiellement durer plus des 500 mS et donc ne doit pas bloquer les acquisitions.

Exploitation et affichage des données
Cette tâche se fait sans récurrence précise. Elle consiste à transférer le contenu de la carte SD (1 à 2 Mo de données) vers la mémoire de l'ESP32, puis, par WiFi vers un PC ou smartphone. Cette tâche ne doit altérer ni les acquisitions, ni les enregistrements, sachant que je ne peux ouvrir qu'un seul fichier à la fois sur la carte SD.

Je pense qu'un double buffer ou une FIFO devrait pouvoir gérer cela ou encore une simple interruption matérielle pour les acquisitions.

Cordialement.

Pierre.

Au final, la partie enregistrement sur carte SD, a quel but ?

Si j'ai bien compris l'acquisition, calculs des valeurs efficaces, stockage doit ce faire à environs 10/50s soit toutes les 200ms.

ensuite tu parles de calculs annexes qui peuvent durer 500ms,ce qui est assez long, toutes les 10s.
Tu veux dire que tes calculs portes sur 500 mesures des valeurs efficaces ?

Je suppose que la partie transfert, envois toutes les valeurs efficaces que tu as calculées, plus les calculs annexes?

Je me pose la question as tu vraiment besoin d'une FIFO, c'est à dire as tu vraiment besoin de recalculer des métriques à chaque fois que tu ajoute une nouvelle valeur en gardant les x précédentes valeurs?

Un simple double buffer, ne suffit pas ?
C'est à dire dans une tâche tu stockes tes valeurs dans le tableau A et dans l'autre tâche tu calculs tes métriques et envois toutes les données du tableau B.

Attention vous avez deux cœurs dans votre ESP32 mais de nombreux périphériques partagés comme le SPI ou I2C.

Vous pourriez avoir des contentions sur ces périphériques sans parler bien sûr du bus mémoire

Il faut regarder finement quels sont les chemins de données et les interdépendances.
Streamer deux megs en wifi tout en continuant les acquisitions ou écriture sur la SD me paraît incertain…

[parenthèse]

sur le papier le SPI des ESP32 est l'un des nombreux périphériques pouvant bénéficier de l'accès direct mémoire , ça éloigne la contention......la pratique n'est pas forcément triviale.....

(J'ai l'impression que l'Accès Direct Mémoire des ESP32 est sous-utilisé, probablement en raison de l'héritage AVR)

[/parenthèse]

Il partage le bus de données avec les CPU non ? (Sans parler du cache possible)

j'e pense que ça va plus loin et , une fois initié par le cpu, peut à la demande éviter la lecture/écriture des octets d'un bloc par le cpu , avec un signalement quant ce transfert est achevé...... à vérifier, j'en suis conscient

Que serait un contrôleur de DMA qui ne saurait pas faire ça : contournement du cpu pour des échanges directs mémoire-périphérique pour des blocs prédéfinis ?

Sur l’ESP32, le CPU et le DMA accèdent à la mémoire via des bus partagés, en particulier SPI0 et SPI1 pour la flash externe et la PSRAM, ce qui peut provoquer des contentions lorsque les deux utilisent ces ressources simultanément.

Le cache unifié de l’ESP32 optimise les accès du CPU en stockant temporairement des données et instructions issues de la mémoire externe, mais ces accès peuvent être suspendus lors des transferts directs effectués par le DMA ou les périphériques SPI.

➜ contention possible car partage du bus de données