Démystification : processus & threads

On trouve tout et son contraire sur ce sujet, que ce soit sur Internet, dans les discussions entre développeurs, et même sur des supports de cours. Clarifions un peu tout ça 🙂

Un processus désigne un programme en cours au sein du système d’exploitation. C’est ce qui est créé quand on lance un exécutable (typiquement, un fichier « .exe » dans le monde Windows). Un processus dispose de son propre espace mémoire, et en tant quel tel, il n’exécute aucune instruction.

Un thread est un fil d’exécution chargé de faire exécuter des instructions par un processeur. Il y a toujours un thread, souvent dénommé « principal », de créé lorsqu’un processus est créé. C’est ce thread de départ qui va commencer à exécuter le programme à partir de son point d’entrée (typiquement, une fonction « main » dans un programme console écrit en C/C++).

Maintenant que les bases sont posées, multithread, parallélisme, multiprocess… qu’en est-il de tout ça ? C’est parti 😉

La plus grande confusion autour du sujet est, à mon sens, le mélange entre les notions de concurrence et de parallélisme. On voit beaucoup l’abus de langage qui consiste à dire « j’ai plusieurs threads, c’est parallèle », mais c’est plus complexe que ça, et parfois faux.
Revenons un peu (beaucoup) en arrière. Je bidouille sur le PC sous Windows 95 de mon grand-père. J’ouvre Microsoft Word, le curseur clignote. J’ai le lecteur vidéo dans un coin sur lequel se lit en boucle une courte vidéo avec un singe perché sur un arbre. Je peux bouger le pointeur de la souris et naviguer dans l’explorateur de fichiers. La machine semble faire tout ça « en parallèle », n’est-ce pas ?

Pourtant, ce vieux coucou était équipé d’un processeur unique, avec un et un seul fil d’exécution.
Dans les années 2000 sortaient encore des processeurs flambants neufs et qui n’étaient pas encore à coeurs multiples, et pourtant, on pouvait les utiliser pour écouter de la musique tout en prenant des notes et en surveillant l’heure dans un coin de l’écran.
C’est parce que le processeur passe de processus en processus, et de thread en thread, leur accordant un peu de son temps, exécutant les prochaines instructions qu’ils souhaitent réaliser, avant de les mettre en pause et passer à quelqu’un d’autre. Quand un thread est mis en pause par le système d’exploitation, on dit qu’il est préempté. Le passage d’un thread à un autre est quant à lui appelé context switch.
Cette alternance se fait à une vitesse très élevée pour un être humain : on parle là de quelques millisecondes, tout au plus. Mais c’est ce qui permet à la machine de faire fonctionner plusieurs programmes, et pour nous êtres humains, cela semble se faire « en même temps ». En réalité la machine accorde un peu de son temps à chacun, basculant entre eux à toute vitesse.
Sous Windows, c’est le scheduler qui a ce rôle.

C’est l’aspect concurrentiel des threads. Sur un ordinateur classique, de par le grand nombre de variables impliquées, il est virtuellement impossible d’anticiper dans quel ordre ils seront ordonnancés. Dans des projets de l’embarqué, au contraire, ils sont généralement ordonnancés d’une manière strictement prévue à l’avance.

Donc, nous y voilà : un processeur peut travailler sur plusieurs tâches en basculant entre elles, et en calculant petit bout par petit bout. Du point de vue d’un être humain, on peut réaliser plusieurs choses « en même temps », et pourtant il s’agit seulement de concurrentiel et non de parallèle.

Et que se passerait-il si nous avions plusieurs processeurs (c’est possible avec les carte-mères multi-socket) ou un seul processeur divisé en plusieurs « cœurs » ? Eh bien, chaque processeur serait capable, de la même façon, de basculer entre des tâches différentes petit bout par petit bout… sauf qu’à un instant T donné, chacun serait en train d’exécuter une instruction sur sa tâche active. Et ça, c’est du parallèle.

Maintenant que cette nuance est clarifiée, regardons de plus prêt les processus et les threads. Mis à part des cas très spécifiques comme par exemple l’interpréteur CPython (shame!), le parallélisme peut être atteint en utilisant des processus comme en utilisant des threads. Quelle option choisir dans ce cas ?
D’une manière générale, privilégiez toujours les threads. Les threads sont lancés au sein d’une processus unique et peuvent non seulement communiquer entre eux mais aussi accéder au même espace en mémoire sans faire de pirouettes.
Les processus, quant à eux, sont indépendants, sont plus longs à lancer (il s’agit pour rappel de lancer un exécutable), et ont leur propre espace mémoire. Il est possible de communiquer entre processus, mais cela se fait indirectement par le biais de mécanismes fournis par le système d’exploitation.

Alors, maintenant, il faudrait peut-être parler de SIMD…

Opinion : ce qui me manque quand j’utilise Git

