DEV Community

Cover image for Concurrencia en Golang II
Tomas Francisco Lingotti
Tomas Francisco Lingotti

Posted on • Edited on

Concurrencia en Golang II

|1. | Modelo de concurrencia
|2. | Goroutines y canales
|3. | Wait Groups
|4. | Select y Worker pools
|5. | ErrGroup (error groups)

Goroutines e hilos recap

En el post anterior, vimos brevemente una comparación entre las goroutines y los threads, que no va mucho más allá, pero si para refrescar un poco la memoria vamos a repasarla:
Las rutinas: Son más livianas ( < 2 kb) por que no necesitan tanto espacio en RAM ni los datos en el registro para que el sistema operativo pueda identificarlo.
Son 100% manejadas por el runtime de Go.
Se comunican por medio de canales con otras rutinas.
Los Hilos: Son propiedad del sistema operativo.
Pesan alrededor de 1mb, tienen que mantener muchos datos para que el sistema operativo los identifique y pueda saber su estado.
La comunicación entre dos hilos es un proceso costoso, si bien excede a este post, pueden buscar más información buscándolo como "Context Switch".

Cómo ejecutar una rutina en Go

El lenguaje nos ofrece una mecánica muy simple que es agregar la palabra reservada go por delante de la función o método que queremos ejecutar.
package main

import "fmt"

func main () {
    go fmt.Println("hello world")
}
Enter fullscreen mode Exit fullscreen mode

Claramente si ejecuta ese código, no va mostrar nada, o si, no lo se. Lo que si se es que no podemos garantizar nada.
¿Por qué no podemos ver el resultado?
La respuesta es fácil, pero por detrás se oculta toda la lógica de procesamiento de rutinas. Primero, hay que destacar que siempre al menos vamos a correr una rutina, cada vez que ejecutemos un programa en Go. Esta es la rutina principal que viene con la función main. Cada sentencia go que agreguemos, va a sumar una más a la existente.
Ya sabiendo que tenemos siempre una rutina (main) y que el runtime es quien se dedica, en parte, a dar el lugar de ejecución y mantener el ciclo de vida de las rutinas, es ahí donde tenemos la certeza de no garantía de ejecución; en otras palabras si al runtime se le ocurre correr la rutina con el Print del "hello world" puede que lo veamos en pantalla, aunque sigue siendo un proceso no determinista.
Vamos a plantear una solución rápida, usando la lib de golang, que es la funcion Sleep(), para que dentro de un tiempo razonable, le damos la oportunidad al scheduler que nos ejecute la goroutine y veamos el mensaje en pantalla (recuerden nunca hacer esto en un programa productivo, jamás)

package main

import (
         "fmt"
         "time"
)

func main () {
    go fmt.Println("hello world")

    time.Sleep(5 * time.Second)
}
Enter fullscreen mode Exit fullscreen mode

Listo, ya le dimos 5 segundos, más que suficiente para que todo comience y termine.
Más adelante vamos a ver cómo podemos usar waitgroups, que es el mecanismo correcto para asegurarnos la finalización de los procesos.

Anatomía de los canales

Un canal es un tipo de dato nativo de Golang. se declara con la palabra reservada chan y tenemos que especificarle "de que va a ser ese canal", además tenemos otro dato opcional que especifica el tamaño (o buffer) del canal, es decir, que tantos mensajes puede tener en su buffer hasta que la escritura sea bloqueante.
Se crean con la función interna new().
Un ejemplo podría ser:
ch := make(chan string) // sin buffer
bufCh := make(chan string, 5) // con un buffer de 5 mensajes

