Arma: Ordonnanceur

Une bonne nouvelle, c’est que si tu lis cet article tu t’intéresses vraiment à comment fonctionne Arma3, et que tu vas donc acquérir encore du niveau. Je ne cesserais de t’encourager à approfondir tes connaissances pour t’améliorer, et à partager les tiennes.

Dans cet article, je vais te parler de l’ordonnanceur(scheduler), qui est une des pièces principales du moteur Real Virtuality d’ARMA. Tu dois comprendre comment il fonctionne, le maitriser un tant soit peu, pour architecturer mieux ton code. C’est la base d’un bon, voir excellent code ! Bien commençons maintenant 🙂

Le rôle de l’ordonnanceur

Peut être que tu es déjà familié avec ce terme à travers des notions de programmation système. Le rôle de l’ordonnanceur est de prioriser et partager le temps cpu en fonction des différents programmes/ tâches(thread) qui doivent être exécutés.

L’ordonnanceur va donc à intervalle prioriser l’exécution d’une tâche, puis suspendre son exécution, puis exécuter une autre tâche, puis suspendre celle-ci, puis reprendre l’exécution d’une autre tâche, etc selon un mode opératoire qui normalement permet de garantir à l’ensemble des différentes tâches d’avoir un temps d’exécution équitable par rapport à leurs besoins, et finalement à l’ensemble de ton programme de fonctionner.

Tu as donc compris le caractère critique de ce composant, et l’impact qu’il peut avoir sur le bon fonctionnement de ton programme (..)

Introduction

Tout d’abord, ton programme par défaut s’exécute dans une VM et ne suit qu’un seul et unique fil d’exécution depuis le début de ton fichier init.sqf jusqu’à la fin. Cela signifie qu’aucune portion du programme ne s’exécute de façon parallélisée. Ton programme s’exécute de façon linéaire de haut vers le bas. L’exécution des commandes et les résultats se produisent donc les uns après les autres, jusqu’au point de sortie, c’est à dire la dernière commande du fichier.

diag_log "debut du programme";
diag_log "ligne1";
diag_log "ligne2";
diag_log "ligne3";
diag_log "fin du programme";

C’est la façon la plus basique de développer sous ARMA, et qui convient parfaitement pour réaliser des tests, ou de simples programmes, mais qui ne suffit plus quant on veut s’attaquer aux choses sérieuses.

Tu as donc dû déjà atteindre ces limites. La première étant la taille de ton code qui devient gros, voir très gros, et qui ressemble à un gros bordel. Il est donc temps d’agir et d’opérer la première opération de transformation en découpant ton code de façon logique et fonctionnelle en utilisant deux commandes: call, spawn.

Les commandes call et spawn

Ces 2 commandes servent à exécuter du code qu’idéalement tu vas placer dans des fichiers à l’extérieur du fichier init.sqf.

Le bénéfice direct que tu vas en tirer est que déjà ça va être plus propre, plus facile à relire et optimiser. Tu vas factoriser ton code, car tu pourras re faire appel à ces mêmes fonctions à d’autres endroits.

Tu ne vas plus avoir besoin de faire des copiés x fois du même code, et donc fixer les mêmes bugs à x endroit différents. Alors si tu ne le fais pas déjà, il faut vraiment que tu t’y mettes.

Tu peux aussi si tu le souhaites (mais ça n’est pas obligatoire), déclarer tes fonctions comme le prévoit BIS dans les fichiers de config, ce qui permet de les retrouver dans l’éditeur, et de définir des protections pour l’exécution distante. Je reviendrais précisément sur ce point dans un futur article concernant les remoteExec.

Revenons maintenant à nos 2 commandes. Tout d’abord, parlons de la commande call. La commande call comme son nom l’indique appelle simplement une fonction et lui passe des paramètres.

fnc_add2 = {
    _this + 2;
};

diag_log "before function";
diag_log str(2 call fnc_add2); // return 4
diag_log "after function";

La commande call est synchrone, ce qui signifie que le fil d’exécution initial du programme ne se poursuit qu’une fois que la commande call a terminé son exécution et retourné son résultat comme le montre l’exemple ci-dessus.