Git est très probablement la solution de SCM (Source Control Management) la plus répandue de nos jours. C’est assez simple à comprendre : il y a tous les outils, dont de nombreux en open-source, pour à peu près tout faire, et ce gratuitement (pour peu qu’on dispose des machines et des compétences nécessaires pour la mise en place).

La plupart des projets sur lesquels j’ai travaillé, et travaille encore, se basaient et se basent sur Git.
Et j’aime beaucoup Git. D’ailleurs je suis un de ces barbus qui utilisent essentiellement le terminal ou le Git bash pour travailler avec !
Mais, mais mais mais… pour avoir utilisé Perforce par le passé, il y a quelque chose qui me manquera encore et toujours avec Git.

Git est dit « décentralisé » : on travaille en local, et une fois satisfait.e des modifications, on les pousse vers le dépôt distant. Un dépôt local complet peut d’ailleurs permettre de reconstruire le dépôt distant si le serveur a été détruit. Et tant qu’on travaille avec des fichiers de texte, comme du code source ou des fichiers de configuration, ça fonctionne très bien. Et je glisse ça là : fetch + rebase > pull.
Perforce est dit « centralisé » : on travaille avec une architecture client-serveur plus classique, auprès du dépôt (bien sûr, il est possible de gérer des proxys pour se prémunir en cas de problème avec ce dernier). Ce qui change la vie avec un SCM centralisé, c’est qu’aussitôt des modifications faites en local, elles sont envoyées au serveur. Elles ne sont pas encore déployées en production, mais sont répliquées, ce qui veut dire que la machine local peut brûler : rien ne sera perdu. Et, même si les modifications ne sont pas en production, il est possible pour un.e collègue de les récupérer, en quelques instants, via le numéro de la « changelist » (qui est un peu l’équivalent du commit de Git chez Perforce).

Et voilà, c’est tout, c’est tout bête, mais c’est tellement sécurisant.
Et très pratique pour se partager des modifications au sein d’une équipe.
Sur Git, on peut bien sûr assurer nos arrières et partager des modifications en poussant nos modifications sur une branche de travail. Mais voilà, il faut les pousser. Et parfois, un accident peut arriver avant qu’on ne le fasse (on teste généralement un minimum son code avant de pousser, même sur une branche de travail temporaire).

En bonus, si l’on travaille avec des fichiers binaires, Perforce dispose d’un ensemble d’outil pour se faciliter la vie, notamment un système de verrou permettant d’empêcher d’autres personnes de venir modifier le fichier.

Non-opinion : comment aborder la programmation défensive ?

Un sujet qui revient régulièrement quand on manipule des données issues d’une source extérieure au programme (comme un fichier local, accessible sur un partage réseau, ou encore des données stockées sur une base de données…) est la gestion des erreurs.

La gestion des erreurs est essentielle pour réagir en fonction du motif d’échec, sachant que chaque motif peut entraîner des réactions différentes : impossibilité de localiser la ressource, d’y accéder en lecture, de la parcourir intégralement, données non conformes au format attendu, etc. Les possibilités sont – malheureusement – nombreuses.
Dans la plupart des cas, il est d’ailleurs impossible d’être absolument et totalement robuste avec des manipulations telles que celles sur les fichiers.

C’est un vaste sujet mais je vais ici m’attarder sur ce qui se passe après un échec. Admettons qu’une ressource n’ait pas été lue, nous laissant avec une donnée dite « nulle » (que ce soit un Nil, un None, un NULL, un nullptr, une structure ou un objet avec des membres aux valeurs par défaut, etc… peu importe).
Comment gérer cela dans un programme ?

Je vais commencer par illustrer la mauvaise façon de procéder (cela peut paraître un peu direct, mais c’est un sujet où il n’y a pas vraiment de débat, juste des bonnes et des mauvaises pratiques). Nous avons un algorithme de calcul scientifique qui doit, au démarrage, récupérer une structure de données contenant des informations sur un climat météorologique. Pour cause d’erreur, cette structure est nulle. L’exécution du programme est néanmoins poursuivie, l’algorithme reçoit cette structure, elle est passée de fonction en fonction, toujours nulle, et chaque fonction vérifie l’état de ladite structure.
Donc, chaque fonction implémente une fonction si, n’effectuant que les calculs ne nécessitant pas cette structure lorsqu’elle est nulle… c’est-à-dire parfois aucun.
Le programme fait même appel aux GPUs disponibles dans la machine afin de paralléliser au maximum ces calculs scientifiques. Le processeur est actif, les puces graphiques sont actives, et à la fin, les résultats sont obtenus : une belle grille de 0.
Du temps de perdu, de l’électricité consommée pour rien, et un programme parsemé de fonctions si qui traitent toutes le même cas.
Nous sommes ici dans le cas typique d’une structure de données absolument primordiale pour la bonne exécution du programme. Les fonctions qui en ont besoin devraient donc toujours recevoir des données exploitables et non une structure nulle.

