Utiliser les threads en physique numérique

Le projet cadre : simuler une expérience physique

La coopération entre la physique et l'informatique est très étroite. Dans les pages de TangenteX.com, vous trouverez une foule d'exemples de l'utilisation des outils informatiques pour faire du calcul et de la simulation des systèmes physiques. Je vous propose dans cette page de commencer à découvrir une autre utilisation des outils informatiques en physique, une utilisation majeure : l'acquisition des données, la mesure physique.

Il est quasiment impossible aujourd'hui de trouver une expérience de physique dans laquelle l'ordinateur n'intervienne pas pour acquérir les mesures des grandeurs physiques de l'expérience. Finis les chronomètres, voltmètres et autres instruments de mesure. Sur la paillasse, nous voyons des capteurs reliés à une centrale d'acquisition des données et un ordinateur qui stocke les données, les traitent et affiche les résultats.

Mon ambition est de vous proposer une simulation informatique d'un tel système. Je pense à simuler l'expérience de diffusion thermique dans une barre 1D. Nous avons déjà vu comment calculer les solutions de l'équation de diffusion thermique. Essayons maintenant de faire autrement : simuler les capteurs de température, le stockage des données et leur traitement. En bref, simuler l'expérience réelle, plutôt que de résoudre l'équation.

Le projet est ambitieux et nécessite l'usage d'éléments de programmation qui ne sont pas forcément connus, comme l'usage des threads. Je vais donc commencer par là et je poursuivrai le projet dans une autre page, ou même plusieurs...

Les threads : une courte introduction

Qu'est-ce qu'un thread

Nous allons faire un petit détour dans des considérations purement informatiques. Après tout, la physique numérique demande aussi une bonne culture informatique...

Tout d'abord, parlons terminologie. Dans la suite, j'utiliserai le terme "thread" pour définir l'objet informatique que je vais vous décrire. La définition normalisée par l'ISO en français est "tâche". On les appelle aussi "processus léger", on verra pourquoi. L'usage du mot "thread" est tellement courant que je ne vois vraiment pas pourquoi y déroger !

Thread et processus
Processus

Votre ordinateur exécute des programmes, ceux que vous écrivez, mais aussi ceux qui lui permettent de fonctionner, en particulier les programmes du système d'exploitation (OS), sans oublier les multiples outils comme Word, Excel, Python et bien d'autres.

Lorsque vous écrivez un programme, vous composez une liste d'instructions en langage évolué, C ou Python par exemple. Ces instructions seront traduites en langage machine par un compilateur (dans le cas du C) ou par un interpréteur (cas de Python). Tel quel, l'ordinateur est incapable de comprendre et d'exécuter cette liste d'instructions.

Pour pouvoir exécuter votre programme, l'OS de votre ordinateur va le transformer en processus. Un processus est l'unité d'exécution d'un ensemble d'instructions en langage machine, à laquelle l'OS va allouer des ressources : de la mémoire, un accès à la CPU, des accès aux fichiers et aux périphériques de votre ordinateur, des objets systèmes comme des compteurs d'instructtions, des pointeurs de pile, des registres et autres choses. Un processus est une unité indépendante, qui va vivre sa vie propre dans la mémoire de votre ordinateur, sous l'autorité de l'OS. En utilisant le gestionnaire de tâches de votre OS, vous pouvez consulter la liste des processus qui fonctionnent sur votre ordinateur : il y en a beaucoup...

Il est aussi important de souligner qu'à un instant donné, un processeur matériel ne peut dérouler qu'un seul processus. Si vous voyez beaucoup de processus dans votre ordinateur, c'est qu'en fait ils se partagent l'accès au processeur et passent la plupart de leur temps à attendre (un accès au processeur, des entrées/sorties disques, des accès télécom, des accès à l'écran, etc.). Lorsqu'on dit que, dans un ordinateur, les processus s'exécutent en parallèle, c'est très souvent un abus de langage ! Sauf dans quelques cas particuliers : votre ordinateur possède plusieurs processeurs, et dans ce cas si les programmes sont bien écrits, chaque processus peut tourner sur un processeur distinct. Ou si votre processeur est multicoeurs (multi-core) et à condition que les programmes soient bien écrits, ce qui est assez rare, alors l'OS peut allouer un processus à chaque coeur. Remarquons que dans le monde Windows, très peu de programmes savent exploiter tous les coeurs d'un ordinateur moderne, ce qui est très dommage.

Thread