Il faut donc retenir que le call est en fait la continuité du fil d’exécution initial même si le code se trouve à un autre endroit, il est en fait exécuté de façon totalement linéaire. La commande call sert à produire un résultat.

La commande spawn appelle également une fonction et passe également des paramètres mais contrairement au call, cette commande est asynchrone et ne produit pas de résultat.

fnc_add2 = {
    _this + 2;
};

diag_log "before function";
diag_log str(2 spawn fnc_add2); // return handler
diag_log "after function";

La commande spawn n’interrompe pas le fil d’exécution qui va se poursuivre immédiatement dès que le spawn va être déclenché. La commande spawn crée en fait un nouveau fil d’exécution en parallèle (thread), et renvoie au fil d’exécution l’ayant appelé un handler de type SCRIPT qui va permettre d’opérer sur ce thread. Sache que BIS a protégé la valeur du handler, et qu’il n’est donc possible dans le jeux de récupérer la véritable valeure du handler, mais seulement et uniquement une chaine de caractère générique « script ».

Du fait de son mode de fonctionnement, les threads crées par la commande spawn sont toujours exécutés en mode schédulé. Ce qui signifie que dès la création, le thread est placé dans la queue de l’ordonnanceur qui préempte ou pas son exécution.

L’ordonnanceur gère beaucoup mieux le temps cpu donné à chacun de tes scripts, que tu ne pourras le faire en pensant micro optimisation. Il faut donc après avoir pensé fonction, que tu re penses ton code en fil d’exécution et que tu découpes logiquement ton programme en plusieurs parties.

Chacune des parties doit avoir un rôle fonctionnel majeur. Tu peux même imbriquer dans des threads d’autres threads.

Un exemple de découpage pourrait être le suivant:

| fil exécution principal
|___ fil exécution secondaire concernant les joueurs
|______ fil exécution tertiaire concernant l'inventaire du jouer
|___ fil exécution secondaire concernant les AI
|___ fil exécution secondaire concernant les scores
|___ fil exécution secondaire concernant les objectifs
etc...

L’intérêt direct de découper ton programme de cette façon est la robustesse de ton programme. Un fil d’exécution qui se plante, n’entraine pas le plantage des autres fils d’exécution. Si tu as fais correctement les choses, cela signifie que le plantage reste compartimenté. A ce titre, je te rappelle qu’une bonne pratique est notamment de proscrire au maximum l’usage des variables globales ce qui permet d’avoir une étanchéité parfaite entre les threads/fonctions. Le deuxième intérêt est que l’ordonnanceur si tu l’utilises correctement va garantir que ton cpu sert tout le temps à faire les bonnes choses.

Fonctionnement de l’ordonnanceur

Maintenant que tu as une meilleur compréhension globale, on va regarder de plus prêt comment l’ordonnanceur fonctionne. Lis scrupuleusement cette partie, car je corrige un bon nombre d’erreurs qui ont été faites sur ce point et qui aboutissent finalement à des erreurs.

Ton ordonnanceur possède une liste de thread que tu peux visualiser en utilisant la commande diag_activeSQFScripts. Pour chaque frame (fps), ton ordonnanceur a ~3ms en tout et pour tout, de temps cpu à distribuer à l’ensemble des fils d’exécutions (y compris le principal).

Pour se faire, l’ordonnanceur utilise un algo de type round robbin:

  • A un instant T, l’ordonnanceur ne peut exécuter qu’un seul et unique thread
  • Les nouveaux threads sont placés en fin de sa liste
  • Un thread existant ne peut pas consommer plus, que ce qui reste des 3ms allouées pour l’ensemble des threads
  • Dès que le thread est terminé, il est sorti de sa liste
  • Dès que les 3ms sont consommées, le thread en cours d’exécution est suspendu
  • Dès que le thread est suspendu, il est placé en fin de liste

Si tu suis bien, tu dois en, arriver à ce constat. Quand on multiplie le nombre de fils d’exécution, on a de moins en moins de chance qu’un thread s’exécute à chaque frame, car la liste des threads se rallonge et seul les premiers sont servis.

