paint-brush
Briser les axiomes dans l'exécution du programmepar@nekto0n
20,959 lectures
20,959 lectures

Briser les axiomes dans l'exécution du programme

par Nikita Vetoshkin9m2023/10/24
Read on Terminal Reader

Trop long; Pour lire

L'auteur, un ingénieur logiciel chevronné, partage un aperçu de leur parcours, du code séquentiel aux systèmes distribués. Ils soulignent que l’adoption de l’exécution non sérialisée, du multithread et de l’informatique distribuée peut conduire à une amélioration des performances et de la résilience. Même si cela introduit de la complexité, il s'agit d'un voyage de découverte et de capacités améliorées en matière de développement logiciel.
featured image - Briser les axiomes dans l'exécution du programme
Nikita Vetoshkin HackerNoon profile picture


Faire de nouvelles erreurs

Je suis ingénieur logiciel depuis environ 15 ans maintenant. Tout au long de ma carrière, j'ai beaucoup appris et appliqué ces apprentissages pour concevoir et mettre en œuvre (et parfois supprimer progressivement ou laisser tels quels) de nombreux systèmes distribués. En cours de route, j’ai commis de nombreuses erreurs et je continue à les commettre. Mais comme mon objectif principal était la fiabilité, j'ai repensé à mon expérience et à la communauté pour trouver des moyens de minimiser la fréquence des erreurs. Ma devise est : il faut absolument essayer de commettre de nouvelles erreurs (moins évidentes, plus sophistiquées). Faire une erreur, c'est bien - c'est ainsi qu'on apprend, en répétant - c'est triste et décourageant.


C'est probablement ce qui m'a toujours fasciné dans les mathématiques. Non seulement parce qu’il est élégant et concis, mais aussi parce que sa rigueur logique évite les erreurs. Cela vous oblige à réfléchir à votre contexte actuel, aux postulats et aux théorèmes sur lesquels vous pouvez vous appuyer. Suivre ces règles s'avère fructueux, vous obtenez le bon résultat. Il est vrai que l’informatique est une branche des mathématiques. Mais ce que nous pratiquons habituellement, c’est le génie logiciel, une chose très distincte. Nous appliquons les réalisations et les découvertes de l’informatique à la pratique, en tenant compte des contraintes de temps et des besoins de l’entreprise. Ce blog est une tentative d'appliquer le raisonnement semi-mathématique à la conception et à la mise en œuvre de programmes informatiques. Nous proposerons un modèle de différents régimes d'exécution fournissant un cadre permettant d'éviter de nombreuses erreurs de programmation.


Depuis des débuts modestes

Lorsque nous apprenons à programmer et faisons nos premiers pas timides (ou audacieux), nous commençons généralement par quelque chose de simple :


  • programmer des boucles, faire de l'arithmétique de base et imprimer les résultats dans un terminal
  • résoudre des problèmes mathématiques, probablement dans un environnement spécialisé comme MathCAD ou Mathematica


Nous acquérons de la mémoire musculaire, apprenons la syntaxe du langage et surtout nous changeons notre façon de penser et de raisonner. Nous apprenons à lire le code, à faire des hypothèses sur la façon dont il est exécuté. Nous ne commençons presque jamais par lire une norme linguistique et lisons attentivement sa section « Modèle de mémoire » - parce que nous ne sommes pas encore équipés pour les apprécier pleinement et les utiliser. Nous pratiquons les essais et les erreurs : nous introduisons des bugs logiques et arithmétiques dans nos premiers programmes. Ces erreurs nous apprennent à vérifier nos hypothèses : cet invariant de boucle est-il correct, pouvons-nous comparer l'index et la longueur des éléments du tableau de cette façon (où mettez-vous ce -1) ? Mais si nous ne voyons pas d’erreurs, nous internalisons souvent implicitement certaines erreurs. invariants le système nous impose et nous fournit.


A savoir celui-ci :


Les lignes de code sont toujours évaluées dans le même ordre (sérialisées).

