Go Em Exemplos: Stateful Goroutines

No exemplo anterior foi utilizado travamento explícito com mutexes para sincronizar acesso compartilhado a estados entre múltiplas goroutines. Outra opção é utilizar recursos de sincronização nativa das goroutines e canais para atingir o mesmo objetivo. Esta forma baseada em canais está alinhada com as ideias de comunicação através do compartilhamento de memória de Go, de forma que cada dado seja acessado por exatamente uma goroutine.

package main
import (
    "fmt"
    "math/rand"
    "sync/atomic"
    "time"
)

Neste exemplo o estado será pertencente a uma única goroutine (proprietária). Isso garante que o dado nunca seja corrompido com acesso concorrente. Para ler ou escrever neste estado, outras goroutines enviarão requisições para a goroutine proprietária e receberão as respostas correspondentes. As structs readOp e writeOp encapsulam estas requisições e uma forma para a goroutine proprietária responder.

type readOp struct {
    key  int
    resp chan int
}
type writeOp struct {
    key  int
    val  int
    resp chan bool
}
func main() {

Serão contadas quantas operações são realizadas.

    var readOps uint64
    var writeOps uint64

Os canais reads e writes serão utilizados por outras goroutines para emitir requisições de leitura e escrita respectivamente.

    reads := make(chan readOp)
    writes := make(chan writeOp)

Aqui está a goroutine que possui o estado, que é um map, como no exemplo anterior, mas agora privado à stateful goroutine. Esta goroutine possui um select que, repetidamente verifica os canais reads e writes, e responde aos requests conforme são recebidos. A execução acontece primeiro realizando a operação solicitada e então enviando o valor no canal de respostas resp para indicar sucesso (e o valor desejado no caso de ser uma operação de leitura reads).

    go func() {
        var state = make(map[int]int)
        for {
            select {
            case read := <-reads:
                read.resp <- state[read.key]
            case write := <-writes:
                state[write.key] = write.val
                write.resp <- true
            }
        }
    }()

Este trecho inicia 100 goroutines que solicitam leituras para a goroutine proprietária do estado, via canal reads. Cada leitura requer a construção da struct readOp, bem como envio da operação pelo canal reads e, então, receber o resultado pelo canal resp.

    for r := 0; r < 100; r++ {
        go func() {
            for {
                read := readOp{
                    key:  rand.Intn(5),
                    resp: make(chan int)}
                reads <- read
                <-read.resp
                atomic.AddUint64(&readOps, 1)
                time.Sleep(time.Millisecond)
            }
        }()
    }

Aqui são iniciadas 10 escritas de forma similar à leitura.

    for w := 0; w < 10; w++ {
        go func() {
            for {
                write := writeOp{
                    key:  rand.Intn(5),
                    val:  rand.Intn(100),
                    resp: make(chan bool)}
                writes <- write
                <-write.resp
                atomic.AddUint64(&writeOps, 1)
                time.Sleep(time.Millisecond)
            }
        }()
    }

O time.Sleep serve apenas para deixar as goroutines trabalharem por um segundo.

    time.Sleep(time.Second)

Finalmente, as operações realizadas são capturadas e a contagem reportada.

    readOpsFinal := atomic.LoadUint64(&readOps)
    fmt.Println("readOps:", readOpsFinal)
    writeOpsFinal := atomic.LoadUint64(&writeOps)
    fmt.Println("writeOps:", writeOpsFinal)
}

Ao executar este código, é exibido que o exemplo de gerenciamento de estado baseado em goroutine completa cerca de 80.000 operações no total.

$ go run stateful-goroutines.go
readOps: 71708
writeOps: 7177

Para este caso em particular, o exemplo baseado em goroutines é um pouco mais acoplado que o baseado em mutex. Embora possa ser útil em alguns casos, como por exemplo onde exista outros canais envolvidos ou ao gerenciar múltiplos mutex, que seria mais propenso a erros. O correto é utilizar a forma que for mais natural, especialmente no que diz respeito à comprensão da forma que faça mais sentido para a realidade do código.

Próximo exemplo: Sorting.