Déjà cela montre que tu as bien compris le sujet, et oui à un instant t, et directement lié à l’implémentation de l’ordonnancement sous ARMA, tous les threads en début de liste ont plus de chances d’être exécuté dans la prochaine frame que ceux en fin de liste.

A titre d’exemple, on peut d’ailleurs faire le calcul de la façon suivante:

- ton thread1 a besoin de 15 ms de temps cpu (3,1,1,3,3,3,1)
- ton thread2 a besoin de 5 ms de temps cpu (2)
- ton thread3 a besoin de 2ms de temps cpu ()

frame1: thread1
frame2: thread2
frame3: thread3(fin du thread)
frame3: thread1 (1ms)
frame4: thread2 (fin du thread)
frame4: thread1 (1ms)
frame5: thread1 
frame6: thread1 
frame7: thread1 
frame8: thread1 (fin du thread)

Comment faire alors pour prioriser l’exécution d’un thread ?

Déjà si tu te poses cette question c’est que ça sent le sapin 🙂 Le rôle des threads sous ARMA n’est pas de produire un résultat immédiat comme le ferait un call. Si tu as besoin de prioriser l’exécution d’un code pour produire un effet visuel par exemple, tu pourras regarder un peu plus bas la partie concernant le mode non schédulé.

Lorsque tu utilises des threads, il faut apprendre à restituer du temps cpu à ton ordonnanceur; Je sais, cette logique n’est pas naturelle et la majeur partie des codeurs ne la comprenne même pas et appliquent l’inverse en pensant qu’il faut feeder à outrance son code au détriment de l’ordonnanceur.

Regardons y un peu de plus prêt:

private _start = diag_tickTime;
_start spawn {
    while { true } do {
        diag_log format ["thread1: %1 frame: %2", (diag_tickTime - _this) * 1000, diag_frameNo];
    };
};

_start spawn {
    while { true } do {
        diag_log format ["thread2: %1 frame: %2", (diag_tickTime - _this) * 1000, diag_frameNo];
    };
};

Comme tu le vois avec ce simple bout de code, le thread1 va manger le temps cpu de la frame1. Puis le thread2 va manger le temps cpu de la frame2. Puis le thread1 va manger le temps cpu de la frame3, et ainsi suite.

Peut être as tu déjà compris ce qui se passe ? Une idée ? C’est simple quand tu construis ton code de cette façon, tu codes comme un bourrin ! Car le rôle de l’ordonnanceur est de garantir que ton cpu est toujours utilisé pour des choses utiles !

En fait, est-ce vraiment utile de brute forcé son processeur de commandes qui pourraient être exécuter de façon beaucoup plus lente. Cette logique est simple, arrête de lancer en série des commandes qui ne sont pas critique, libère du temps cpu que tu redonnes à ton ordonnanceur pour autre chose.

Un exemple de restitution du temps via la commande sleep, uisleep, waituntil.

private _start = diag_tickTime;
_start spawn {
    while { true } do {
        diag_log format ["thread1: %1 frame: %2", (diag_tickTime - _this) * 1000, diag_frameNo];
        sleep 0.01;
    };
};

_start spawn {
    while { true } do {
        diag_log format ["thread2: %1 frame: %2", (diag_tickTime - _this) * 1000, diag_frameNo];
        sleep 0.01;
    };
};

Nous n’avons plus maintenant 1 thread qui s’exécute pendant 1 frame, mais 2 threads qui s’exécutent pendant 1 frame, et il reste encore du temps cpu pour faire autre chose. Lorsque l’ordonnanceur rencontre une instruction sleep et que la condition de réveil n’est pas encore atteinte, il déplace le script à la fin de sa liste pour une exécution ultérieure et exécute le thread suivant.

Plus tu respecteras ce principe, plus l’exécution de tes scripts sera fluide et parallélisé. Malheureusement tu ne peux pas maitriser le travail des autres développeurs / moddeurs qui continuent à bourriner et impacter ton code (..)

