• Aucun résultat trouvé

Synchronisation entre coprocessus : les verrous

Les fonctions de cette section sont d´efinies dans le module Mutex (pour Mutual exclusion, en anglais).

Nous avons ´evoqu´e ci-dessus un probl`eme d’acc`es concurrent aux ressources mutables. En particulier, le sc´enario suivant illustre le probl`eme d’acc`es aux ressources partag´ees. Consid´erons

Temps

Coprocessus p lit k ´ecrit k + 1

−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−→ Coprocessus q lit k ´ecrit k + 1

Valeur de c k k k+1 k+1

Figure7.1 – Comp´etition pour l’acc`es `a une ressource partag´ee.

un compteur c et deux processus p et q, chacun incr´ementant en parall`ele le mˆeme compteur c. Supposons le sc´enario d´ecrit dans la Figure 7.1. Le coprocessus p lit la valeur du compteur c, puis donne la main `a q. `A son tour, q lit la valeur de c puis ´ecrit la valeur k + 1 dans c. Le processus p reprend la main et ´ecrit la valeur k + 1 dans c. La valeur finale de c est donc k + 1 au lieu de k + 2.

Ce probl`eme classique peut ˆetre r´esolu en utilisant des verrous qui empˆechent l’entrelacement arbitraire de p et q.

Les verrous sont des objets partag´es par l’ensemble des coprocessus d’un mˆeme programme qui ne peuvent ˆetre poss´ed´es que par un seul coprocessus `a la fois. Un verrou est cr´e´e par la fonction create.

val Mutex.create : unit -> Mutex.t

Cette op´eration ne fait que construire le verrou mais ne le bloque pas. Pour prendre un verrou d´ej`a existant, il faut appeler la fonction lock en lui passant le verrou en argument. Si le verrou est poss´ed´e par un autre processus, alors l’ex´ecution du processus appelant est gel´ee jusqu’`a ce que le verrou soit lib´er´e. Sinon le verrou est libre et l’ex´ecution continue en empˆechant tout autre processus d’acqu´erir le verrou jusqu’`a ce que celui-ci soit lib´er´e. La lib´eration d’un verrou doit se faire explicitement par le processus qui le poss`ede, en appelant unlock.

val Mutex.lock : Mutex.t -> unit val Mutex.unlock : Mutex.t -> unit

L’appel syst`eme Mutex.lock se comporte comme Thread.join vis-`a-vis des signaux : si le coprocessus re¸coit un signal pendant qu’il ex´ecute Mutex.lock, le signal sera pris en compte (i.e. le runtime OCaml inform´e du d´eclanchement du signal), mais le coprocessus se remet en attente de telle fa¸con que Mutex.lock ne retourne effectivement que lorsque le lock a effectivement ´et´e acquis et bien sˆur Mutex.lock ne levera pas l’exception EINTR. Le traitement r´eel du signal par OCaml se fera seulement au retour de Mutex.lock.

On peut ´egalement tenter de prendre un verrou sans bloquer en appelant try_lock val Mutex.try_lock : Mutex.t -> bool

Cette fonction retourne true si le verrou a pu ˆetre pris (il ´etait donc libre) et false sinon. Dans ce dernier cas, l’ex´ecution n’est pas suspendue, puisque le verrou n’est pas pris. Le coprocessus peut donc faire autre chose et ´eventuellement revenir tenter sa chance plus tard.

Exemple: Incr´ementer un compteur global utilis´e par plusieurs coprocessus pose un probl`eme de synchronisation : les instants entre la lecture de la valeur du compteur et l’´ecriture de la valeur incr´ement´ee sont dans une r´egion critique, i.e. deux coprocessus ne doivent pas ˆetre en mˆeme temps dans cette r´egion. La synchronisation peut facilement ˆetre g´er´ee par un verrou.

1 type counter = { lock : Mutex.t; mutable counter : int } 2 let newcounter() = { lock = Mutex.create(); counter = 0 } 3 let addtocounter c k =

5 c.counter <- c.counter + k; 6 Mutex.unlock c.lock;;

