Comment golang gère-t-il la mémoire ?

Cet article est une traduction de la page The Go Memory Model (en anglais sur le site du projet Go).

   Verrous

Introduction

Ce document – Le modèle de mémoire de Go – spécifie les conditions dans lesquelles on peut garantir que les lectures d'une variable dans une goroutine sont l'observation des écritures dans cette variable par une goroutine différente.

Advenir avant

Dans une goroutine en particulier, les lectures et les écritures doivent se comporter selon l'ordre d'exécution spécifié par le programme Go. C'est-à-dire, que les compilateurs et les processeurs ne peuvent changer l'ordre des lectures et des écritures exécutées dans cette goroutine seulement si ce changement de d'ordre d'exécution ne modifie pas le comportement de cette goroutine – comportement défini par les spécifications du langage Go. En raison d'un tel réarrangement, l'ordre d'exécution observé par une goroutine pourrait différer de l'ordre perçu par des autres goroutine. Par exemple, si une goroutine exécute a = 1; b = 2;, les autres goroutines pourraient observer la valeur mise à jour de la variable b avant la valeur mise à jour de la variable a.

Pour définir les conditions de lectures et d'écritures, nous définissons advenir avant, un ordre partiel d'exécution d'opérations sur la mémoire – opérations effectuées par un programme Go. Si l'événement e1 advient avant l'événement e2, alors on dit qu'e2 se produit après e1. En outre, si e1 ne se produit pas avant e2 et n'advient pas après e2, alors on peut dire qu'e1 et e2 se produisent concurremment.

Dans une goroutine en particulier, l'ordre advenir avant est l'ordre exprimé par le programme.


La lecture r d'une variable v n'est autorisée à observer une écriture w dans v seulement si les deux conditions suivantes sont remplies :

  1. r ne se produit pas avant w.
  2. Il n'y a pas d'autre écriture w' dans v qui advient après w, mais avant r.

Pour garantir que la lecture r d'une variable v observe une écriture particulière w dans v, assurez-vous que w est la seule écriture que r est autorisée à observer. C'est-à-dire que r est garanti d'observer w si les deux conditions suivantes sont remplies :

  1. w advient avant r.
  2. Toutes les autres écritures dans la variable v adviennent soit avant w ou après r.

Ces deux dernières conditions sont plus fortes que les deux premières ; elles exigent qu'il n'y a pas autres écritures advenant en même temps que w ou r.

Dans une goroutine en particulier, il n'y a aucune concurrence (au sein de la goroutine, les actions se déroulent séquentiellement), ainsi les deux définitions sont équivalentes : une lecture r observe la valeur écrite dans v par l'écriture w la plus récente. Lorsque plusieurs goroutines accèdent à une variable partagée v, elles doivent employer des événements de synchronisation afin d'établir les conditions advenir avant qui garantissent que les lectures observent l'écriture souhaitée.

L'initialisation de la variable v avec la valeur zéro (la valeur zéro varie en fonction du type de la variable v) est équivalente à une lecture dans le modèle mémoire que nous sommes en train de décrire dans cet article.