Voila c’est à peu prêt tout pour le mode schédulé, maintenant parlons un peu du mode non schédulé.

Le mode sans ordonnanceur ou le à la zeub du développeur

Je vais maintenant te parler un peu du mode sans ordonnanceur. On a vu précédement du crade maintenant on va aller dans le vraiment dirty poilu. Ce mode là est directement lié aux premières versions et au fonctionnement initial d’ARMA et d’un très vieux langage primitif: le SQS. Je ne vais pas te vendre du rêve et être direct avec toi, le mode sans ordonnancement nous ramène à la préhistoire de l’informatique (équivalent du fonctionnement d’un windows 3.11).

Comme son nom l’indique, dans le mode non schédulé, on ne va pas utiliser l’ordonnanceur. En fait, ici ça n’est pas exact, car l’ordonnanceur va agir autrement. Celui-ci va tout simplement ignorer toutes les commandes de suspension(sleep), et exécuter le thread du début à la fin sans s’interrompre.

On a donc pour ainsi dire un accès direct à l’intégralité des ressources cpu d’ARMA, ce qui peut être un véritable atout dans certains cas. Une façon simple de le vérifier est par exemple de lancer cette commande et constater qu’elle va avoir un effet nefaste sur l’affichage des fps.

Voyons voir cette bombe logique:

   fnc_bomb = {
        private _index = 0;
        private _instance = _this + 1;
        while { true } do { 
            diag_log format ["thread TRIGGER%4: %1 frame: %2 iteration : %3", diag_tickTime, diag_frameNo, _index, _instance]; 
            _index = _index + 1;
         };
    };

Ce code placer dans ton init, et appelé via un trigger, va avoir pour effet de siphonner complètement les ressources de l’ensemble d’ARMA car l’ordonnanceur ne re donnera pas la main à d’autres threads tant que la boucle n’est pas terminée. Pour palier à cette problématique, BIS a mis en place une sécurité qui dans ce mode termine automatiquement toute boucle après 10 000 itérations.

Essayons la bombinette V2:

   fnc_bomb = {
        private _index = 0;
        private _instance = _this + 1;
        while { true } do { 
            diag_log format ["thread TRIGGER%4: %1 frame: %2 iteration : %3", diag_tickTime, diag_frameNo, _index, _instance]; 
            _index = _index + 1;
            if(_index > 9998) then { _instance call fnc_bomb; };
         };
    };

Cette bombe va donc s’exécuter dans une seule frame qui ne se terminera donc jamais et provoquera le gel complet d’ARMA.

Autant dire que ce mode non schédulé ne doit être utilisé qu’avec parcimonie et dans des cas très précis. Son intérêt est bien entendu de prioriser l’exécution du code le plus critique.

Les cas d’usage dans lequel le mode non schédulé intervient sont les suivants:

  • triggers
  • functions with preInit attribute
  • FSM-conditions
  • Event handlers (on units and in GUI)
  • object Init Event Handlers
  • object initialization fields
  • all pre-init code executions
  • sqf code which is called from sqs-code
  • expressions of Eden Editor entity attributes
  • code execution with call from a unscheduled environment
  • code inside isNil

Maintenant que tu sais cela, il faut que tu sois donc très vigilant au code que tu utilises dans ce mode non schédulé, et que tu évites d’y utiliser des boucles. Ce mode peut avoir des effets véritablement néfaste et ralentir ton frame rate.

Ca sera tout pour cet article sur l’ordonnanceur, n’hésite pas si tu as besoin de plus de précisions.

Code34

Votre commentaire

Entrez vos coordonnées ci-dessous ou cliquez sur une icône pour vous connecter:

Logo WordPress.com

Vous commentez à l’aide de votre compte WordPress.com. Déconnexion /  Changer )

Photo Google

Vous commentez à l’aide de votre compte Google. Déconnexion /  Changer )

Image Twitter

Vous commentez à l’aide de votre compte Twitter. Déconnexion /  Changer )

Photo Facebook

Vous commentez à l’aide de votre compte Facebook. Déconnexion /  Changer )

Connexion à %s