Alors comment faire ?
La réponse vient généralement assez naturellement. Ne vérifier qu’une fois l’état de cette structure, et surtout, le plus tôt possible. Dès que la tentative de lecture a été faite, en cas d’erreur, le programme est interrompu. Cela peut se faire par le biais d’une assertion, une exception, ou d’une boite de dialogue en avertissant l’utilisateur si l’application dispose d’une interface graphique.
Si les données ont été lues avec succès, le programme peut continuer, et les portions de code nécessitant cette structure n’auront pas à vérifier sa présence.

Bien évidemment, cela ne s’applique pas dans le cadre d’une application jugée critique et qui pourrait être exposée, par exemple, à des bitflips à cause de conditions extrêmes (température, pression, accélération…), typiquement dans le monde de l’embarqué dans les transports, où il est alors indispensable d’implémenter des mécanismes de contrôle de l’intégrité des données et des robustesses aux valeurs hors des intervalles prévus.
Ne cherchons pas la petite bête 🙂

Opinion : les tests servent-ils à contrôler que l’implémentation est correcte ?

Une méthode de travail semble être à la mode dans certains secteurs de nos jours : le développement piloté par les tests. Concrètement, cela consiste à élaborer en premier lieu des tests, munis d’entrées fournies et des sorties attendues, avant de passer à l’implémentation.
Mais même si l’on ne « pilote » pas son développement par les tests, on peut tout à fait en élaborer ; c’est d’ailleurs encouragé.

C’est peut-être une mode, mais il y a des secteurs – comme l’avionique – où les tests font partie du quotidien des développeurs depuis longtemps, très longtemps. Bref !

La question est la suivante : les tests servent-ils à contrôler que l’implémentation est faite sans erreur ?

Il faut bien sûr nuancer, mais en très résumée, mon opinion très personnelle se présente en 2 points.

  • on peut tout à fait élaborer des tests après l’implémentation, et c’est très bien ainsi.
  • non, les tests ne servent pas vraiment à contrôler que l’implémentation est correcte.

Commençons par ce second point. Quand on parle de tests, entre développeurs, on fait généralement référence à des tests qu’on qualifie dans l’avionique de « tests bas niveau ». Un petit bout de code de test, avec des entrées et des sorties, appelle un petit bout de code de l’application, pour vérifier que les résultats sont corrects.
C’est à différencier des tests dits « de haut niveau », qui consistent plutôt à préparer l’ensemble des entrées, faire tourner l’application, et contrôler l’ensemble des sorties.

Cette nuance est très importante : il y a peu de chances que vous ayez besoin de quelqu’un pour vérifier si vous avez implémenté une petite fonction de la bonne façon. En revanche, il y a très certainement des chercheurs qui sauront vous dire si le programme s’est comporté correctement en ayant subi le scénario de test qu’ils auront préalablement préparé dans un simulateur spécialisé.
D’ailleurs, pour que les tests en général aient un rôle de contrôle de l’implémentation, il faut qu’ils soient élaborés par une personne qui n’est pas celle qui a réalité ladite implémentation. Si l’on écrit dans la même journée les tests et le code, il y a de bonnes chances qu’on reproduise une éventuelle erreur des deux côtés, la faisant passer inaperçue. Il est d’ailleurs très courant que cette méthode de travail soit obligatoire dans l’avionique.

Je considère donc que oui, les tests de haut niveau peuvent servir à contrôler, par l’intermédiaire de personnes hautement qualifiées sur la partie « métier », qu’un programme (ou un pan du programme) est implémenté correctement.
Mais en dehors de cette particularité, les tests, de haut comme de bas niveau, ont surtout un rôle de protection plus que de contrôle : ils nous permettent de détecter les régressions.

Ce qui m’amène donc au premier point. Implémenter un algorithme, tester soi-même le programme, contrôler les entrées et sorties… cela permet souvent de détecter bien des erreurs d’implémentation, sans même passer par des cas de test. Et, quand le sujet est bien frais, qu’on est encore plongé dedans, c’est assez peu coûteux de déboguer et détecter où se situe le problème. Est-ce vraiment strictement nécessaire de commencer par le test ?
En revanche, des semaines, des mois, voire des années plus tard, lorsqu’une autre partie du code sera altérée (probablement par quelqu’un d’autre), il sera beaucoup plus difficile de détecter les éventuels effets de bord de cette modification sur notre algorithme originel. Et il sera bien plus difficile de déboguer à travers l’application pour finalement aboutir au point de rupture.
Les tests, eux, nous permettent de nous prémunir de ce genre de situation. Aussitôt les modifications effectuées, tout effet de bord sera détecté par le biais de l’exécution des tests, ce qui permettra de détecter immédiatement quelles sont les fonctionnalités qui ont régressé.

Un bon test peut tout à fait vérifier, par des cas bien identifiés que l’implémentation est correcte. C’est généralement le but initial.
Mais un bon test va surtout nous assurer qu’elle va le rester durablement.