Les lectures et les écritures des valeurs plus grandes qu'un seul mot machine (la taille d'un mot machine dépend du modèle de processeur) se comportent comme plusieurs opérations manipulant un seul mot machine dans un ordre non spécifié (par exemple, l'entier de deux mots de 32 octets 2F221EA1BFB31102 peut être stocké 0211B3BFA11E222F sur certains ordinateurs, alors qu'il peut s'agir que d'un seul mot machine de 64 octets sur d'autres ordinateurs et qu'une autre stratégie de stockage en mémoire peut être adoptée sur d'autres machines).

Synchronisation

Initialisation

L'initialisation d'un programme s'exécute dans une seule goroutine, mais cette goroutine peut créer d'autres goroutines, qui fonctionnent concurremment.

Si un paquet p importe le paquet q, l'achèvement des fonctions init de q se produit avant le début de n'importe quelle autre fonction init de p.

Le début de la fonction main.main advient après que toutes les fonctions init soient terminées.

Création d'une goroutine

L'instruction go qui lance une nouvelle goroutine se produit avant que l'exécution de cette goroutine ne commence.


Par exemple, dans ce programme :

var a string

func f() {
	print(a)
}

func hello() {
	a = "hello, world"
	go f()
}

L'appel à la fonction hello affichera "hello, world" à un certain moment dans le futur (peut-être après le retour de la fonction hello).

Destruction d'une goroutine

Il n'est pas garanti que la sortie d'une goroutine se produise avant les autres événements du programme. Par exemple, dans ce programme :

var a string

func hello() {
	go func() { a = "hello" }()
	print(a)
}

L'affectation de la variable a n'est suivie d'aucun événement de synchronisation, ainsi il n'est pas garanti qu'elle soit observable une autre goroutine. En fait, un compilateur agressif pourrait supprimer l'intégralité de l'instruction go.
Si les effets d'une goroutine doivent être observés par une autre goroutine, il faut employer un mécanisme de synchronisation tel qu'un verrou ou un canal de communication afin d'établir un ordre relatif.

Communication par canal (type chan)

La communication par canal (avec le type chan) est la méthode principale de synchronisation entre les goroutines. Chaque envoi dans un canal particulier est associé à la réception correspondante depuis ce canal, habituellement dans une goroutine différente.

Un envoi dans un canal se produit avant que la réception correspondante ne soit reçue de ce canal.

Avec ce programme :
var c = make(chan int, 10)
var a string

func f() {
	a = "hello, world"
	c <- 0
}

func main() {
	go f()
	<-c
	print(a)
}

Nous avons la garantie que le message "hello, world" sera affiché. L'écriture dans la variable a se produit avant l'envoi dans le canal c – qui se produit avant que la réception correspondante dans le canal c ne s'accomplisse – ce qui advient avant l'affichage (avec print).

La fermeture d'un canal advient avant la réception d'une valeur à zéro parce que ce canal est fermé.

Dans l'exemple précédent, le fait de remplacer c <- 0 par close(c) produit un programme ayant ce comportement.

Une réception depuis un canal sans tampon (buffer) advient avant qu'un envoi dans ce canal ne soit terminé.

Avec ce programme (similaire au précédent, mais avec les instructions d'envoi et de réception permutées et en utilisant un canal sans tampon) :

var c = make(chan int)
var a string

func f() {
	a = "hello, world"
	<-c
}

func main() {
	go f()
	c <- 0
	print(a)
}

Nous avons également la garantie d'afficher "hello, world". L'écriture dans la variable a se produit avant l'envoi dans le canal c – qui se produit avant que la réception correspondante dans le canal c ne s'accomplisse – ce qui advient avant l'affichage (avec print).

Si le canal avait un tampon (par exemple, s'il était déclaré ainsi : c = make(chan int, 1)) alors il ne serait pas garanti que ce programme afficherait "hello, world" (Il pourrait afficher une chaîne de caractères vide, planter, ou faire autre chose).

Verrous

Le paquet sync implémente deux types de données de verrouillage, sync.Mutex et sync.RWMutex.

Pour toute variable l de type sync.Mutex ou sync.RWMutex et n précédent m, l'appel n de l.Unlock advient avant que l'appel m de l.Lock n'aboutisse.

Avec ce programme :

var l sync.Mutex
var a string

func f() {
	a = "hello, world"
	l.Unlock()
}

func main() {
	l.Lock()
	go f()
	l.Lock()
	print(a)
}

Nous avons la garantie que le message "hello, world" sera affiché. Le premier appel à l.Unlock() (dans la fonction f) se produit avant que le deuxième appel à l.Lock() (dans la fonction main) n'aboutisse – ce qui se produit avant l'affichage (avec la fonction print).

Pour tout appel à l.RLock sur une variable l de type sync.RWMutex, il existe n tel que l.RLock advienne (termine son exécution) après l'appel n à l.Unlock et que l'appel correspondant à l.RUnlock advienne avant l'appel n + 1 à l.Lock

Le type Once

Le paquet sync fournit un mécanisme sûr pour l'initialisation, en la présence de multiples goroutines, grâce à l'utilisation du type Once. Plusieurs threads peuvent exécuter once.Do(f) pour une fonction f particulière, mais une seule instance de la fonction f() sera exécutée, et les autres appels seront bloqués jusqu'à ce que f() ait fini son exécution.

Un seul appel de f() par once.Do(f) advient (retourne de son exécution) avant que tout autre appel par once.Do(f) n'advienne.

Dans ce programme :

var a string
var once sync.Once

func setup() {
	a = "hello, world"
}

func doprint() {
	once.Do(setup)
	print(a)
}

func twoprint() {
	go doprint()
	go doprint()
}

Appeler la fonction twoprint provoque par deux fois l'affichage de "hello, world". Le premier appel à doprint ne lance qu'une fois la fonction setup.

Synchronisation incorrecte

Notez qu'une lecture r peut observer la valeur écrite par une écriture w qui arrive en même temps que r. Même si cela se produit, cela ne signifie pas que lectures advenant après r observeront les écritures qui ont eu lieu avant w.

Dans ce programme:

var a, b int

func f() {
	a = 1
	b = 2
}

func g() {
	print(b)
	print(a)
}

func main() {
	go f()
	g()
}

Il peut arriver que la fonction g affiche 2 puis 0.

Ce fait infirme quelques pratiques pourtant communes.

Le verrouillage à double vérification est une tentative pour éviter la surcharge de synchronisation. Par exemple, le programme twoprint pourrait être incorrect s'il était écrit ainsi :

var a string
var done bool

func setup() {
	a = "hello, world"
	done = true
}

func doprint() {
	if !done {
		once.Do(setup)
	}
	print(a)
}

func twoprint() {
	go doprint()
	go doprint()
}

Mais il n'y a aucune garantie que, dans doprint, observation de l'écriture de la variable done implique d'observer l'écriture de la variable a. Cette version peut (à tort) afficher une chaîne vide au lieu de "hello, world".

Une autre mauvaise pratique consiste à attendre une valeur une valeur en accaparant le processeur, comme dans :

var a string
var done bool

func setup() {
	a = "hello, world"
	done = true
}

func main() {
	go setup()
	for !done {
	}
	print(a)
}

Comme précédemment, il n'y a aucune garantie que, dans la fonction main, le fait d'observer l'écriture de la variable done implique d'observer l'écriture de la variable a. Donc ce programme pourrait également afficher une chaîne vide. Pire encore, il n'existe aucune garantie que l'écriture de la variable done sera observée dans la fonction main, car il n'y a pas d'événement de synchronisation entre les deux threads. Il n'y a aucune garantie que la boucle principale se termine.

Il existe des variantes subtiles sur ce thème, comme ce programme :

type T struct {
	msg string
}

var g *T

func setup() {
	t := new(T)
	t.msg = "hello, world"
	g = t
}

func main() {
	go setup()
	for g == nil {
	}
	print(g.msg)
}

Même si la fonction main observe g != nil et sort de sa boucle, il n'y a aucune garantie que cela observera la valeur initialisée pour g.msg.

Dans tous ces exemples, la solution est la même : utiliser la synchronisation explicite.


Étiquettes :   specification 
Portrait de Benjamin BALET
Benjamin BALET
Consultant APM

Retrouvez mes cooordonées

Benjamin BALET sur viadeo






Vous aimerez aussi

Les lois de la réflexion

Une traduction du blog officiel de golang expliquant le mécanisme de la réflexion en Go.   Lire »

Gobs le format natif d'échange de données en Go

Traduction d'un article du blog officiel expliquant comment échanger des données entre deux programmes golang grâce à un format natif   Lire »

Canaux et go routines avec ou sans état

Exemples d'utilisation du type chan et des go routines stateful et stateless   Lire »

Comment écrire du code Go ?

Traduction d'une partie des spécifications officielles du langage Go, cet article explique comment développer en Go.   Lire »

Comment gérer efficacement les erreurs en golang ?

Préconisations officielles pour la gestion des erreurs dans un programme golang. Cet article complète les explications sur panic, defer et recover   Lire »

Commentaires

Soyez le premier à commenter cet article

Publier un commentaires

Tous les commentaires sont soumis à modération. Les balises HTML sont pour la plupart autorisées. Les liens contextuels et intéressants sont en follow.

(requis)
(requis)
(requis, mais non publié)
(recommandé si vous souhaitez être publié)