Algorithmique & Programmation () Pottier 4 juin 2014 4 juin 2014 1 / 84
Une communication peu coûteuse... Les processus légers : partagent le tas et les variables globales, donc peuvent communiquer de façon potentiellement peu coûteuse. 4 juin 2014 3 / 84
...mais pas simple pour autant Nous allons constater que si plusieurs threads tentent d accéder au même emplacement au même moment, le résultat est souvent imprévisible et/ou indésirable. Donc, la communication par mémoire partagée ne suffit pas pour écrire des programmes concurrents corrects et efficaces ; il faut aussi des mécanismes de synchronisation, c est-à-dire d attente. 4 juin 2014 4 / 84
Différents mécanismes de synchronisation Nous avons déjà rencontré un mécanisme de synchronisation, join. Il permet à un thread B d attendre la terminaison d un thread A. Cela permet, par exemple, de transmettre un résultat de A vers B. Il en existe d autres... 4 juin 2014 5 / 84
Différents mécanismes de synchronisation Si deux threads stockent des informations dans une table de hash partagée, il faut leur interdire d y accéder au même moment. Il faut donc parfois que l un d eux attende. Les verrous («locks») étudiés aujourd hui servent à cela. 4 juin 2014 6 / 84
Différents mécanismes de synchronisation Si A produit un flot de résultats, qu il souhaite transmettre au fur et à mesure qu ils sont produits à B, alors ces threads doivent se coordonner. A doit parfois attendre B, et vice-versa. Les files d attente concurrentes utilisées la semaine dernière en TD et étudiées en détail la semaine prochaine permettent cela. Nous verrons qu elles sont implémentées à l aide de mécanismes plus élémentaires, à savoir verrous et variables de condition. 4 juin 2014 7 / 84
Aujourd hui, donc, nous étudions les verrous : à quoi servent-ils? comment fonctionnent-ils? quels présentent-ils? 4 juin 2014 8 / 84
Le problème du compteur partagé Imaginons que plusieurs threads souhaitent partager un compteur. 4 juin 2014 10 / 84
Le problème du compteur partagé Voici une implémentation ordinaire d un objet «compteur» : class UnsafeCounter { private int count ; void increment () { count++ ; } int get () { return count ; } } Que se passe-t-il si plusieurs threads utilisent simultanément un tel objet? (Démonstration.) 4 juin 2014 11 / 84
Que se passe-t-il? Comment expliquer ce comportement? Chacun des deux threads appelle n fois c.increment(). La valeur finale de c ne devrait-elle pas être 2n, quel que soit l ordre dans lequel ces appels sont effectués? Ce raisonnement serait correct si chaque appel était exécuté de façon atomique, c est-à-dire sans interférence de la part de l autre thread. Or, ce n est pas le cas... 4 juin 2014 12 / 84
increment n est pas atomique L instruction count++ ; est une version abrégée de : count = count + 1 ; qui signifie en fait : this. count = this. count + 1 ; qui signifie en fait : int tmp = this. count ; tmp = tmp + 1 ; this. count = tmp ; On reconnaît une lecture en mémoire («load»), suivie d une addition, suivie d une écriture en mémoire («store»). 4 juin 2014 13 / 84
increment n est pas atomique Supposons que deux threads A et B appellent c.increment() au même moment. L objet c est partagé. L emplacement en mémoire nommé c.count est le même du point de vue de A et du point de vue de B. La variable tmp est locale, donc non partagée. Chaque thread a la sienne. Le code exécuté est donc, grosso modo : // thread A // thread B int tmpa = this. count ; int tmpb = this. count ; tmpa = tmpa + 1 ; tmpb = tmpb + 1 ; this. count = tmpa ; this. countb = tmp ; où chaque thread progresse à sa vitesse propre. 4 juin 2014 14 / 84
increment n est pas atomique Comment ce code est-il exécuté par la machine? Si la machine a un seul processeur, il y a partage du temps, donc entrelacement arbitraire des instructions de A et des instructions de B. Si la machine a plusieurs processeurs, A et B s exécutent réellement en même temps, et et la mémoire traite les ordres de lecture et d écriture émis par A et B dans un ordre un entrelacement imprévisible. (En fait, c est encore pire, voir plus loin.) 4 juin 2014 15 / 84
Un entrelacement possible // thread A // thread B int tmpa = this. count ; int tmpb = this. count ; tmpa = tmpa + 1 ; tmpb = tmpb + 1 ; this. count = tmpa ; this. count = tmpb ; Quoiqu il en soit, le scénario suivant (entre autres) est possible : A lit c.count et initialise tmpa à 0 ; B lit c.count et initialise tmpb à 0 ; A et B incrémentent tmpa et tmpb ; A écrit 1 dans c.count ; B écrit 1 dans c.count. À la fin, le compteur vaut 1 alors que c.increment a été appelé deux fois. Aïe! 4 juin 2014 16 / 84
increment n est pas atomique D autres scénarios peuvent se produire : par exemple, on peut voir diminuer la valeur du compteur. Dans tous les cas, le problème provient du fait que B modifie le compteur à un instant où A a déjà pu observer le compteur mais pas encore le modifier. On dit qu il y a interférence entre les threads A et B. 4 juin 2014 17 / 84
Interférence Il y a interférence ou conflit («race condition») si et seulement si : il existe un entrelacement des instructions qui fait que... deux threads accèdent au même instant à un même emplacement en mémoire, et au moins l un d eux tente d écrire à cet emplacement. Deux lectures simultanées ne constituent donc pas une race condition. 4 juin 2014 18 / 84
Thou shalt not race Nous poserons ce principe : Un programme qui présente une race condition est incorrect. 4 juin 2014 19 / 84
Thou shalt not race Deux raisons principales justifient ce principe : pour comprendre le comportement d un programme qui présente une race condition, il faudrait au minimum étudier tous les entrelacements possibles des instructions des différents threads ; cela ne suffirait même pas, car certains comportements des machines modernes ne sont expliqués par aucun entrelacement... 4 juin 2014 20 / 84
Un comportement inattendu Soient x et done des variables globales, donc partagées. Le code suivant présente une race condition sur la variable partagée done. On suppose qu initialement x vaut 0 et done vaut false. L instruction assert peut-elle échouer? // thread A // thread B x = 42 ; while (!done ) { } done = true ; assert ( x == 42) ; Sur certaines machines (par exemple ARM), oui. B voit que done vaut true mais trouve une valeur autre que 42 dans x. Cela n est expliqué par aucun entrelacement. Comme disent Boehm et Adve, «You Don t Know Jack about Shared Variables or Memory Models». 4 juin 2014 21 / 84
Mais que fait ARM? Le processeur A a demandé à la mémoire d écrire d abord 42 dans x, puis true dans done. Or, le processeur B a lu true dans done mais pas 42 dans x. La mémoire n a pas respecté l ordre des écritures. En fait, il y a une mémoire interne au processeur A, une autre interne au processeur B, des échanges de messages entre elles, et ces messages ne circulent pas dans l ordre, pour des raisons d efficacité. Ce n est pas un bug du processeur! 4 juin 2014 22 / 84
Et que fait Intel? Les processeurs Intel garantissent que «les messages d écriture circulent dans l ordre», donc si B voit que done vaut true, il voit aussi que x vaut 42. Cela n empêche pas que, à un instant donné, A peut savoir que done vaut true tandis que B croit encore que done vaut false, parce que le message n a pas encore été reçu. On dit que ARM, Intel,... offrent des «modèles mémoire faiblement cohérents». 4 juin 2014 23 / 84
Et que dit Java? En ce qui concerne le comportement de la mémoire en présence de race, le standard Java est extrêmement complexe. (Démonstration.) Le compilateur Java change while (!done) boolean d = done ; while (!d) ;. ; en Mes collègues débattent toujours s il a tort ou raison! Déclarer que done est volatile impose un comportement «cohérent» et fait disparaître le problème. 4 juin 2014 24 / 84
Thou shalt not race Revenons sur Terre. Pour nous, une règle simple suffit : Un programme qui présente une race condition est incorrect. On doit synchroniser les threads pour éviter toute race condition. Le comportement des programmes est alors beaucoup plus facile à analyser et à prédire, car, en l absence de race, le fait que la mémoire est «faiblement cohérente» ne se voit pas. 4 juin 2014 25 / 84
Comment faire un compteur partagé? Notre objet UnsafeCounter ne doit pas être utilisé simultanément par plusieurs threads : cela conduit à une race condition. Il y a race condition sur count parce que plusieurs threads y accèdent (en lecture et en écriture) au même moment. 4 juin 2014 27 / 84
Comment faire un compteur partagé? On pourrait songer à ajouter un booléen ready dont la valeur indique s il est permis ou non d accéder à count. Mais cela ne ferait que déplacer le problème : il y aurait alors une race condition sur ready. Mais alors, comment implémenter correctement un compteur partagé? 4 juin 2014 28 / 84
Plusieurs solutions Dans la suite, nous allons découvrir deux réponses à ce problème : l une à base d instructions évoluées, l autre, plus générale, à base de verrous. Les deux idées sont liées : l implémentation des verrous utilise (plusieurs techniques dont) des instructions évoluées. 4 juin 2014 29 / 84
Avec l aide de la machine Et si la machine nous proposait une instruction toute faite pour lire la valeur du compteur puis y écrire une nouvelle valeur, le tout de façon atomique? 4 juin 2014 30 / 84
Avec l aide de la machine De nombreux processeurs proposent des instructions qui combinent une lecture et une écriture au même emplacement. L une de ces instructions est compareandset. Voyons quelle forme elle prend du point de vue d un programmeur Java... 4 juin 2014 31 / 84
L instruction compareandset Java fournit une classe AtomicInteger : public class AtomicInteger { public AtomicInteger ( int initialvalue) ; public int get () ; public void set ( int newvalue) ; public boolean compareandset ( int expectedvalue, int newvalue) ; } Un tel objet contient un champ de type int, consultable et modifiable grâce aux méthodes get et set. Il propose de plus une méthode compareandset... 4 juin 2014 32 / 84
L instruction compareandset L expression ai.compareandset(expectedvalue, newvalue) : 1 lit la valeur stockée dans l objet ai ; 2 compare cette valeur à expectedvalue ; 3 si cette comparaison réussit, écrit dans ai la valeur newvalue ; 4 renvoie le résultat de la comparaison (qui indique donc si l écriture a eu lieu) ; 5 le tout de façon atomique, c est-à-dire sans qu un autre thread puisse modifier ai pendant ce temps. 4 juin 2014 33 / 84
Comment utiliser compareandset? La classe AtomicInteger fournit aussi une méthode getandincrement, dont l effet est analogue à celui de l instruction count++. getandincrement est implémentée à l aide de compareandset. Comment?... 4 juin 2014 34 / 84
Comment utiliser compareandset? On va vouloir utiliser compareandset... public int getandincrement () { compareandset (?,?) ; } Mais il faut d abord déterminer l ancienne valeur et calculer la nouvelle... 4 juin 2014 35 / 84
Comment utiliser compareandset? Pour cela, on appelle d abord get : public int getandincrement () { int n = get() ; compareandset ( n, n + 1) ; } Mais compareandset peut échouer. Il faut examiner son résultat... 4 juin 2014 36 / 84
Comment utiliser compareandset? Si compareandset réussit, tout va bien! Aucune interférence n a eu lieu. public int getandincrement () { } int n = get() ; if ( compareandset (n, n + 1)) return n ; //?? Mais que faire dans le cas contraire? 4 juin 2014 37 / 84
Comment utiliser compareandset? Il faut réessayer jusqu à ce que compareandset réussisse. public int getandincrement () { while ( true ) { int n = get() ; if ( compareandset (n, n + 1)) return n ; } } C est une attente active (à éviter en principe, sauf si elle dure peu). 4 juin 2014 38 / 84
Comment utiliser compareandset? En résumé, get permet d observer la valeur du compteur, compareandset permet de la modifier, à condition que notre observation n ait pas été invalidée entre-temps ; la boucle permet d attendre que compareandset réussisse. 4 juin 2014 39 / 84
Faut-il utiliser compareandset? L utilisation de compareandset ne constitue pas une race condition. Elle est «autorisée». compareandset résout le problème du compteur partagé. Cependant, elle ne résout pas (directement) le problème plus général de l exclusion... 4 juin 2014 40 / 84
Peut-on partager une pile? Imaginons que nous souhaitions partager une pile («stack») entre plusieurs threads. 4 juin 2014 42 / 84
Une pile traditionnelle Traditionnellement, on peut implémenter une pile à l aide d une liste chaînée de cellules immuables : class StackCell <X> { final X data ; final StackCell <X> next ; StackCell ( X data, StackCell <X> next ) {... } } 4 juin 2014 43 / 84
Une pile traditionnelle L objet «pile» contient un pointeur top vers la tête de la liste : class Stack <X> { private StackCell <X> top ; void push ( X x) { top = new StackCell <X> (x, top) ; } X pop () { if ( top == null ) return null ; X data = top. data ; top = top. next ; return data ; } } Si deux threads appellent s.push(...) ou s.pop() au même moment, que se passe-t-il?... 4 juin 2014 44 / 84
Peut-on partager une pile? D abord, il y a une race condition sur le champ s.top. Donc, le programme est incorrect. Si cela ne vous convainc pas, étudiez quelques scénarios possibles : deux appels simultanés à push peuvent n empiler qu un seul objet! un objet est perdu. deux appels simultanés à pop peuvent ne dépiler qu un seul objet! un objet est dupliqué. etc. 4 juin 2014 45 / 84
Quel est le problème? La racine du problème, en ce qui concerne push par exemple, est que l instruction : top = new StackCell <X> (x, top) ; n est pas atomique. Elle signifie en fait : StackCell <X> oldcell = top ; StackCell <X> newcell = new StackCell <X> (x, oldcell) ; top = newcell ; Cette séquence d instructions est sujette à interférence. 4 juin 2014 46 / 84
Quel est le problème? Ici, on retrouve le motif simple (lecture-calcul-écriture) de la méthode UnsafeCounter.increment. Le problème peut donc à nouveau être résolu à l aide de l instruction compareandset. (Exercice! On utilisera la classe AtomicReference fournie par Java.) 4 juin 2014 47 / 84
Quel est le problème? Cependant, en général, on peut souhaiter exécuter une séquence d instructions plus longue, constituée de plus d une lecture et/ou plus d une écriture, de façon atomique (sans interférence). C est le cas si on souhaite partager, par exemple : une table de hash dotée de méthodes put et get ; un arbre binaire de recherche doté de méthodes put et get ; etc. 4 juin 2014 48 / 84
Spécifier ce que l on attend On aimerait déclarer quelles séquences d instructions, appelées «sections critiques», doivent être exécutées sans interférence. atomic {... } On pourrait écrire : void push ( X x) { atomic { top = new StackCell <X> (x, top) ; } } X pop () { atomic {... } } C est un peu simpliste/simplifié, mais c est l idée. 4 juin 2014 49 / 84
Spécifier ce que l on attend On pourrait alors exprimer la propriété que l on attend : Jamais deux threads ne sont engagés chacun dans une section critique. Cette propriété est appelée exclusion. 4 juin 2014 50 / 84
Implémenter l exclusion Pour réaliser l exclusion, l idée est simple : si un thread B arrive à l entrée d une section critique, et si un thread A se trouve déjà dans une section critique, alors B doit attendre jusqu à ce que A en sorte. Un mécanisme de synchronisation est donc nécessaire. Les verrous constituent un tel mécanisme. 4 juin 2014 51 / 84
Les verrous Habituellement, les verrous servent à la fois à : délimiter les sections critiques, donc spécifier ce que l on attend ; implémenter l attente à l entrée d une section critique. 4 juin 2014 53 / 84
Les verrous Il existe trois opérations sur les verrous : 1 création d un nouveau verrou l ; 2 verrouillage («acquisition») d un verrou existant l ; 3 déverrouillage («libération») d un verrou l préalablement verrouillé. 4 juin 2014 54 / 84
Les verrous en Java En Java, un verrou est un objet (alloué dans le tas, comme tout objet). Java fournit une interface Lock : public interface Lock { void lock () ; void unlock () ; } Il en existe plusieurs implémentations, dont celle-ci : public class ReentrantLock implements Lock {... } On peut donc créer autant de verrous qu on le souhaite : Lock l = new ReentrantLock () ; 4 juin 2014 55 / 84
Les verrous Un verrou a deux états possibles : il est soit déverrouillé («libre»), soit verrouillé («pris»). L opération de verrouillage, lock, est bloquante : si un thread B tente d acquérir un verrou, et si un thread A a déjà pris ce verrou, alors B doit attendre jusqu à ce que A libère ce verrou. C est donc une opération de synchronisation. Un verrou ne peut pas être «pris» simultanément par plusieurs threads. 4 juin 2014 56 / 84
Les verrous Un verrou peut être implémenté par attente active, à l aide de compareandset. (Exercice!) Il peut également être implémenté via un appel système : le thread qui appelle lock est suspendu jusqu à ce que le verrou soit libre. On combine en général ces deux mécanismes. Le programmeur Java n a pas à savoir comment les verrous sont implémentés. 4 juin 2014 57 / 84
Délimiter une section critique On voit que, pour délimiter une section critique, il suffit : d acquérir un verrou à l entrée de la section critique, de libérer ce verrou à la sortie de la section critique. 4 juin 2014 58 / 84
Délimiter une section critique en Java Si lock est un verrou, on écrira donc : lock. lock() ; try { // SECTION CRITIQUE } finally { lock. unlock() ; } try/finally garantit que le verrou sera libéré même si le code situé dans la section critique lance une exception. Ceci correspond à l hypothétique mot-clef atomic de tout-à-l heure. 4 juin 2014 59 / 84
Quel(s) verrou(s) utiliser? Il reste une chose à préciser. Quel verrou faut-il utiliser pour délimiter une section critique? Si s est une pile, il faut interdire à deux threads d exécuter s.pop ou s.push au même moment. Cependant, si s1 et s2 sont deux piles distinctes, mieux vaut autoriser s1.pop et s2.push à être exécutés simultanément. 4 juin 2014 60 / 84
Quel(s) verrou(s) utiliser? En termes plus généraux, deux sections de code : qui peuvent interférer entre elles doivent être contrôlées par un même verrou ; qui ne peuvent pas interférer entre elles peuvent être contrôlées par des verrous distincts. 4 juin 2014 61 / 84
Quel(s) verrou(s) utiliser? Ici, il semble naturel que chaque pile partagée ait son propre verrou. 4 juin 2014 62 / 84
Une pile partagée en Java On associe à chaque objet de classe SafeStack un verrou distinct : class SafeStack <X> { private final Lock lock = new ReentrantLock () ; private StackCell <X> top ; void push (X x) { lock. lock() ; // take the lock try { top = new StackCell <X> (x, top) ; } finally { lock. unlock() ; // release the lock } } X pop () {... } // also takes and releases the lock } Le corps des méthodes push et pop forme une section critique. 4 juin 2014 63 / 84
Une pile partagée en Java Un objet s de classe SafeStack peut être utilisé sans danger par plusieurs threads simultanément. Au plus un thread à la fois pourra exécuter un appel à s.push ou s.pop ; les autres devront attendre. Deux objets distincts s1 et s2 de classe SafeStack peuvent être utilisés simultanément par deux threads sans qu aucune attente soit nécessaire. 4 juin 2014 64 / 84
Une pile partagée en Java Le fait que la classe SafeStack est «thread-safe» (ou «synchronized») doit être indiqué dans sa documentation. On oublie trop souvent de préciser si l accès concurrent à un objet est permis ou non. Dans la documentation de Java, c est parfois précisé (voir par exemple StringBuilder versus StringBuffer), parfois non. 4 juin 2014 65 / 84
Le mot-clef synchronized Pour alléger l emploi des verrous, Java propose trois idées : tout objet o est aussi un verrou ; une section critique protégée par l objet o s écrit : synchronized ( o) { // SECTION CRITIQUE } si on souhaite que le corps d une méthode constitue une section critique protégée par this, alors il suffit d écrire synchronized dans l en-tête de la méthode. Cela correspond à l hypothétique mot-clef atomic de tout-à-l heure ; mais il faut préciser quel verrou on prend (ici, o). 4 juin 2014 66 / 84
Une pile partagée, avec synchronized L objet this joue le rôle de verrou. class SafeStack <X> { private StackCell <X> top ; synchronized void push ( X x) { top = new StackCell <X> (x, top) ; } synchronized X pop () {... } } Ce verrou est pris puis rendu par push, et de même par pop. 4 juin 2014 67 / 84
Deux règles simples Le programmeur doit respecter deux règles simples : tout accès à un champ partagé modifiable doit être protégé par un verrou ; les accès à un même champ doivent être protégés par un même verrou. Les champs non modifiables (final) n ont pas besoin d être protégés. Ces règles suffisent à garantir l absence de race condition. 4 juin 2014 69 / 84
et invariants Si une structure de données partagée possède un invariant : par exemple, «ceci est un arbre binaire de recherche équilibré», alors : lorsqu on acquiert le verrou, on peut supposer l invariant satisfait ; on peut alors briser (temporairement) l invariant, sans crainte qu un autre thread puisse observer cette violation ; lorsqu on relâche le verrou, on doit démontrer que l invariant est à nouveau satisfait. 4 juin 2014 70 / 84
Une grande latitude Le programmeur reste libre de décider (et doit savoir!) : quelles structures de données sont partagées ; quels verrous protègent quelles structures ; quel invariant est (éventuellement) associé à chaque verrou. 4 juin 2014 71 / 84
Violations d atomicité Malheureusement, l absence de race ne suffit pas! Ce code obéit à nos règles et ne présente donc aucune race condition : class BuggyCounter { private int count ; synchronized int get () { return count ; } synchronized void set ( int newcnt ) { count = newcnt ; } void increment () { set ( get () + 1) ; } } Est-il correct?... 4 juin 2014 72 / 84
Violations d atomicité Ce code ne se comporte pas de façon satisfaisante, parce que la méthode increment n est pas atomique. Entre l instant où on observe la valeur actuelle de count et l instant où on modifie count, d autres threads peuvent agir. L absence de race n est donc pas une condition suffisante pour que le programme soit correct. 4 juin 2014 73 / 84
Violations d atomicité Pour corriger ce code, on doit déclarer que increment est synchronized. Cela ne provoquera pas de blocage, car les verrous de Java sont ré-entrants : un même thread peut prendre plusieurs fois un même verrou. 4 juin 2014 74 / 84
Interblocage Autre danger : puisque l opération lock est bloquante, il y a risque de blocage éternel («deadlock»). Un thread A pourrait attendre un verrou détenu par un thread B qui ne le libérera jamais, parce qu il attend lui-même un verrou détenu par un thread C qui... Les threads ainsi bloqués forment un cycle : A attend B qui attend C qui...... qui attend A. Chacun attend un verrou que le suivant possède. Ce danger est bien réel! (Démonstration.) 4 juin 2014 75 / 84
Interblocage Pour éviter ce danger, on peut adopter une règle simple : chaque thread doit détenir au plus un verrou à la fois ; ou bien une règle moins restrictive mais plus complexe : pour un ordre partiel fixé sur les verrous, un thread qui acquiert plusieurs verrous doit les acquérir dans l ordre. On voit que l exemple précédent ne respecte aucune de ces règles. 4 juin 2014 76 / 84
Interblocage Le danger ne provient pas seulement de l opération lock mais de toutes les opérations bloquantes, dont par exemple join. Une règle prudente est qu il ne faut pas effectuer un appel bloquant lorsque l on a pris un verrou. Plus généralement, une section critique doit être de durée aussi courte que possible. 4 juin 2014 77 / 84
Dernier danger : l inefficacité. Questions d efficacité Prendre et relâcher un verrou a un coût en soi («lock overhead»). De plus, deux pièges nous sont tendus : goulots d étranglement séquentiels : de nombreux threads attendent pour prendre à tour de rôle un même verrou ; «huit peintres et un seul pot de peinture» compétition («contention») : plus les threads qui tentent de prendre un même verrou au même moment sont nombreux, plus cette opération est lente. «après vous, cher confrère ; je n en ferai rien» (Démonstrations.) 4 juin 2014 78 / 84
Questions d efficacité Il est souvent difficile de comprendre pourquoi un programme concurrent est efficace ou inefficace en pratique. Les comportements sont difficiles à reproduire et influencés par les observations! De plus, les performances peuvent dépendre : de la configuration de la machine (nombre de processeurs, taille et disposition des caches, etc.) ; de sa charge (pour un serveur, nombre de requêtes reçues par seconde, etc.). 4 juin 2014 79 / 84
Que retenir? La mémoire partagée pouvait sembler porteuse de promesses. On voit maintenant qu elle n a rien de miraculeux. Pour utiliser de façon cohérente la mémoire partagée, l exclusion est nécessaire, et est obtenue via des mécanismes de synchronisation, dont les verrous sont un exemple. 4 juin 2014 81 / 84
Que retenir? Les sont multiples : synchroniser trop peu conduit à des comportements incohérents ; synchroniser trop ou trop grossièrement conduit à une inefficacité, voire à un interblocage. 4 juin 2014 82 / 84