LEARN | Golang - Concurrence
Contenu
Contenu
L’un des avantages de Golang est le support natif du language pour la concurrence. Dans la plupart des autres languages, il est nécessaire d’utiliser une librairie externe pour assurer cette fonction.
Théorie
Parallélisme
C’est lorsque deux tâches s’exécutent en même temps.
Il est obligatoire pour cela d’avoir du matériel hardware répliqué :
- Soit plusieurs machines
- Soit une même machine avec un processeur multi-coeurs
Concurrence
C’est lorsque deux tâches ont un début et une fin qui se chevauchent mais ne s’éxécutent pas forcément en même temps.
Note
Il est possible d’éxécuter une tâche en concurrence sur un seul coeur.
Concurrent Programming
Le concurrent programming consiste à indiquer au programme comment se répartir sur les différents coeurs
- Le programmeur défini quelles tâches peuvent être exécutées en parallèle
- C’est ensuite l’OS et Go runtime scheduler qui vont paralléliser ou non la tâche (hardware mapping)
Pourquoi utiliser la concurrence ?
La concurrence améliore les performances.
- Réduction de la hidden latency: une tâche peut s’exécuter pendant qu’une autre attend (accès mémoire)
Note
Définitions
- Process: c’est une instance d’exécution d’une application (par exemple un process qui fait tourner votre navigateur).
- Thread: c’est comme un process mais plus léger, il peut faire tout ce qu’un process fait
Un process peut contenir plusieurs threads.
Le passage d’un process à un autre s’appelle le context switch pendant lequel le state du process va être sauvegardé le temps que l’autre process s’éxécute.
Le thread lui partage le state de son process. La concurrence entre threads sera donc plus rapide et plus performante pour des petites tâches.
Goroutines
Les goroutines agissent comme des threads dans l’application Go.
C’est encore une division: un process a plusieurs threads et un thread qui a plusieurs go routines.
Ces goroutines vont être schedulées par le Go Runtime Scheduler afin de gérer la concurrence.
Note
La fonction main()
est une goroutine.
Lorsque la goroutine main()
se termine, toutes les goroutines se terminent aussi.
Interleavings
Prenons deux tâches qui ont 3 instructions chacunes :
- 1A, 1B, 1C
- 2A, 2B, 2C
Ces deux tâches exécutées en concurrence pourront donner plusieurs interleavings:
- 1A, 1B, 1C, 2A, 2B, 2C
- 1A, 2A, 1B, 2B, 1C, 2C
- …
L’odre des instructions est donc inconnu et non déterministe.
Note
En programmation, il faut éviter d’avoir un résultat qui dépend d’un facteur non-déterministe.
Nous souhaitons avoir le même résultat à chaque fois.
Race Condition
Lorsqu’un programme dépend de l’ordre des instructions entre deux tâches (interleavings), nous avons un problème de race condition.
Cela signifie que le résultat de ce programme dépendra de l’ordre dans lequel s’exécute les instructions.
Il existe des techniques permettant d’éviter ce problème.
Synchronisation
La synchronisation des goroutines en Go est essentielle pour garantir la cohérence et la sécurité dans les applications concurrentes.
Voyons les principaux mécanismes de synchronisation.
Mutex
L’accès et l’écriture sur les variables partagée ne peux pas être interleaved, pour cela on utilise la Mutual Exclusion.
sync.Mutex
assure la mutual exclusion grâce à un système de vérrouillage sur la variable (ou sémaphore binaire).
Lock()
va vérouiller la variable, si une goroutine essaie d’accéder à cette variable déjà vérouillée, elle sera bloquée.Unlock()
va dévérouiller la variable, la goroutine qui était bloquée viaLock()
pourra à son tour accéder à la variable.
|
|
WaitGroup
sync.WaitGroup
oblige une goroutine à attendre les autres goroutines.
Il s’agit tout simplement d’un compteur:
Add()
On incrémente le compteur pour chaque goroutine à attendreDone()
On décrémente le compteur pour chaque goroutine qui se termineWait()
La goroutine qui attend est bloquée jusqu’à ce le compteur est à 0
|
|
Semaphore
Un sémaphore est un mécanisme de synchronisation qui permet de contrôler l’accès concurrent à des ressources partagées.
Il agit comme un compteur flexible pour gérer les goroutines en fonction de la capacité définie :
Acquire()
permet d’acquérir une unité de capacité du sémaphore. Si le sémaphore est plein, la goroutine attend jusqu’à ce qu’une unité devienne disponible.Release()
permet de libérer une unité de capacité du sémaphore, ce qui permet à une autre goroutine d’acquérir cette unité.
|
|
Le sémaphore est utilisé pour contrôler le niveau de parallélisme.
Note
Channels
La communication entre goroutines se fait par des channels.
Un channel permet de transférer des données entre goroutines :
- Les channels sont typés
- On initialise un channel:
c := make(chan int)
- Envoi de données:
c <- 3
- Réception de données:
x := <- c
Unbuffered Channels
Les channels sans buffer ne peuvent pas garder la donnée en transit, ce qui signifie qu’ils sont bloquants jusqu’à ce que la donnée soit consommée ou envoyée.
- L’envoi bloque la goroutine tant que la donnée en transit n’est pas consommée
- La réception bloque la goroutine tant que la donnée en transit n’est pas envoyée
|
|
Astuce
Buffered Channels
Les channels peuvent garder un nombre d’objets en transit (par défaut 0 = unbuffered), c’est la capacité.
c := make(chan int, 3)
- L’envoi bloque la goroutine que si le buffer est rempli
- La réception bloque la goroutine que si le buffer est vide
Exemple
L’intérêt du buffer est de permettre au producteur et au consommateur de produire et consommer à leur rythme (backpressure/throttling).
Blocking Channels
Il est possible de bloquer la goroutine main()
de cette façon :
|
|
Avertissement
close(waitc)
pour débloquer la goroutine main()
Itération sur channel
Un cas fréquent d’itérer sur un channel afin de traiter toutes les données dans une boucle.
|
|
Lorsque le sender n’a plus de données à traiter, il peut fermet le channel pour indiquer au receiver qu’il peut arrêter d’itérer close(c)
Select
select
permet à une goroutine d’attendre parmi plusieurs opérations de channels.
La goroutine sera bloquée jusqu’à ce que l’un des cas puisse s’exécuter, puis il exécute ce cas.
|
|
Note
Il est également possible de sélectionner une opération send
ou receive
.
|
|
Ces deux cas sont bloquants, et le premier cas qui arrive sera executé.
Default Select
Il est possible d’avoir une opération par défaut afin d’éviter de bloquer.
Abort Channel
Il est possible d’abandonner l’exécution d’une boucle for grâce à un channel abort
dans un select
.
|
|
Note
abort
est reçu.Partage de variable
Règle Générale
Granularité de la concurrence
La concurrence se fait au niveau de la machine.
Ce qui veut dire qu’une instruction comme i = i + 1
va être décomposée comme cela :
read i
increment
write i
Il est possible que la valeur récupérée en mémoire par la goroutine ne soit pas la bonne.
Astuce
Initialisation
Une initialisation doit:
- Être exécutée une seule fois
- Être exécutée en premier, avant toutes les autres instructions
Sync Once
once.Do(f)
garantie que la fonction f()
ne sera exécutée :
- qu’une seule fois même si elle est appelée dans plusieurs goroutines
- en premier, avant tout autre instruction (les goroutines vont être bloquées en attendant la fin de
f()
|
|
Deadlock
Synchronization Dependencies
La synchronisation rend les goroutines dépendantes entre elles.
- Par les channels:
G1: ch <- 1
etG2: x := <- ch
- Par les mutex:
G1: mut.Unlock()
etG2: mut.Lock()
Dans les deux cas, la goroutine G2 attend la goroutine G1.
Circular Dependencies
La dépendance circulaire cause le blockage de toutes les goroutines impliquées :
- Par les channels et les mutex
- G1 attend G2
- G2 attend G1
|
|
Avertissement
Go Runtime va détecter ce genre de deadlock et retourner une erreur fatale.
Cependant, il ne détectera pas de deadlock dans des sous goroutines.
C’est donc au programmeur de faire attention à ce genre de dépendances cycliques.