Ce cours vise à donner une vision partielle de certaines méthodes formelles permettant de s'assurer de la correction d'un système, d'un protocole représenté par un programme.
Un certain nombre de philosophes, disons
sont assis à une table
circulaire dans un restaurant chinois. Ci-dessous figure une image pour
.
On modélise le problème des philosophes généralement par
programmes s'exécutant
simultanément. Les ressources partagées entre les programmes sont les baguettes.
Les programmes sont souvent modélisés par des threads (instances d'exécutions).
L'algorithme d'un philosophe pourrait être par exemple :
Etape 2 - Prendre la baguette gauche si possible, sinon attendre.
Etape 3 - Manger.
Etape 4 - Reposer les baguettes.
Etape 5 - Attendre un temps variable avant de repasser à l'étape 1.
Ce programme à l'air relativement simple.
Cependant si
programmes sont exécutés simultanément cela
peut conduire à un cas de blocage (deadlock en anglais) :
chaque philosophe prend sa baguette droite et se retrouve alors bloqué
dans l'étape 2 à jamais puisque aucune baguette gauche n'est alors disponible.
Nous remarquons qu'un assemblage de programmes simples peut être relativement complexe, il y a en effet peut-être d'autres cas de blocages que nous n'avons pas vus.
Avant de voir si un programme comporte des blocages donnons tout d'abord la solution au problème des philosophes.
Une solution fréquente consiste à numéroter les baguettes de 1 à
et d'utiliser
l'algorithme suivant :
Etape 2 - Prendre la baguette droite ou gauche avec le plus grand indice, si elle est disponible.
Etape 3 - Manger.
Etape 4 - Reposer les baguettes
Etape 5 - Attendre un temps variable avant de repasser à l'étape 1.
Cet algorithme est mathématiquement prouvé dans la section 5.
L'idée du model checking est d'avoir un modèle représentant un programme ou un système. Tous les états possible de ce modèle sont alors explorés pour vérifier une propriété. La difficulté est de trouver les algorithmes et le formalisme exprimant les propriétés permettant de vérifier le plus efficacement possible un système donné.
On peut abstraire les programmes sous forme d'automates. Les automates possèdent un état, ici par exemple les étapes 1 à 5. Puis il y a des transitions entre ces états. Les transitions sont constituées d'une garde (la condition qui permet de déclencher la transition) et une liste d'actions. Ainsi l'algorithme 1 est présenté ci-dessous :
Un tel automate est nommé structure de Kripke. On peut définir cette structure par l'énoncé suivant :
Ainsi pour modéliser l'automate précédent on peut prendre :
Nous allons maintenant décrire le procédé modélisant l'exécution concurrente de processus :
On voit ainsi que l'on peut définir formellement le produit de deux structure de Kripke :
On en déduit alors la propriété évidente ci-dessous :
On peut ainsi faire le produit des automates de la section 2.2 comportant des étiquettes comme illustré ci-dessous :
On remarque qu'aucune transition ne sort de l'état
.
Nous aurions cependant du rajouter entre autre la transition
correspondant à l'exécution du philosophe 0 avec
l'étiquette RIGHT_STICK[0]==0; RIGHT_STICK[0]=1.
Or juste avant on a effectué l'action :
LEFT_STICK[1]=1, or RIGHT_STICK[0] et LEFT_STICK[1]
étant similaires la garde sera toujours fausse.
Il n'y a donc pas de transitions sortant de l'état
.
Cet état est bloquant, le terme anglais consacré est deadlock.
Ainsi on constate que certains états du système produit (ceux en bleu clair) ne sont jamais atteints : il s'agit d'états où les deux philosophes auraient en même temps la même baguette.
Dans cette section nous présenterons l'usage du model checker Spin [Spi] [Hol97], sur le problème des philosophes.
Nous donnons ici la description du système dans le langage Promela, qui est le formalisme de description des systèmes utilisés dans Spin.
Tout d'abord dans le modèle checking nous allons fixer une valeur pour
le nombre de philosophes. De manière similaire aux langages C et C++ la constante
est définie par :
#define N 11Nous définissons les baguettes par un tableau de taille
byte stick[N];Ainsi stick[0] vaut 1 si la baguette numérotée 0 est utilisée, et stick[0] vaut zéro si la baguette ne l'est pas. Ainsi pour chaque philosophe on peut définir sa baguette droite et sa baguette gauche par :
#define LEFT_STICK stick[_pid-1] #define RIGHT_STICK stick[_pid%N]Chaque philosophe va être modélisé par une instance d'exécution exécutant l'algorithme 1. Une instance d'exécution est nommée proctype en Promela. Voici le code :
proctype Philo () {
/* algorithme avec un dead lock */
do
:: d_step { LEFT_STICK == 0 -> LEFT_STICK=1 };
if
:: d_step { RIGHT_STICK == 0 -> RIGHT_STICK=1};
LEFT_STICK=0;
RIGHT_STICK=0;
fi;
od;
}
Trois types de construction sont utilisés dans l'exemple ci-dessus :
do :: condition1 -> ..... ; :: condition2 -> ..... ; :: condition3 -> ..... ; od;C'est une boucle infinie. Si l'une des instruction après :: est vraie alors la branche est exécutée. Une fois la branche exécutée on revient sur la boucle do, qui recommence à l'infini sauf si une instruction break est rencontrée dans une branche. Le point important qui diffère des langages de programmation impératifs habituels est le non déterminisme : si deux conditions sont simultanément vraies alors l'une ou l'autre peut être empruntée. La figure ci-dessous illustre l'automate correspondant à un do :
if :: condition1 -> ..... ; :: condition2 -> ..... ; :: condition3 -> ..... ; if; instruction_suivante;le fonctionnement est similaire à celui des boucles do sauf qu'après l'exécution d'une branche c'est l'instruction suivante qui est exécutée.
d_step { condition1 -> instruction1; instruction2; ... }
permet si la condition à l'entrée du d_step condition1 est vraie d'exécuter
la suite d'instructions. La figure ci-dessous montre que pour une séquence d'instructions
linéaire l'englober dans une déclaration d_step est équivalent à faire
une seule transition. La seule condition étant bien entendu que la condition initiale suffise
à déclencher les instructions 1 et 2.
Il nous faut en plus définir un processus initial qui va lancer les
autres:
init {
atomic {
int i=0;
do
:: i < N-1 -> run Philo(); i=i+1
:: i == N-1 -> run Philo(); break
od;
}
/* attendre sans fin */
do
:: 0;
od;
}
Voici la solution correcte où c'est la baguette de plus petit indice qui est prise en premier. On remarquera l'usage d'une variable nommée _pid (pour process identifier) qui donne le numéro correspondant à l'instance d'exécution courante.
proctype Philo () {
/**
* Solution correcte où la baguette de plus petite priorité est prise
* en premier.
*/
do
:: d_step { _pid!=N && stick[_pid-1] == 0 -> stick[_pid-1]=1 };
if
:: d_step { stick[_pid] == 0 -> stick[_pid]=1};
stick[_pid-1]=0;
stick[_pid]=0;
fi;
:: d_step { _pid==N && stick[0] == 0 -> stick[0]=1 };
if
:: d_step { stick[N-1] == 0 -> stick[N-1]=1};
stick[N-1]=0;
stick[0]=0;
fi;
od;
}
Maintenant que nous avons réalisé une modélisation du système à l'aide de Promela, il faut vérifier qu'elle correspond bien à nos attentes. Nous devons pour cela vérifier certaines propriétés comme par exemple : si un philosophe est en train de manger alors à un moment dans le futur il va poser ses baguettes.
Il faut tout d'abord un langage permettant d'exprimer les propriétés que l'on souhaite vérifier, et pour lequel nous disposons d'un algorithme efficace.
Nous allons décrire le formalisme de la logique temporelle linéaire (LTL). Nous allons raisonner sur les chemins d'exécution. Un chemin d'exécution est une suite d'états d'une structure de Kripke représentant une exécution véritable. En voici une définition formelle :
On va alors définir les opérateurs
,
,
et
pour exprimer les aspects
temporels:
On peut alors définir deux autres opérateurs à partir du
(until) :
On peut composer les opérateurs. Ainsi pour exprimer qu'une exécution
vérifie une infinité de fois une propriété
on peut écrire :
Un résultat important qui justifie l'usage de la logique précédente est présenté dans cette section : les formules de la logique LTL peuvent se coder sous forme d'automates de Büchi.
Voici l'automate représentant
:
Voici l'automate représentant
:
Voici une propriété qui n'est pas démontrée dans ce cours :
Nous décrivons ici, sans le détailler comment vérifier qu'une structure de Kripke vérifie une formule LTL.
Imaginons maintenant que nous faisions le produit d'un automate de Büchi et de notre
système. Si nous trouvons un état acceptant pour le produit c'est que l'on
a trouvé une exécution du système d'origine dans laquelle
est vraie.
En pratique les model checkers vérifient plutôt qu'il n'y a pas d'exécution possible,
c'est à dire pas de chemins passant une infinité de fois par les états acceptants.
Ainsi si on veut vérifier que le système satisfait
,
on prend le l'automate de Büchi pour
, on en fait produit synchronisé
et on vérifie qu'aucun chemin ne passe une infinité de fois par les états acceptants.
Un programme générant l'automate de Büchi d'une formule LTL est fourni avec Spin. Une version plus performante [GO01] est réalisée par le programme LTL2BA à l'url suivante :
Le model checking LTL comme décrit précédemment est dû essentiellement à Lichtenstein, Pnueli, Vardi et Wolper [VW86].
Voici une application de la section précédente.
Il faut tout d'abord vérifier que le modèle que nous avons fait correspond bien à la réalité.
Par exemple quand un philosophe mange il doit tenir sa baguette droite
et sa baguette gauche. C'est à dire la formule suivante doit être vraie :
où :
#define p1 (philoState[1]==2) #define p2 (len(stick[1])==1 && len(stick[2])==1)Pour vérifier la formule il suffit de taper en ligne de commande :
spin -f "! [] (p1 -> p2)".
Cela va retourner un automate qui représente la propriété. Il suffit alors de copier
coller cette formule dans notre description du système :
#define p1 (philoState[1]==2)
#define p2 (len(stick[1])==1 && len(stick[2])==1)
never {
T0_init:
if
:: (! ((p2)) && (p1)) -> goto accept_all
:: (1) -> goto T0_init
fi;
accept_all:
skip
}
Les états acceptants de l'automate de Büchi sont ceux dont le nom commence par accept_.
Nous pouvons alors lancer spin en invoquant la ligne de commande :
spin -a phi.spi gcc -O2 -o pan pan.c pan -aDans la sortie nous remarquons la ligne :
State-vector 60 byte, depth reached 185, errors: 0Cela signifie que le modèle a été vérifiée et que la propriété est vraie.
Dans cette section nous vérifions le modèle avec le blocage. Il suffit de taper les commandes suivantes pour vérifier la présence de blocages :
spin -a phi.spi gcc -O2 -o pan pan.c panDans la sortie on trouve alors :
State-vector 52 byte, depth reached 48, errors: 1Il y a une erreur. Un fichier phi.spi.trail a été généré. On peut alors rejouer l'exécution du système qui conduit à l'erreur.
spin -v -t phi.spiCeci permet de comprendre sur un exemple concret la raison du blocage. On peut rechercher la profondeur minimum déclenchant l'erreur. Il faut pour cela lancer pan avec l'option -m200, pour se limiter à 200 pas de profondeur. Ici on remarque que la profondeur minimum est de 102 pas pour avoir l'erreur.
Ici c'est relativement facile, le résultat de l'exécution du système est :
....
99: proc 5 (Philo) line 23 "phi.spi" (state 3) [((fork[(_pid-1)]==0))]
99: proc 5 (Philo) line 23 "phi.spi" (state 2) [fork[(_pid-1)] = 1]
100: proc 1 (Philo) line 23 "phi.spi" (state 3) [((fork[(_pid-1)]==0))]
100: proc 1 (Philo) line 23 "phi.spi" (state 2) [fork[(_pid-1)] = 1]
spin: trail ends after 100 steps
#processes: 12
fork[0] = 1
fork[1] = 1
fork[2] = 1
fork[3] = 1
fork[4] = 1
fork[5] = 1
fork[6] = 1
fork[7] = 1
fork[8] = 1
fork[9] = 1
fork[10] = 1
100: proc 11 (Philo) line 24 "phi.spi" (state 11)
100: proc 10 (Philo) line 24 "phi.spi" (state 11)
100: proc 9 (Philo) line 24 "phi.spi" (state 11)
100: proc 8 (Philo) line 24 "phi.spi" (state 11)
100: proc 7 (Philo) line 24 "phi.spi" (state 11)
100: proc 6 (Philo) line 24 "phi.spi" (state 11)
100: proc 5 (Philo) line 24 "phi.spi" (state 11)
100: proc 4 (Philo) line 24 "phi.spi" (state 11)
100: proc 3 (Philo) line 24 "phi.spi" (state 11)
100: proc 2 (Philo) line 24 "phi.spi" (state 11)
100: proc 1 (Philo) line 24 "phi.spi" (state 11)
100: proc 0 (:init:) line 68 "phi.spi" (state 12)
12 processes created
On remarque que tous les philosophes on pris une baguette. 12 processus ont été créés (11 pour les philosophes et 1 pour le processus init). Les philosophes sont tous dans l'état 11 (à la ligne 24 du fichier phi.spi).
Ce fichier est téléchargeable à l'url http://www.derepas.com/epita/mc.
Voici le nombre d'états du système généré pour différentes valeurs de
:
| N | temps (sec.) | nb. états |
| 5 | 0.5 | 1204 |
| 10 | 3 | 1.7118e+06 |
| 11 | 16 | 6.82331e+06 |
Dans cette section nous donnons une preuve de correction de l'algorithme 2 décrit dans la section 1.3.
Il y a trois possibilités pour les valeurs de
,
et
:
Supposons maintenant qu'il n'y a pas de baguette sur la table dans l'état
.
Si un philosophe est dans l'état 3, 4 ou 5 alors une transition vers un autre état est possible,
et
n'est pas bloquant.
Supposons maintenant que tous les philosophes soient dans l'état 1 ou 2.
Puisqu'il y a
baguettes pour
philosophe, et qu'aucun philosophe ne possède deux baguettes,
alors chaque philosophe a exactement une baguette.
Notons
le philosophe qui tient la baguette
. Puisque
est la baguette
de plus haute priorité
devrait posséder une autre baguette de priorité moindre.
Ceci est impossible puisque
n'a qu'une baguette. Cette situation est impossible.
Dans chaque état possible
n'est pas bloquant.
Une approche alternative au model checking qui se restreint à un
donné
pourrait être d'effectuer sous forme informatique la preuve mathématique écrite
précédemment. On serait ainsi vraiment sûr que pour tout
l'algorithme est bon.
Il n'existe pas à l'heure actuelle d'algorithme permettant de générer automatiquement
une preuve comme celle ci-dessus. Il faut donc tout d'abord rédiger une preuve manuelle,
qui exige de comprendre finement les propriétés à vérifier et pourquoi elles le sont,
puis de donner cette preuve à un logiciel pouvant vérifier la preuve.
De tels outils sont appelés assistants de preuve, puisqu'ils permettent simplement de vérifier la correction d'une preuve et non de trouver la preuve. On trouve par exemple [Coq] ou [Pvs].
Ici les entiers ne seront pas représentés directement sous forme de bits mais uniquement
par les relations logiques des fondements mathématiques qui les définissent.
En mathématique les entiers sont définis comme suit : on dispose d'un nombre nommé
(la lettre ``o'' en majuscule) qui représente le nombre zéro. On définit l'opération
qui à un entier naturel associe son successeur, c'est à dire sa valeur incrémentée de
.
Ces deux affirmations constituent les axiomes de Péano et définissent les entiers naturels.
Définissons les entiers naturels dans un type nommé Value :
template<class V> struct Value { typedef V value; };
On peut alors définir zéro comme étant un type particulier dérivant de Value :
struct zero : public Value<zero> { };
On définit alors l'opérateur Successeur comme le type suivant :
template<class C> struct S
: public Value<S<C> > { typedef C predecessor; };
Il s'agit d'un template, c'est à dire que pour un type donné par exemple zero,
on peut créer le nouveau type ici S<zero>.
On peut donc créer des types spécifiques pour les nombres jusqu'à 10 :
typedef S<zero> one; typedef S<one> two; typedef S<two> three; typedef S<three> four; typedef S<four> five; typedef S<five> six; typedef S<six> seven; typedef S<seven> eight; typedef S<eight> nine; typedef S<nine> ten;Comment dès lors définir l'addition entre deux nombres ? Il faut faire un template qui prend deux types et en renvoie. Le type résultant devra représenter le résultat et être plus facile à calculer :
template<class C,class D> struct plus
: public S<plus<C,typename D::predecessor> > { };
Il nous faut rajouter un axiome de base qui spécifie que zero est l'élément neutre
pour l'addition :
template<class C> struct plus<C,zero> : public C { };
De même on peut définir le moins :
template<class C,class D> struct minus
: public minus<C,typename D::predecessor>::predecessor { };
template<class C> struct minus<C,zero>
: public C { };
la multiplication :
template<class C,class D> struct times
: public plus<C,typename times<C,typename D::predecessor>::value> { };
template<class C> struct times<C,zero>
: public zero { };
Nous allons maintenant définir l'ordre sur les entiers avec l'opérateur ge (greater or equal) :
template<class C,class D> struct ge
: public ge<typename C::predecessor,typename D::predecessor> { };
template<class C> struct ge<C,zero>
: public one { };
template<class C> struct ge<zero,C>
: public zero { };
template<> struct ge<zero,zero>
: public one { };
Rajoutons alors un test de divisibilité :
template<class C,class D,class E = S<S<zero> > > struct Divisible { };
template<class C,class D> struct Divisible<C,D,S<S<zero> > >
: public Divisible<C,D,typename ge<C,D>::value> { };
Si
template<class C,class D> struct Divisible<C,D,zero>
: public zero { };
template<class C> struct Divisible<zero,C,zero>
: public one { };
Si
template<class C,class D> struct Divisible<C,D,S<zero> >
: public Divisible<typename minus<C,D>::value,D> { };
On définit alors le test de primalité comme suit :
template<class C,class D> struct Prime<C,D,zero,zero,zero>
: public Prime<C,D,one,zero,typename ge<D,C>::value> { };
// Test la division par D. Essayer avec le successeur en cas d'échec.
template<class C,class D> struct Prime<C,D,one,zero,zero>
: public Prime<C,S<D>,zero,typename Divisible<C,D>::value,zero> { };
// Un facteur a été trouvé !
template<class C,class D> struct Prime<C,D,zero,one,zero>
: public zero { };
// Aucun facteur n'a été trouvé !
template<class C,class D> struct Prime<C,D,one,zero,one>
: public one { };
On définit alors un axiome permettant d'utiliser l'écriture décimale :
template<class C,class D> struct Decimal
: public plus<typename times<ten,C>::value,D> { };
Il ne reste plus qu'à écrire deux fonctions d'affichage :
template<> char *output(zero) { return "No"; }
template<> char *output(one) { return "Yes"; }
Et pour prouver que 13 est premier il nous suffit d'écrire :
main() {
puts(output(Prime<Decimal<one,three>::value>::value()));
}
Prouvez en utilisant l'automate de l'exercice précédent que les exécutions
de
ne vérifient pas
.
Caractérisez les deux types d'exécutions violant
.