Go by Example: Stateful Goroutines

En el ejemplo anterior utilizamos bloqueo explícito con mutex para sincronizar el acceso al estado compartido a través de múltiples goroutines. Otra opción es usar las características de sincronización incorporadas de las goroutines y canales para lograr el mismo resultado. Este enfoque basado en canales se alinea con las ideas de Go de compartir memoria mediante comunicación y tener cada pieza de datos propiedad de exactamente 1 goroutine.

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

En este ejemplo, nuestro estado será propiedad de una única goroutine. Esto garantizará que los datos nunca se corrompan con acceso concurrente. Para leer o escribir ese estado, otras goroutines enviarán mensajes a la goroutine propietaria y recibirán las respuestas correspondientes. Estos readOp y writeOp structs encapsulan esas solicitudes y una forma para que la goroutine propietaria responda.

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

Como antes, contaremos cuántas operaciones realizamos.

    var readOps uint64
    var writeOps uint64

Los canales reads y writes serán utilizados por otras goroutines para emitir solicitudes de lectura y escritura, respectivamente.

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

Aquí está la goroutine que posee el state, que es un mapa como en el ejemplo anterior pero ahora privado para la goroutine con estado. Esta goroutine selecciona repetidamente entre los canales reads y writes, respondiendo a las solicitudes a medida que llegan. Una respuesta se ejecuta primero realizando la operación solicitada y luego enviando un valor en el canal de respuesta resp para indicar éxito (y el valor deseado en el caso de 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
            }
        }
    }()

Esto inicia 100 goroutines para emitir lecturas a la goroutine propietaria del estado a través del canal reads. Cada lectura requiere construir un readOp, enviarlo a través del canal reads y luego recibir el resultado a través del canal resp proporcionado.

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

Iniciamos también 10 escrituras, utilizando un enfoque similar.

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

Dejamos que las goroutines trabajen durante un segundo.

    time.Sleep(time.Second)

Finalmente, capturamos e informamos los conteos de operaciones.

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

Al ejecutar nuestro programa se muestra que el ejemplo de gestión de estado basado en goroutines completa alrededor de 80,000 operaciones en total.

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

Para este caso particular, el enfoque basado en goroutines fue un poco más complicado que el basado en mutex. Sin embargo, podría ser útil en ciertos casos, por ejemplo, cuando se tienen otros canales involucrados o cuando la gestión de múltiples mutex sería propensa a errores. Debes usar el enfoque que te parezca más natural, especialmente en cuanto a la comprensión de la corrección de tu programa.

Siguiente ejemplo: Sorting.