Quelle est la meilleure façon d'implémenter des compteurs globaux pour une application hautement concurrente? Dans mon cas, j'ai peut-être des routines 10K-20K Go qui effectuent du "travail", et je veux compter le nombre et les types d'éléments sur lesquels les routines travaillent collectivement ...
Le style de codage synchrone "classique" ressemblerait à:
var work_counter int
func GoWorkerRoutine() {
for {
// do work
atomic.AddInt32(&work_counter,1)
}
}
Maintenant, cela devient plus compliqué parce que je veux suivre le "type" de travail en cours, donc j'aurais vraiment besoin de quelque chose comme ceci:
var work_counter map[string]int
var work_mux sync.Mutex
func GoWorkerRoutine() {
for {
// do work
work_mux.Lock()
work_counter["type1"]++
work_mux.Unlock()
}
}
Il semble qu'il devrait y avoir une méthode optimisée de "go" en utilisant des canaux ou quelque chose de similaire à ceci:
var work_counter int
var work_chan chan int // make() called somewhere else (buffered)
// started somewher else
func GoCounterRoutine() {
for {
select {
case c := <- work_chan:
work_counter += c
break
}
}
}
func GoWorkerRoutine() {
for {
// do work
work_chan <- 1
}
}
Ce dernier exemple manque toujours la carte, mais c'est assez facile à ajouter. Ce style offrira-t-il de meilleures performances qu'un simple incrément atomique? Je ne peux pas dire si cela est plus ou moins compliqué lorsque nous parlons d'un accès simultané à une valeur globale par rapport à quelque chose qui peut bloquer les E/S pour terminer ...
Les pensées sont appréciées.
Mise à jour 28/05/2013:
J'ai testé quelques implémentations, et les résultats n'étaient pas ce à quoi je m'attendais, voici mon code source de compteur:
package helpers
import (
)
type CounterIncrementStruct struct {
bucket string
value int
}
type CounterQueryStruct struct {
bucket string
channel chan int
}
var counter map[string]int
var counterIncrementChan chan CounterIncrementStruct
var counterQueryChan chan CounterQueryStruct
var counterListChan chan chan map[string]int
func CounterInitialize() {
counter = make(map[string]int)
counterIncrementChan = make(chan CounterIncrementStruct,0)
counterQueryChan = make(chan CounterQueryStruct,100)
counterListChan = make(chan chan map[string]int,100)
go goCounterWriter()
}
func goCounterWriter() {
for {
select {
case ci := <- counterIncrementChan:
if len(ci.bucket)==0 { return }
counter[ci.bucket]+=ci.value
break
case cq := <- counterQueryChan:
val,found:=counter[cq.bucket]
if found {
cq.channel <- val
} else {
cq.channel <- -1
}
break
case cl := <- counterListChan:
nm := make(map[string]int)
for k, v := range counter {
nm[k] = v
}
cl <- nm
break
}
}
}
func CounterIncrement(bucket string, counter int) {
if len(bucket)==0 || counter==0 { return }
counterIncrementChan <- CounterIncrementStruct{bucket,counter}
}
func CounterQuery(bucket string) int {
if len(bucket)==0 { return -1 }
reply := make(chan int)
counterQueryChan <- CounterQueryStruct{bucket,reply}
return <- reply
}
func CounterList() map[string]int {
reply := make(chan map[string]int)
counterListChan <- reply
return <- reply
}
Il utilise des canaux pour les écritures et les lectures, ce qui semble logique.
Voici mes cas de test:
func bcRoutine(b *testing.B,e chan bool) {
for i := 0; i < b.N; i++ {
CounterIncrement("abc123",5)
CounterIncrement("def456",5)
CounterIncrement("ghi789",5)
CounterIncrement("abc123",5)
CounterIncrement("def456",5)
CounterIncrement("ghi789",5)
}
e<-true
}
func BenchmarkChannels(b *testing.B) {
b.StopTimer()
CounterInitialize()
e:=make(chan bool)
b.StartTimer()
go bcRoutine(b,e)
go bcRoutine(b,e)
go bcRoutine(b,e)
go bcRoutine(b,e)
go bcRoutine(b,e)
<-e
<-e
<-e
<-e
<-e
}
var mux sync.Mutex
var m map[string]int
func bmIncrement(bucket string, value int) {
mux.Lock()
m[bucket]+=value
mux.Unlock()
}
func bmRoutine(b *testing.B,e chan bool) {
for i := 0; i < b.N; i++ {
bmIncrement("abc123",5)
bmIncrement("def456",5)
bmIncrement("ghi789",5)
bmIncrement("abc123",5)
bmIncrement("def456",5)
bmIncrement("ghi789",5)
}
e<-true
}
func BenchmarkMutex(b *testing.B) {
b.StopTimer()
m=make(map[string]int)
e:=make(chan bool)
b.StartTimer()
for i := 0; i < b.N; i++ {
bmIncrement("abc123",5)
bmIncrement("def456",5)
bmIncrement("ghi789",5)
bmIncrement("abc123",5)
bmIncrement("def456",5)
bmIncrement("ghi789",5)
}
go bmRoutine(b,e)
go bmRoutine(b,e)
go bmRoutine(b,e)
go bmRoutine(b,e)
go bmRoutine(b,e)
<-e
<-e
<-e
<-e
<-e
}
J'ai implémenté un benchmark simple avec juste un mutex autour de la carte (juste des tests d'écriture), et j'ai testé les deux avec 5 goroutines fonctionnant en parallèle. Voici les résultats:
$ go test --bench=. helpers
PASS
BenchmarkChannels 100000 15560 ns/op
BenchmarkMutex 1000000 2669 ns/op
ok helpers 4.452s
Je ne m'attendais pas à ce que le mutex soit beaucoup plus rapide ...
D'autres réflexions?
N'utilisez pas sync/atomic - depuis la page liée
Package atomic fournit des primitives de mémoire atomique de bas niveau utiles pour implémenter des algorithmes de synchronisation. Ces fonctions nécessitent un grand soin pour être utilisées correctement. À l'exception des applications spéciales de bas niveau, la synchronisation est mieux effectuée avec les canaux ou les fonctionnalités du package de synchronisation
La dernière fois que j'ai dû faire ça J'ai comparé quelque chose qui ressemblait à votre deuxième exemple avec un mutex et quelque chose qui ressemblait à votre troisième exemple avec un canal. Le code des canaux a gagné lorsque les choses étaient vraiment occupées, mais assurez-vous que le tampon de canal est grand.
Si vous essayez de synchroniser un pool de travailleurs (par exemple, autorisez n goroutines à effectuer un certain travail), les canaux sont un très bon moyen de s'y prendre, mais si tout ce dont vous avez réellement besoin est un compteur (par exemple, les pages vues ) alors ils sont exagérés. Les packages sync et sync/atomic sont là pour vous aider.
import "sync/atomic"
type count32 int32
func (c *count32) increment() int32 {
return atomic.AddInt32((*int32)(c), 1)
}
func (c *count32) get() int32 {
return atomic.LoadInt32((*int32)(c))
}
N'ayez pas peur d'utiliser des mutex et des verrous simplement parce que vous pensez qu'ils ne sont pas "appropriés". Dans votre deuxième exemple, il est absolument clair ce qui se passe, et cela compte beaucoup. Vous devrez l'essayer vous-même pour voir à quel point ce mutex est satisfait et si l'ajout de complications augmentera les performances.
Si vous avez besoin de performances accrues, le sharding est peut-être la meilleure solution: http://play.golang.org/p/uLirjskGeN
L'inconvénient est que vos décomptes ne seront aussi à jour que si votre partage le décide. Il peut également y avoir autant de résultats de performance en appelant time.Since()
, mais, comme toujours, mesurez-le d'abord :)
L'autre réponse utilisant sync/atomic convient pour des choses comme les compteurs de pages, mais pas pour soumettre des identifiants uniques à une API externe. Pour ce faire, vous avez besoin d'une opération "incrémenter-et-retourner", qui ne peut être implémentée qu'en boucle CAS.
Voici une boucle CAS autour d'un int32 pour générer des ID de message uniques:
import "sync/atomic"
type UniqueID struct {
counter int32
}
func (c *UniqueID) Get() int32 {
for {
val := atomic.LoadInt32(&c.counter)
if atomic.CompareAndSwapInt32(&c.counter, val, val+1) {
return val
}
}
}
Pour l'utiliser, faites simplement:
requestID := client.msgID.Get()
form.Set("id", requestID)
Cela a un avantage sur les canaux car il ne nécessite pas autant de ressources inactives supplémentaires - les goroutines existantes sont utilisées car elles demandent des identifiants plutôt que d'utiliser une goroutine pour chaque compteur dont votre programme a besoin.
TODO: Benchmark par rapport aux canaux. Je vais deviner que les canaux sont pires dans le cas sans contention et meilleurs dans le cas à forte contention, car ils ont la file d'attente tandis que ce code tourne simplement pour tenter de gagner la course.
Vieille question mais je suis juste tombé dessus et cela peut aider: https://github.com/uber-go/atomic
Fondamentalement, les ingénieurs d'Uber ont construit quelques fonctions utiles de Nice en plus du sync/atomic
paquet
Je n'ai pas encore testé cela en production mais la base de code est très petite et l'implémentation de la plupart des fonctions est assez standard stock
Certainement préféré à l'utilisation de canaux ou de mutex de base
Le dernier était proche:
package main
import "fmt"
func main() {
ch := make(chan int, 3)
go GoCounterRoutine(ch)
go GoWorkerRoutine(1, ch)
// not run as goroutine because mein() would just end
GoWorkerRoutine(2, ch)
}
// started somewhere else
func GoCounterRoutine(ch chan int) {
counter := 0
for {
ch <- counter
counter += 1
}
}
func GoWorkerRoutine(n int, ch chan int) {
var seq int
for seq := range ch {
// do work:
fmt.Println(n, seq)
}
}
Cela introduit un seul point d'échec: si le contre-goroutine meurt, tout est perdu. Cela peut ne pas être un problème si tous les goroutine sont exécutés sur un seul ordinateur, mais peuvent devenir un problème s'ils sont dispersés sur le réseau. Pour rendre le compteur à l'abri des défaillances de nœuds uniques dans le cluster, algorithmes spéciaux doivent être utilisés.
J'ai implémenté cela avec une simple carte + mutex qui semble être la meilleure façon de gérer cela car c'est la "manière la plus simple" (c'est ce que Go dit d'utiliser pour choisir les verrous vs les canaux).
package main
import (
"fmt"
"sync"
)
type single struct {
mu sync.Mutex
values map[string]int64
}
var counters = single{
values: make(map[string]int64),
}
func (s *single) Get(key string) int64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.values[key]
}
func (s *single) Incr(key string) int64 {
s.mu.Lock()
defer s.mu.Unlock()
s.values[key]++
return s.values[key]
}
func main() {
fmt.Println(counters.Incr("bar"))
fmt.Println(counters.Incr("bar"))
fmt.Println(counters.Incr("bar"))
fmt.Println(counters.Get("foo"))
fmt.Println(counters.Get("bar"))
}
Vous pouvez exécuter le code sur https://play.golang.org/p/9bDMDLFBAY . J'ai fait une version simple emballée sur Gist.github.com