La principal diferencia radica en el bloqueo a la hora de la lectura/escritura. Empecemos con los channels sin buffer o sin capacidad definida.
Repasamos qué es un bloqueo a nivel de goroutine, que es algo grave y de lo que el programa no va a poder recuperarse fácilmente. En esencia, la rutina va a quedar bloqueada, hasta que pueda ser desbloqueada (ya sea con una escritura o lectura del canal para que libere al canal y la rutina que no pudo operar), pero en caso de que las "rutinas que iban a desbloquear", hayan terminado, estamos en la presencia de un deadlock, y ahi si la ejecución de nuestro programa se detiene.
Para la escritura, va a ser siempre blocking, es decir que se va a completar la operación cuando la lectura se lleva a cabo, es la razón por la que los canales sin capacidad definida, tienen que escribirse en goroutines separadas a las de lectura.
Para la lectura también es blocking mientras no haya mensajes para leer. Entonces, si volvemos a la definición de bloqueo, una rutina podría quedar bloqueada si no tenemos bien desarrollado que primero escriba y después acceda a leer. En caso de que nos quede al revés, tenemos un deadlock.
Por eso decimos que los canales unbuffered son sincrónicos porque entre los dos, deben esperar ambas operaciones sucedan para realizar la comunicación.
Ahora vamos con los buffered:

Para la escritura, van a ser non-blocking a menos que el buffer esté lleno, en ese caso vamos a tener que "esperar" que otra rutina lea un mensaje para que salga del buffer, y ahí volvemos a tener espacio para uno más. En el ejemplo de arriba, en caso de que nadie los lea, el mensaje numero 6, no se podría efectivizar y la rutina quedaría bloqueada.
Para la lectura, también va a ser non-blocking a menos que el canal esté vacío.
Por eso decimos que la comunicación es asíncrona para los canales con capacidad definida.

Sintaxis para leer y escribir

Vamos directo al código para escribir:

func DoCalc(factor int, ch chan int) {
   value := db.GetValue() // tomamos un valor de la db
   // La sintaxis para trabajar en el canal es con el operador "<-".
   // Nos indica la dirección en la que se escribe el valor, por ej (ch <- "value"), vamos a escribir sobre canal, le estamos mandando un valor.
   // Por otro lado, el _channel_ puede asignar valores a variables, estructuras, etc, dependiendo de lo que contenga, por ejemplo (value := <- ch), decimos que leemos de un canal.
   ch <- value * int
}
Enter fullscreen mode Exit fullscreen mode

Para la lectura es similar, aunque tenemos variantes
Podemos hacerlo con el operador, nos va a traer el dato, pero además podemos recibir un tipo bool, para saber si realmente fue una lectura exitosa o si el canal estaba cerrado, tenemos un zero value.

v, ok := <- ch
    // donde v es el valor del canal y ok nos permite saber si la lectura fue exitosa o no.

Enter fullscreen mode Exit fullscreen mode

Es el mismo ejemplo pero sin el OK, no es necesario pedirlo. En algunos casos más recomendado que en otros.

v := <- ch
Enter fullscreen mode Exit fullscreen mode

Es importante saber que tipo de canal vamos a usar (con capacidad o sin capacidad) y eso nos va a ayudar a estructurar mejor los mecanismos de lectura/escritura para evitar bloqueos. Aunque la mayoría se pueden solucionar con tests, pero siempre es conveniente tener los conceptos claros para saber lo que estamos haciendo.

Conclusiones

Tenemos las goroutines que se ejecutan fácilmente con la keyword go y una función o clausura, es rutina corre sobre un hilo (thread) del sistema operativo. La rutina principal es Main y siempre se ejecuta, cuando esta se corta, le comunica al sistema operativo si fue con código 0 (sin errores) o distinto de 0 dependiendo del error ocurrido.
Por otro lado, tenemos los canales que es el medio de comunicación de las rutinas. también es un tipo de dato nativo de Go, o sea que no tenemos que importar nada de afuera. Los canales pueden tener o no un tamaño y eso modifica la forma en que bloquea la lectura/escritura.
Para terminar, recuerden que es importante saber la naturaleza del problema y si es necesario usar más de un hilo de ejecución, y en caso de necesitarlos, también debemos saber que si los recursos están bien manejados, podemos tener un gran número de rutinas en un hardware que no sea el más caro o grande.

Si te gusto el contenido, podes sponsorearme aca!

Top comments (0)