Un thread est un sous-ensemble d'un processus. Son rôle est d'exécuter le plus indépendamment possible une fonction du code du programme, comme nous le verrons plus bas. Un processus comporte toujours un thread, par construction, mais peu aussi en comporter plusieurs. Dans ce cas, les threads partagent l'espace mémoire et d'adressage du processus ainsi que ses ressources externes. Par contre, chaque thread possède sa propre pile de données locales.

On utilise plusieurs threads dans un processus lorsque ce processus doit traiter plusieurs actions dont on pense qu'elles introduiront des attentes. Prenons l'exemple d'un programme qui télécharge un fichier et l'affiche. Ces deux actions génèrent beaucoup d'attente et donc on imagine un thread pour traiter le téléchargement et un thread pour traiter l'affichage. Autre cas fréquent : les programmes qui gèrent plusieurs fenêtres; on alloue un thread à la gestion de chaque fenêtre.

Avantages/inconvénients des threads vs. processus

La création et le lancement d'un processus est une lourde tâche pour l'OS. La création et l'activation d'un thread est plusieurs dizaines de fois plus rapide. De même, le "swap" entre deux threads d'un même processus est plus rapide qu'entre deux processus. le changement de contexte (le swap) ne concerne en effet que les piles et quelques compteurs dans le cas de threads, contre tout un ensemble d'objets système dans le cas de processus.

Les threads d'un même processus partagent le même espace mémoire du processus. Ils peuvent donc échanger des données très facilement. L'échange de données entre processus est assez compliqué et implique l'usage d'objets systèmes particulier (mémoire partagée, pipeline, sockets, etc.).

Mais les processus ont aussi leurs avantages. Le principal est d'avoir chacun leur espace d'adressage et de données propre et protégé. Le plantage d'un processus ne provoque pas le plantage des autres processus. L'étanchéité des espaces garantit aussi une bonne sécurité des données. L'allocation des processus aux processeurs est aussi mieux gérée par les OS dans le cas des architectures parallèles et massivement parallèles.

Les outils pour gérer les threads sous Python

Disons-le tout de suite : Python n'est pas le langage le plus approprié pour programmer avec des threads ! Il est même tout à fait déconseillé lorsque l'usage des threads vise des traitements qui occupent toute la CPU. En effet, Python est un langage interprété, qui fonctionne dans un seul processus, et c'est là tout le problème. Nous avons vu que chaque thread partage avec les autres les ressources physiques et logiques du processus. Si un thread de calcul, par exemple, mobilise toute la CPU, il ne restera rien aux autres threads, qui devront attendre. Sous Python, le seul usage intelligent des threads concerne les activités pendant lesquelles les attentes de la CPU sont "longues" : les télécoms par exemple où l'on attend les messages entrants, les interfaces graphiques, les accès disques et autres activités de ce genre. Bref, les threads sous Python font mauvais ménage avec le calcul ! Il serait bien plus futé d'utiliser C ou C++.
 J'ai cependant choisi de rester sous Python pour cette introduction aux threads. Les principes d'usage des threads sont les mêmes quelque soit le langage...

Simuler un ensemble de capteurs avec des threads

Le projet préparatoire

Modéliser notre système d'acquisition

Imaginons un ensemble de capteurs thermiques qui délivrent un signal électrique en fonction de la température lue sur leur environnement. Ces capteurs sont branchés à une carte d'acquisition qui va numériser le signal électrique issu de chaque capteur et l'identifier, c'est à dire associer une mesure à un capteur. Puis la carte d'acquisition va stocker les mesures dans une base de données.

Dans un premier temps, pour la présente simulation, nos capteurs généreront des valeurs aléatoires de température. Nous verrons plus tard comment leur faire générer des valeurs conformes à l'expérience. Notre but dans cette page est de voir comment procéder informatiquement...

Analyse de notre simulation

Principes de la simulation

Les principes de notre simulation sont très simples:

Structure d'un message produit par un capteur

Le message produit par chaque capteur à chaque mesure comportera les informations suivantes :

Ces données peuvent bien sur être complétées et elles le sont dans la réalité. On y ajoute généralement la date et l'heure de l'acquisition, la qualité de la donnée et bien d'autres informations utiles.

Structure de l'enregistrement de la BD simulée

Chaque enregistrement stocké dans la liste simulant la base de données comportera les informations suivantes :

La structure réelle d'un enregistrement de BDTR (Base de Données Temps Réel) est bien plus complet, mais c'est l'esprit qui compte : une information de temps (ici donnée par le rang d'acquisition), les valeurs acquises et leur unité.