La seule consultation du compteur ne pose pas de probl`eme. Elle peut ˆetre effectu´ee en parall`ele avec une modification du compteur, le r´esultat sera simplement la valeur du compteur juste avant ou juste apr`es sa modification, les deux r´eponses ´etant coh´erentes.

Un motif fr´equent est la prise de verrou temporaire pendant un appel de fonction. Il faut bien sˆur prendre soin de le relˆacher `a la fin de l’appel que l’appel ait r´eussi ou ´echou´e. On peut abstraire ce comportement dans une fonction de biblioth`eque :

let run_with_lock l f x =

Mutex.lock l; try_finalize f x Mutex.unlock l Dans l’exemple pr´ec´edent, on aurait ainsi pu ´ecrire :

let addtocounter c =

Misc.run_with_lock c.lock (fun k -> c.counter <- c.counter + k)

Exemple: Une alternative au mod`ele du serveur avec coprocessus est de lancer un nombre de coprocessus `a l’avance qui traitent les requˆetes en parall`ele.

val tcp_farm_server :

int -> (file_descr -> file_descr * sockaddr -> ’a) -> sockaddr -> unit La fonction tcp_farm_server se comporte comme tcp_server mais prend un argument suppl´e- mentaire qui est le nombre de coprocessus `a lancer qui vont chacun devenir serveurs sur la mˆeme adresse. L’int´erˆet d’une ferme de coprocessus est de r´eduire le temps de traitement de chaque connexion en ´eliminant le temps de cr´eation d’un coprocessus pour ce traitement, puisque ceux- ci sont cr´e´es une fois pour toute.

7 let tcp_farm_server n treat_connection addr =

8 let server_sock = Misc.install_tcp_server_socket addr in 9 let mutex = Mutex.create() in

10 let rec serve () = 11 let client =

12 Misc.run_with_lock mutex

13 (Misc.restart_on_EINTR accept) server_sock in 14 treat_connection server_sock client;

15 serve () in

16 for i = 1 to n-1 do ignore (Thread.create serve ()) done; 17 serve ();;

La seule pr´ecaution `a prendre est d’assurer l’exclusion mutuelle autour de accept afin qu’un seul des coprocessus n’accepte une connexion au mˆeme moment. L’id´ee est que la fonction treat_connectionfasse un traitement s´equentiel, mais ce n’est pas une obligation et on peut effectivement combiner une ferme de processus avec la cr´eation de nouveaux coprocessus, ce qui peut s’ajuster dynamiquement selon la charge du service.

La prise et le relˆachement d’un verrou est une op´eration peu coˆuteuse lorsqu’elle r´eussit sans bloquer. Elle est en g´en´eral impl´ement´ee par une seule instruction≪test-and-set≫que poss`edent

tous les processeurs modernes (plus d’autres petits coˆuts induits ´eventuels tels que la mise `a jour des caches). Par contre, lorsque le verrou n’est pas disponible, le processus doit ˆetre suspendu et reprogramm´e plus tard, ce qui induit alors un coˆut suppl´ementaire significatif. Il faut donc retenir que c’est la suspension r´eelle d’un processus pour donner la main `a un autre et non sa suspension potentielle lors de la prise d’un verrou qui est p´enalisante. En cons´equence, on aura

presque toujours int´erˆet `a relˆacher un verrou d`es que possible pour le reprendre plus tard si n´ecessaire plutˆot que d’´eviter ces deux op´erations, ce qui aurait pour effet d’agrandir la r´egion critique et donc la fr´equence avec laquelle un autre coprocessus se trouvera effectivement en comp´etition pour le verrou et dans l’obligation de se suspendre.

Les verrous r´eduisent l’entrelacement. En contrepartie, ils risquent de provoquer des situa- tions d’interblocage. Par exemple, il y a interblocage si le coprocessus p attend un verrou v poss´ed´e par le coprocessus q qui lui-mˆeme attend un verrou u poss´ed´e par p. (Dans le pire des cas, un processus attend un verrou qu’il poss`ede lui-mˆeme...) La programmation concurrente est difficile, et se pr´emunir contre les situations d’interblocage n’est pas toujours facile. Une fa¸con simple et souvent possible d’´eviter cette situation consiste `a d´efinir une hi´erarchie entre les verrous et de s’assurer que l’ordre dans lequel on prendra dynamiquement les verrous respecte la hi´erarchie : on ne prend jamais un verrou qui n’est pas domin´e par tous les verrous que l’on poss`ede d´ej`a.