web-dev-qa-db-fra.com

La meilleure façon d'implémenter des compteurs mondiaux pour des applications hautement simultanées?

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?

39
Bocajim

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.

19
Nick Craig-Wood

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))
}

Go Playground Example

17
Aequitas

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 :)

8
Dijkstra

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.

4
Riking

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

3
Stefano Fratini

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.

2
keks

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

2
Xeoncross