Concurrencia y multihilo en Go: de la A a la Z
Go (Golang) fue diseñado teniendo en cuenta las cargas computacionales modernas. En la era de los procesadores multinúcleo y los servicios en la nube, la capacidad de trabajar eficientemente con la concurrencia se ha convertido en una habilidad obligatoria para un desarrollador backend. En este artículo, analizaremos cómo Go resuelve los problemas de ejecución paralela de código, en qué se diferencian las gorutinas de los hilos del SO y cómo usar correctamente los canales y mutex.
1. Gorutinas: hilos ligeros
Una gorutina (goroutine) es una función o método que se ejecuta de manera concurrente con otras gorutinas. A diferencia de los hilos del sistema operativo, las gorutinas tienen una sobrecarga mínima: su pila comienza con 2-4 KB y puede crecer dinámicamente. Iniciar una gorutina es increíblemente simple: solo agregue la palabra clave go antes de la llamada a la función.
package main
import ( "fmt" "time")
func say(text string) { for i := 0; i < 3; i++ { fmt.Println(text) time.Sleep(100 * time.Millisecond) }}
func main() { go say("Hello from goroutine") say("Hello from main") // Damos tiempo a la gorutina para que termine time.Sleep(500 * time.Millisecond)}Tenga en cuenta: el programa finaliza inmediatamente después de ejecutar la función main(), sin esperar a que finalicen las gorutinas iniciadas. Para la sincronización usamos time.Sleep (en proyectos reales — sync.WaitGroup o canales).
2. Canales: transferencia segura de datos
Los canales (channels) son "tuberías" tipadas a través de las cuales las gorutinas intercambian datos. Resuelven dos tareas clave: la sincronización de la ejecución y la transferencia de valores. Los canales pueden ser con búfer y sin búfer.
Canal sin búfer
La operación de envío bloquea la gorutina hasta que otra gorutina realice la lectura. Esto proporciona una sincronización "sobre la marcha".
package main
import "fmt"
func main() { ch := make(chan string)
go func() { ch <- "¡Hola desde la gorutina!" }()
msg := <-ch fmt.Println(msg)}Canal con búfer
Un canal con búfer permite enviar varios valores sin lectura inmediata, hasta que el búfer se llene.
package main
import "fmt"
func main() { ch := make(chan int, 2) ch <- 10 ch <- 20 // ch <- 30 // deadlock! búfer lleno
fmt.Println(<-ch) // 10 fmt.Println(<-ch) // 20}Range y close
Cerrar el canal señala a los receptores que no habrá más datos. El bucle for range finaliza automáticamente cuando se cierra el canal.
package main
import "fmt"
func producer(ch chan int) { for i := 1; i <= 5; i++ { ch <- i } close(ch)}
func main() { ch := make(chan int) go producer(ch)
for val := range ch { fmt.Println("Recibido:", val) }}3. Sincronización y mutex
A pesar del poder de los canales, a veces se requiere acceso directo a datos compartidos. El paquete sync proporciona mutex (sync.Mutex) y mutex de lectura-escritura (sync.RWMutex) para proteger secciones críticas.
package main
import ( "fmt" "sync")
var counter = 0var mu sync.Mutex
func increment(wg *sync.WaitGroup) { defer wg.Done() mu.Lock() counter++ mu.Unlock()}
func main() { var wg sync.WaitGroup for i := 0; i < 1000; i++ { wg.Add(1) go increment(&wg) } wg.Wait() fmt.Println("Contador final:", counter)}Aquí, sync.WaitGroup espera a que todas las gorutinas terminen, y el mutex previene la condición de carrera (data race). Sin el mutex, el resultado sería impredecible.