Conception du script ThreadPython.py

Définition des classes

Pour la première fois sur TangenteX, je vais faire usage de la programmation orientée objet. En fait, j'utilise seulement la possibilité de définir une classe de description de données, l'équivalent pur et dur de la structure en C. C'est bien pratique pour décrire la structure d'une mesure d'un capteur et celle d'un enregistrement de notre BDTR simulée.

La classe Mesure

J'ai défini plus haut les données composant une mesure pour un capteur. Voici la description sous forme d'une classe python:

class Mesure(object):

    def __init__(self):

        self.CapteurId = None

        self.CapteurNum = None

        self.Valeur = None

        self.Unit = None

Dans ce bout de code, je décris la structure de l'enregistrement, mais j'initialise aussi le contenu des variables à la création d'un enregistrement de type Mesure.

Lorsque je voudrais créer un enregistrement de type Mesure, que je nommerai "mesure" par exemple, c'est très simple :

mesure = Mesure()

ce qui revient à déclarer que la variable mesure est de type Mesure().

La classe BDTR

La technique est absolument identique à celle utilisée pour déclarer la classe Mesure :

class BDTR(object):

    def __init__(self):

        self.AcqNum = None

        self.Tableau = np.zeros(nbcapteurs)

        self.Unit = None

et pour créer un enregistrement dans la BDTR, je procède de la même manière que pour une mesure :

record = BDTR()

La notion de pile

Imaginez votre bureau et plusieurs dossiers à traiter que vous allez empiler sur ce bureau. Vous allez les disposer les uns sur les autres. Une grande vague de courage vous submerge et vous décidez de les traiter : comment allez-vous procéder ? Vous pouvez décider de traiter le plus récent, c'est à dire le dernier, celui qui est au sommet de la pile. Mais vous pouvez aussi décider de traiter d'abord celui qui est le plus ancien, c'est à dire celui qui est tout à fait en dessous des autres. A supposer, comme nous l'avons fait que vous entassiez les dossiers les uns sur les autres, car bien sur, vous pouvez décider d'insérer un dossier n'importe où dans la pile, ou bien à un endroit bien déterminé.

En informatique, c'est pareil ! Une pile est fondamentalement une liste d'objets. En Python, vous pouvez insérer un objet en début de liste, en fin de liste ou même n'importe où. Mais alors, quelle différence entre une pile et une liste ? La manière dont on y accède...

On utilise principalement deux types de piles : la pile FIFO et la pile LIFO. FIFO signifie First Input First Output. Vous traitez en premier le dossier qui est tout au fond de la pile de dossiers parce que c'est le premier que vous avez déposé sur la pile de dossiers. On traite d'abord l'objet le plus ancien dans la pile. Pour vous donner un cas concret, c'est le principe des files d'attente dans les supermarchés : le client arrivé en premier à la caisse est servi en premier.

Le principe de la pile LIFO est différent. LIFO signifie Last Input First Output. On traite en premier le dernier arrivé. C'est la tendance habituelle devant une pile de dossiers... On peut aussi décider qu'un dossier est prioritaire et alors, on le cherche dans la pile et on le traite en premier. En programmation, cela consiste à chercher un objet dans la liste et à l'extraire de la liste en premier.

Imaginez maintenant que vous êtes plusieurs à vouloir traiter les dossiers de la pile. Lorsque vous en prenez un, il est assez évident que les autres ne pourront pas y accéder. Vous avez verrouillé le dossier. Et vous pouvez décider de ne pas accéder n'importe comment à la pile de dossiers.

Bref, la gestion des piles est un outil qui peut être fastidieux à programmer, mais c'est un exercice intéressant et incontournable en algorithmique. Comme ce n'est pas l'objet de cette page, mais que nous devons utiliser une pile, je vais employer les méthodes du module Queue de Python qui sont tout à fait adéquates pour nos besoins. Ah oui, en Python (et en anglais), on emploie "queue" pour file d'attente et par extension pour "pile" alors que "pile " se dit "stack". Il y a des différences algorithmiques entre une pile et une queue, mais on ne s'y attardera pas ici.

Donc vous trouverez dans mon code une méthode pour déposer un objet sur une pile, la méthode put(), et une méthode pour récupérer un objet, la méthode get(). Je créérai ma pile en utilisant la classe Queue, qui, par défaut, initialise une pile FIFO :

# Création d'une pile FIFO de taille indéfinie

pileAcq = Queue(maxsize=0)