Ce postulat nous permet de supposer que les propositions suivantes sont vraies (nous n'allons pas les prouver) :


  • l'ordre d'évaluation ne change pas entre les exécutions
  • les appels de fonction reviennent toujours


Les axiomes mathématiques permettent de dériver et de construire des structures plus grandes sur une base solide. En mathématiques, nous avons la géométrie euclidienne avec 4+1 postulats. Le dernier dit :

les lignes parallèles restent parallèles, elles ne se croisent ni ne divergent


Pendant des millénaires, les mathématiciens ont tenté de le prouver et de le déduire des quatre premiers. Il s'avère que ce n'est pas possible. Nous pouvons remplacer ce postulat des « lignes parallèles » par des alternatives et obtenir différents types de géométries (à savoir hyperboliques et elliptiques), qui ouvrent de nouvelles perspectives et s’avèrent applicables et utiles. Après tout, la surface de notre planète n'est pas plate et nous devons en tenir compte, par exemple dans les logiciels GPS et les itinéraires aériens.

Le besoin de changement

Mais avant cela, arrêtons-nous et posons les questions les plus techniques : pourquoi s'embêter ? Si le programme fait son travail, s’il est facile à prendre en charge, à maintenir et à évoluer, pourquoi devrions-nous abandonner en premier lieu cet invariant douillet d’exécution séquentielle prévisible ?


Je vois deux réponses. Le premier est la performance . Si nous pouvons faire en sorte que notre programme s'exécute deux fois plus vite ou de manière similaire - en nécessitant la moitié du matériel - c'est une réussite technique. Si en utilisant la même quantité de ressources informatiques, nous pouvons traiter 2x (ou 3, 4, 5, 10x) de données, cela peut ouvrir des applications complètement nouvelles du même programme. Il peut fonctionner sur un téléphone mobile dans votre poche au lieu d'un serveur. Parfois, nous pouvons accélérer en appliquant des algorithmes intelligents ou en réécrivant dans un langage plus performant. Ce sont nos premières options à explorer, oui. Mais ils ont une limite. L'architecture bat presque toujours la mise en œuvre. La loi de Moor ne fonctionne pas très bien ces derniers temps, les performances d'un seul processeur augmentent lentement, les performances de la RAM (latence principalement) sont à la traîne. Alors, naturellement, les ingénieurs ont commencé à chercher d’autres options.


La deuxième considération est la fiabilité . La nature est chaotique, la deuxième loi de la thermodynamique s'oppose constamment à tout ce qui est précis, séquentiel et reproductible. Les bits se retournent, les matériaux se dégradent, l'alimentation diminue, les fils sont coupés, empêchant l'exécution de nos programmes. Garder une abstraction séquentielle et reproductible devient une tâche difficile. Si nos programmes pouvaient survivre aux pannes logicielles et matérielles, nous pourrions fournir des services présentant un avantage commercial concurrentiel. C'est une autre tâche d'ingénierie que nous pouvons commencer à aborder.


Equipés de cet objectif, nous pouvons commencer à expérimenter des approches non sérialisées.


Fils d'exécution

Regardons ce morceau de pseudo-code :


```

def fetch_coordinates(poi: str) -> Point:

def find_pois(center: Point, distance: int) -> List[str]:

def get_my_location() -> Point:


def fetch_coordinates(p) - Point:

def main():

me = get_my_location()

for point in find_pois(me, 500):
loc = fetch_coordinates(point)
sys.stdout.write(f“Name: {point} is at x={loc.x} y={loc.y}”)

Nous pouvons lire le code de haut en bas et supposer raisonnablement que la fonction `find_pois` sera appelée après `get_my_location`. Et nous récupérerons et renverrons les coordonnées du premier POI après avoir récupéré le suivant. Ces hypothèses sont correctes et permettent de construire un modèle mental, raisonnant sur le programme.


Imaginons que nous puissions faire en sorte que notre code s'exécute de manière non séquentielle. Il existe de nombreuses façons de procéder syntaxiquement. Nous allons ignorer les expériences de réorganisation des instructions (c'est ce que font les compilateurs et les processeurs modernes) et étendre notre langage afin de pouvoir exprimer un nouveau régime d'exécution de fonctions : en même temps ou même en parallèle par rapport aux autres fonctions. En reformulant, nous devons introduire plusieurs threads d’exécution. Les fonctions de notre programme s'exécutent dans un environnement spécifique (conçu et maintenu par le système d'exploitation). Nous nous intéressons actuellement à la mémoire virtuelle adressable et à un thread - une unité de planification, quelque chose qui peut être exécuté par un processeur.


Les threads existent en différentes saveurs : un fil POSIX, un fil vert, une coroutine, une goroutine. Les détails diffèrent grandement, mais cela se résume à quelque chose qui peut être exécuté. Si plusieurs fonctions peuvent s'exécuter simultanément, chacune a besoin de sa propre unité de planification. C'est de là que vient le multithreading, au lieu d'un, nous avons plusieurs threads d'exécution. Certains environnements (MPI) et langages peuvent créer des threads implicitement, mais nous devons généralement le faire explicitement en utilisant `pthread_create` en C, des classes de module `threading` en Python ou une simple instruction `go` en Go. Avec quelques précautions, nous pouvons faire en sorte que le même code s'exécute principalement en parallèle :


 def fetch_coordinates(poi, results, idx) -> None: … results[idx] = poi def main(): me = get_my_location() points = find_pois(me, 500) results = [None] * len(points) # Reserve space for each result threads = [] for i, point in enumerate(find_pois(me, 500)): # i - index for result thr = threading.Thread(target=fetch_coordinates, args=(poi, results, i)) thr.start() threads.append(thr) for thr in threads: thr.wait() for point, result in zip(points, results): sys.stdout.write(f“Name: {poi} is at x={loc.x} y={loc.y}”)


Nous avons atteint notre objectif de performances : notre programme peut fonctionner sur plusieurs processeurs et évoluer à mesure que le nombre de cœurs augmente et se termine plus rapidement. Prochaine question d'ingénierie qu'il faut se poser : à quel prix ?

Nous avons intentionnellement renoncé à une exécution sérialisée et prévisible. Il y a pas de bijection entre une fonction + un moment précis et les données. À chaque instant, il existe toujours un seul mappage entre une fonction en cours d'exécution et ses données :


Plusieurs fonctions fonctionnent désormais simultanément avec les données :


La conséquence suivante est qu'une fonction peut se terminer avant une autre cette fois, la prochaine fois ce peut être l'inverse. Ce nouveau régime d'exécution conduit à des courses aux données : lorsque des fonctions concurrentes travaillent avec des données, cela signifie que l'ordre des opérations appliquées aux données n'est pas défini. Nous commençons à être confrontés à des courses aux données et apprenons à y faire face en utilisant :

  • sections critiques : mutex (et spinlocks)
  • algorithmes sans verrouillage (la forme la plus simple se trouve dans l'extrait ci-dessus)
  • outils de détection de courses
  • etc.


À ce stade, nous découvrons au moins deux choses. Premièrement, il existe plusieurs façons d’accéder aux données. Certaines données sont locale (par exemple, des variables de portée de fonction) et nous seuls pouvons le voir (et y accéder) et il est donc toujours dans l'état dans lequel nous l'avons laissé. Cependant, certaines données sont partagées ou télécommande . Il réside toujours dans la mémoire de notre processus, mais nous utilisons des moyens spéciaux pour y accéder et il peut être désynchronisé. Dans certains cas, pour travailler avec, nous le copions dans notre mémoire locale pour éviter les courses de données - c'est pourquoi == .cloner() ==est populaire dans Rust.


Lorsque nous poursuivons ce raisonnement, d’autres techniques telles que le stockage local par thread apparaissent naturellement. Nous venons d'acquérir un nouveau gadget dans notre boîte à outils de programmation, élargissant ce que nous pouvons réaliser en créant des logiciels.


Cependant, il existe un invariant sur lequel nous pouvons toujours compter. Lorsque nous recherchons des données partagées (à distance) à partir d'un fil de discussion, nous les obtenons toujours. Il n’existe aucune situation dans laquelle une partie de la mémoire n’est pas disponible. Le système d'exploitation mettra fin à tous les participants (threads) en tuant le processus en cas de dysfonctionnement de la région de mémoire physique de sauvegarde. La même chose s'applique à « notre » thread si nous avons verrouillé un mutex, nous ne pouvons en aucun cas perdre le verrou et devons arrêter ce que nous faisons immédiatement. Nous pouvons compter sur cet invariant (appliqué par le système d'exploitation et le matériel moderne) selon lequel tous les participants sont morts ou vivants. Tous partagent le sort : si le processus (MOO), le système d'exploitation (bug du noyau) ou le matériel rencontre un problème, tous nos threads cesseront d'exister ensemble sans effets secondaires externes.


Inventer un processus

Une chose importante à noter. Comment avons-nous fait ce premier pas en introduisant les fils de discussion ? Nous nous sommes séparés, avons bifurqué. Au lieu d’avoir une seule unité de planification, nous en avons introduit plusieurs. Continuons à appliquer cette approche de non-partage et voyons comment cela se passe. Cette fois, nous copions la mémoire virtuelle du processus. C'est ce qu'on appelle générer un processus . Nous pouvons exécuter une autre instance de notre programme ou démarrer un autre utilitaire existant. C'est une excellente approche pour :

  • réutiliser un autre code avec des limites strictes
  • exécuter du code non fiable, en l'isolant de notre propre mémoire


Presque tout == navigateurs modernes == fonctionnent de cette façon, afin qu'ils soient capables d'exécuter du code exécutable Javascript non fiable téléchargé depuis Internet et de le terminer de manière fiable lorsque vous fermez un onglet sans faire tomber l'ensemble de l'application.

Il s'agit encore d'un autre régime d'exécution que nous avons découvert en abandonnant l'invariant du destin partagé , en ne partageant plus la mémoire virtuelle et en en faisant une copie. Les copies ne sont pas gratuites :

  • Le système d'exploitation doit gérer les structures de données liées à la mémoire (pour maintenir le mappage virtuel -> physique)
  • Certains bits auraient pu être partagés et les processus consomment donc de la mémoire supplémentaire



Se libérer

Pourquoi s'arrêter ici ? Explorons quels autres supports pouvons-nous copier et distribuer notre programme. Mais pourquoi procéder à la distribution en premier lieu ? Dans de nombreux cas, les tâches à accomplir peuvent être résolues à l’aide d’une seule machine.


Nous devons être distribués pour échapper au destin partagé principes afin que notre logiciel cesse de dépendre des problèmes inévitables rencontrés par les couches sous-jacentes.


Pour n'en nommer que quelques-uns :

  • Mises à niveau du système d'exploitation : nous devons de temps en temps redémarrer nos machines

  • Pannes matérielles : elles surviennent plus souvent que nous le souhaiterions

  • Pannes externes : les pannes de courant et de réseau sont monnaie courante.


Si nous copions un système d'exploitation, nous appelons cela une machine virtuelle et pouvons exécuter les programmes des clients sur une machine physique et y créer une énorme activité cloud. Si nous prenons deux ordinateurs ou plus et exécutons nos programmes sur chacun, notre programme peut survivre même à une panne matérielle, en fournissant un service 24h/24 et 7j/7 et en obtenant un avantage concurrentiel. Il y a longtemps, les grandes entreprises sont allées encore plus loin et les géants de l'Internet en exécutent désormais des copies dans différents centres de données et même sur des continents, rendant ainsi un programme résilient à un typhon ou à une simple panne de courant.


Mais cette indépendance a un prix : les anciens invariants ne sont pas appliqués, nous sommes livrés à nous-mêmes. Ne vous inquiétez pas, nous ne sommes pas les premiers. Il existe de nombreuses techniques, outils et services pour nous aider.


Points à retenir

Nous venons d'acquérir la capacité de raisonner sur les systèmes et leurs régimes d'exécution respectifs. Dans tout système à grande échelle, la plupart des éléments sont séquentiels et sans état, de nombreux composants sont multithread avec des types de mémoire et des hiérarchies, tous maintenus ensemble par un mélange de éléments véritablement distribués :


Le but est de pouvoir distinguer où nous en sommes actuellement, quels invariants détiennent et agir (modifier/concevoir) en conséquence. Nous avons mis en évidence le raisonnement de base, transformant les « inconnues inconnues » en « inconnues connues ». Ne le prenez pas à la légère, c’est un progrès significatif.