Le paramètre maxsize = 0 indique que je ne fixe pas de taille définie à ma pile et qu'elle peut donc occupée toute la mémoire disponible.

La fonction attachée à chaque thread

Tant qu'il est vivant, chaque thread et donc chaque capteur, va procéder aux actions décrites ci-dessous. Le "tant qu'il est vivant" est matérialisé par la boucle indéfinie "while True". Le numéro du thread, la queue sur laquelle il doit écrire et la période d'acquisition sont passés en paramètres lors de la création du thread.

A chaque cycle d'acquisition déterminé par le paramètre periode, le thread:

Ce qui donne le code suivant :

def SimuCapteur(i,queue, periode):

TInit = 20.0

while True:

# création d'une nouvelle mesure

mesure = Mesure()

mesure.CapteurId = 'Capteur%d' % i

mesure.CapteurNum = i

mesure.Valeur = TInit*(1 + random.random()/2.)

mesure.Unit = "°C"

# écriture de la mesure dans l'unité d'acquisition

queue.put(mesure)

queue.task_done()

# attendre t secondes pour la prochaine mesure

time.sleep(periode)

Gestion des threads

Nous l'avons vu plus haut, c'est depuis le script principal (le processus) que l'on doit lancer les threads. Pour les besoins de ma simulation, je désire créer un certain nombre de threads, 10 dans mon cas, qui seront tous identiques. La création de ces "nbcapteurs" threads est assurée par ce code :

capteurs = []

for i in xrange(0,nbcapteurs):

    capteur = Thread(target=SimuCapteur, args=(i,pileAcq,PeriodeAcquisition))

    capteurs.append(capteur)

    capteurs[-1].setDaemon(True)

    capteurs[-1].start()

Voyons plus en détail ce qu'il fait. Par commodité, et parce qu'il est possible que je m'en serve plus tard, je vais créér une liste, nommée capteurs, des identifiants des threads que je vais lancer. Cela peut servir par exemple à vérifier leur activité ou à les tuer. Pour lancer mes threads, j'utilise une boucle qui, pour chacun d'entre eux :

Voilà, tous mes threads sont maintenant lancés et actifs. Il n'y a plus qu'à attendre les résultats...

La boucle d'acquisition et de simulation de la BDTR

Je commence par définir une liste vide, qui sera ma BDTR simulée :

BD = [] # liste des acquisitions

puis je lance le cycle d'acquisition avec une boucle qui sera exécutée tant que ce nombre d'acquisitions attendu ne sera pas atteint.

while AcqNum <=  NbAcq:

    # lecture de la pile d'acquisition tant qu'elle n'est pas vide

    record = BDTR()

    while not pileAcq.empty():

        mesure = Mesure()

        mesure = pileAcq.get()

        record.AcqNum = AcqNum

        record.Tableau[mesure.CapteurNum] = mesure.Valeur  

        record.Unit = mesure.Unit

    # ajout sur la liste BD

    BD.append(record)

    AcqNum += 1

    # attente de la prochaine acquisition

    time.sleep(PeriodeAcquisition)

Le contenu de cette bocule n'est pas très compliqué :

Les résultats

A la fin du cycle d'acquisition, c'est à dire quand le nombre de cycles prévu a été atteint, le script affiche le contenu de la BDTR simulée en indiquant pour chaque cycle, le numéro de cycle d'acquisition et les valeurs acquises par chaque capteur. Le code est :

for item in BD:

    print ('Acq:%2d %s %6.2f %6.2f %6.2f %6.2f %6.2f %6.2f %6.2f %6.2f %6.2f %6.2f' % (item.AcqNum, item.Unit, \

           item.Tableau[0], item.Tableau[1],item.Tableau[2],item.Tableau[3],\

           item.Tableau[4], item.Tableau[5],item.Tableau[6],item.Tableau[7],\

           item.Tableau[8], item.Tableau[9]))

Votre console Python devrait afficher quelque chose de ce genre :

Console python thread

Il ne reste plus qu'à calculer l'évolution de la température en fonction du temps, de la position sur la barre et de la température initiale pour obtenir une simulation un peu plus convaincante de l'expérience. Suite au prochain numéro...

Les scripts Python

Le script ThreadPython.py étudié dans cette page est disponible dans le package ThreadPython.zip


Contenu et design par Dominique Lefebvre - www.tangenteX.com mars 2017   Licence Creative Commons   Contact : PhysiqueX ou

Cette œuvre est mise à disposition selon les termes de la Licence Creative Commons Attribution - Pas d’Utilisation Commerciale - Pas de Modification 3.0 France.