Cristhian Villegas
Backend11 min read2 views

Concurrencia en Go: Goroutines y Channels — Curso Go #7

Concurrencia en Go: Goroutines y Channels — Curso Go #7

Introduccion: concurrencia vs paralelismo

Logo de Go

Bienvenido a la parte 7 de nuestro curso de Go para principiantes. La concurrencia es una de las caracteristicas mas poderosas de Go y lo que lo distingue de muchos otros lenguajes de programacion.

Antes de escribir codigo, es fundamental entender la diferencia entre dos conceptos que suelen confundirse:

  • Concurrencia: es la capacidad de manejar multiples tareas al mismo tiempo, alternando entre ellas. Es como un cocinero que prepara varios platos al mismo tiempo: mientras espera que hierva el agua, corta las verduras.
  • Paralelismo: es la ejecucion simultanea real de multiples tareas. Es como tener varios cocineros, cada uno preparando un plato diferente al mismo tiempo.
Dato clave: Go fue disenado desde cero para la concurrencia. Rob Pike, uno de los creadores de Go, lo resumio asi: "La concurrencia no es paralelismo. La concurrencia es una forma de estructurar un programa; el paralelismo es una propiedad de la ejecucion."

Go implementa su modelo de concurrencia basandose en el concepto de CSP (Communicating Sequential Processes), donde los procesos independientes se comunican enviando y recibiendo mensajes a traves de canales.

Goroutines: hilos ligeros con la palabra clave go

Una goroutine es una funcion que se ejecuta de forma concurrente con otras goroutines. Son extremadamente ligeras: mientras un hilo del sistema operativo puede consumir ~1 MB de memoria, una goroutine empieza con solo ~2 KB. Puedes tener miles o incluso millones de goroutines corriendo al mismo tiempo.

go
1package main
2
3import (
4    "fmt"
5    "time"
6)
7
8func saludar(nombre string) {
9    for i := 0; i < 3; i++ {
10        fmt.Println("Hola desde", nombre)
11        time.Sleep(100 * time.Millisecond)
12    }
13}
14
15func main() {
16    // Lanzar una goroutine con la palabra clave "go"
17    go saludar("goroutine 1")
18    go saludar("goroutine 2")
19
20    // La funcion main tambien es una goroutine
21    saludar("main")
22
23    // Sin el Sleep en saludar, main podria terminar
24    // antes de que las goroutines completen su trabajo
25}

Observa que solo necesitas agregar la palabra clave go antes de la llamada a una funcion para ejecutarla como goroutine. Asi de simple.

Cuidado: Cuando la funcion main termina, todas las goroutines que aun estan corriendo se detienen inmediatamente. No dejes que main termine antes de que tus goroutines completen su trabajo. Usa sync.WaitGroup o canales para sincronizar.

sync.WaitGroup para sincronizacion

El problema del ejemplo anterior es que usamos time.Sleep para esperar, lo cual no es confiable. La solucion correcta es usar sync.WaitGroup, que permite esperar a que un grupo de goroutines termine.

go
1package main
2
3import (
4    "fmt"
5    "sync"
6)
7
8func trabajador(id int, wg *sync.WaitGroup) {
9    // defer asegura que Done() se llama al terminar
10    defer wg.Done()
11    fmt.Printf("Trabajador %d: iniciando\n", id)
12    // Simular trabajo...
13    for i := 0; i < 3; i++ {
14        fmt.Printf("Trabajador %d: paso %d\n", id, i+1)
15    }
16    fmt.Printf("Trabajador %d: terminado\n", id)
17}
18
19func main() {
20    var wg sync.WaitGroup
21
22    for i := 1; i <= 5; i++ {
23        wg.Add(1) // Incrementar el contador
24        go trabajador(i, &wg)
25    }
26
27    wg.Wait() // Bloquear hasta que el contador llegue a 0
28    fmt.Println("Todos los trabajadores terminaron")
29}

El patron es simple: llama a wg.Add(1) antes de lanzar cada goroutine, wg.Done() cuando la goroutine termina, y wg.Wait() para esperar a todas.

Channels: comunicacion entre goroutines

Los channels (canales) son el mecanismo principal de comunicacion entre goroutines en Go. Puedes pensar en un canal como un tubo por el cual puedes enviar y recibir valores.

Canales sin buffer (unbuffered)

Un canal sin buffer requiere que el emisor y el receptor esten listos al mismo tiempo. El emisor se bloquea hasta que alguien recibe el valor.

go
1package main
2
3import "fmt"
4
5func main() {
6    // Crear un canal de tipo string
7    mensajes := make(chan string)
8
9    // Enviar un valor en una goroutine
10    go func() {
11        mensajes <- "Hola desde la goroutine"
12    }()
13
14    // Recibir el valor (bloquea hasta que llega)
15    msg := <-mensajes
16    fmt.Println(msg) // Hola desde la goroutine
17}

Canales con buffer (buffered)

Un canal con buffer tiene capacidad para almacenar un numero fijo de valores. El emisor solo se bloquea cuando el buffer esta lleno.

go
1package main
2
3import "fmt"
4
5func main() {
6    // Canal con buffer de capacidad 3
7    ch := make(chan int, 3)
8
9    // Podemos enviar sin que nadie reciba todavia
10    ch <- 10
11    ch <- 20
12    ch <- 30
13
14    // Recibir los valores en orden (FIFO)
15    fmt.Println(<-ch) // 10
16    fmt.Println(<-ch) // 20
17    fmt.Println(<-ch) // 30
18}
Regla general: Usa canales sin buffer cuando necesitas sincronizacion garantizada entre goroutines. Usa canales con buffer cuando quieres desacoplar el emisor del receptor y permitir cierto grado de asincronismo.

Direccion de canales: send-only y receive-only

Go te permite restringir la direccion de un canal en los parametros de una funcion. Esto hace que tu codigo sea mas seguro y claro sobre la intencion.

go
1package main
2
3import "fmt"
4
5// Solo puede enviar al canal
6func productor(ch chan<- int) {
7    for i := 0; i < 5; i++ {
8        ch <- i * 10
9    }
10    close(ch) // Cerrar el canal cuando terminamos
11}
12
13// Solo puede recibir del canal
14func consumidor(ch <-chan int) {
15    for valor := range ch {
16        fmt.Println("Recibido:", valor)
17    }
18}
19
20func main() {
21    ch := make(chan int, 5)
22    go productor(ch)
23    consumidor(ch) // Se ejecuta en main, no como goroutine
24}

La sintaxis es: chan<- int para send-only (solo enviar) y <-chan int para receive-only (solo recibir). Si intentas leer de un canal send-only, el compilador te dara un error.

Select: multiplexar canales

La sentencia select permite esperar en multiples operaciones de canales simultaneamente. Es como un switch, pero para canales.

go
1package main
2
3import (
4    "fmt"
5    "time"
6)
7
8func main() {
9    ch1 := make(chan string)
10    ch2 := make(chan string)
11
12    go func() {
13        time.Sleep(100 * time.Millisecond)
14        ch1 <- "resultado de ch1"
15    }()
16
17    go func() {
18        time.Sleep(200 * time.Millisecond)
19        ch2 <- "resultado de ch2"
20    }()
21
22    // Esperar el primero que responda
23    for i := 0; i < 2; i++ {
24        select {
25        case msg1 := <-ch1:
26            fmt.Println("Recibido de ch1:", msg1)
27        case msg2 := <-ch2:
28            fmt.Println("Recibido de ch2:", msg2)
29        }
30    }
31}

El select se bloquea hasta que uno de los canales esta listo. Si varios estan listos al mismo tiempo, elige uno al azar. Tambien puedes agregar un caso default para hacer un select no bloqueante, o un time.After para implementar timeouts.

go
1select {
2case msg := <-ch:
3    fmt.Println("Recibido:", msg)
4case <-time.After(3 * time.Second):
5    fmt.Println("Timeout: no se recibio respuesta en 3 segundos")
6}

Mutex y sync.RWMutex para estado compartido

Aunque los canales son la forma preferida de comunicacion en Go, a veces necesitas proteger acceso a datos compartidos. Para eso existen los mutex (mutual exclusion).

go
1package main
2
3import (
4    "fmt"
5    "sync"
6)
7
8// ContadorSeguro usa un mutex para proteger el valor
9type ContadorSeguro struct {
10    mu    sync.Mutex
11    valor int
12}
13
14func (c *ContadorSeguro) Incrementar() {
15    c.mu.Lock()
16    defer c.mu.Unlock()
17    c.valor++
18}
19
20func (c *ContadorSeguro) Obtener() int {
21    c.mu.Lock()
22    defer c.mu.Unlock()
23    return c.valor
24}
25
26func main() {
27    contador := ContadorSeguro{}
28    var wg sync.WaitGroup
29
30    // 1000 goroutines incrementando el mismo contador
31    for i := 0; i < 1000; i++ {
32        wg.Add(1)
33        go func() {
34            defer wg.Done()
35            contador.Incrementar()
36        }()
37    }
38
39    wg.Wait()
40    fmt.Println("Valor final:", contador.Obtener()) // Siempre 1000
41}

Si tienes muchas lecturas y pocas escrituras, sync.RWMutex es mas eficiente. Permite multiples lectores simultaneos, pero solo un escritor a la vez.

go
1type Cache struct {
2    mu    sync.RWMutex
3    datos map[string]string
4}
5
6func (c *Cache) Leer(clave string) (string, bool) {
7    c.mu.RLock()         // Bloqueo de lectura (multiples lectores OK)
8    defer c.mu.RUnlock()
9    valor, ok := c.datos[clave]
10    return valor, ok
11}
12
13func (c *Cache) Escribir(clave, valor string) {
14    c.mu.Lock()          // Bloqueo de escritura (exclusivo)
15    defer c.mu.Unlock()
16    c.datos[clave] = valor
17}
Filosofia Go: "No comuniques compartiendo memoria; comparte memoria comunicando." Es decir, prefiere canales sobre mutex siempre que sea posible. Los mutex son apropiados cuando proteges una estructura de datos simple; los canales son mejores para coordinar flujos de trabajo complejos.

Patrones comunes: worker pool, fan-in, fan-out

Go tiene varios patrones de concurrencia bien establecidos. Veamos los mas importantes:

Worker Pool (grupo de trabajadores)

Un pool de workers procesa tareas de un canal compartido. Es util cuando quieres limitar el numero de goroutines activas.

go
1package main
2
3import (
4    "fmt"
5    "sync"
6)
7
8func worker(id int, tareas <-chan int, resultados chan<- int, wg *sync.WaitGroup) {
9    defer wg.Done()
10    for tarea := range tareas {
11        fmt.Printf("Worker %d procesando tarea %d\n", id, tarea)
12        resultados <- tarea * 2 // simular procesamiento
13    }
14}
15
16func main() {
17    const numWorkers = 3
18    const numTareas = 10
19
20    tareas := make(chan int, numTareas)
21    resultados := make(chan int, numTareas)
22
23    var wg sync.WaitGroup
24
25    // Lanzar workers
26    for i := 1; i <= numWorkers; i++ {
27        wg.Add(1)
28        go worker(i, tareas, resultados, &wg)
29    }
30
31    // Enviar tareas
32    for i := 1; i <= numTareas; i++ {
33        tareas <- i
34    }
35    close(tareas)
36
37    // Esperar a que terminen y cerrar resultados
38    go func() {
39        wg.Wait()
40        close(resultados)
41    }()
42
43    // Recoger resultados
44    for r := range resultados {
45        fmt.Println("Resultado:", r)
46    }
47}

Fan-out / Fan-in

Fan-out significa distribuir trabajo entre multiples goroutines. Fan-in significa combinar los resultados de multiples goroutines en un solo canal.

go
1// Fan-in: combinar multiples canales en uno
2func fanIn(canales ...<-chan string) <-chan string {
3    var wg sync.WaitGroup
4    combinado := make(chan string)
5
6    for _, ch := range canales {
7        wg.Add(1)
8        go func(c <-chan string) {
9            defer wg.Done()
10            for val := range c {
11                combinado <- val
12            }
13        }(ch)
14    }
15
16    go func() {
17        wg.Wait()
18        close(combinado)
19    }()
20
21    return combinado
22}

Race conditions y el detector de carreras

Una race condition (condicion de carrera) ocurre cuando dos o mas goroutines acceden a la misma variable simultaneamente y al menos una la modifica. El resultado es impredecible.

bash
1# Ejecutar con el detector de carreras
2go run -race main.go
3
4# Tambien funciona con tests
5go test -race ./...

El detector de carreras de Go es una herramienta invaluable. Analiza tu programa en tiempo de ejecucion y te alerta si detecta accesos concurrentes inseguros.

go
1// EJEMPLO DE RACE CONDITION (incorrecto)
2package main
3
4import (
5    "fmt"
6    "sync"
7)
8
9func main() {
10    contador := 0
11    var wg sync.WaitGroup
12
13    for i := 0; i < 1000; i++ {
14        wg.Add(1)
15        go func() {
16            defer wg.Done()
17            contador++ // RACE CONDITION: acceso no protegido
18        }()
19    }
20
21    wg.Wait()
22    // El resultado sera diferente cada vez
23    fmt.Println("Valor:", contador) // Podria ser 980, 995, 1000...
24}
Siempre usa -race en desarrollo: Ejecuta tus tests y tu programa con la bandera -race durante el desarrollo. Las race conditions son bugs silenciosos que pueden causar comportamiento impredecible en produccion. El detector las encuentra por ti.

Ejemplo practico: verificador concurrente de URLs

Vamos a construir un programa completo que verifica el estado de multiples URLs de forma concurrente, usando goroutines, canales y WaitGroup.

go
1package main
2
3import (
4    "fmt"
5    "net/http"
6    "sync"
7    "time"
8)
9
10// Resultado almacena la informacion de cada verificacion
11type Resultado struct {
12    URL      string
13    Estado   int
14    Duracion time.Duration
15    Error    string
16}
17
18func verificarURL(url string, resultados chan<- Resultado, wg *sync.WaitGroup) {
19    defer wg.Done()
20
21    inicio := time.Now()
22    cliente := http.Client{Timeout: 5 * time.Second}
23
24    resp, err := cliente.Get(url)
25    duracion := time.Since(inicio)
26
27    if err != nil {
28        resultados <- Resultado{
29            URL:      url,
30            Estado:   0,
31            Duracion: duracion,
32            Error:    err.Error(),
33        }
34        return
35    }
36    defer resp.Body.Close()
37
38    resultados <- Resultado{
39        URL:      url,
40        Estado:   resp.StatusCode,
41        Duracion: duracion,
42        Error:    "",
43    }
44}
45
46func main() {
47    urls := []string{
48        "https://www.google.com",
49        "https://www.github.com",
50        "https://go.dev",
51        "https://www.rust-lang.org",
52        "https://sitio-que-no-existe.xyz",
53    }
54
55    resultados := make(chan Resultado, len(urls))
56    var wg sync.WaitGroup
57
58    fmt.Println("Verificando URLs...")
59    inicio := time.Now()
60
61    for _, url := range urls {
62        wg.Add(1)
63        go verificarURL(url, resultados, &wg)
64    }
65
66    // Cerrar el canal cuando todas las goroutines terminen
67    go func() {
68        wg.Wait()
69        close(resultados)
70    }()
71
72    // Recoger e imprimir resultados
73    exitosos := 0
74    fallidos := 0
75    for r := range resultados {
76        if r.Error != "" {
77            fmt.Printf("  FALLO  %s (%v) - %s\n", r.URL, r.Duracion.Round(time.Millisecond), r.Error)
78            fallidos++
79        } else {
80            fmt.Printf("  OK %d  %s (%v)\n", r.Estado, r.URL, r.Duracion.Round(time.Millisecond))
81            exitosos++
82        }
83    }
84
85    fmt.Printf("\nResumen: %d exitosos, %d fallidos en %v\n",
86        exitosos, fallidos, time.Since(inicio).Round(time.Millisecond))
87}
Observa el poder de la concurrencia: Si cada URL tarda ~500ms en responder y tienes 5 URLs, de forma secuencial tardarias ~2.5 segundos. Con goroutines, todas se verifican simultaneamente y el total es aproximadamente ~500ms (el tiempo de la mas lenta).

Resumen y proximo articulo

En este articulo aprendiste los fundamentos de la concurrencia en Go:

  • Goroutines: funciones que corren de forma concurrente con go func()
  • sync.WaitGroup: para esperar a que un grupo de goroutines termine
  • Channels: tubos para enviar y recibir datos entre goroutines
  • Canales con y sin buffer: diferentes estrategias de sincronizacion
  • Direccion de canales: chan<- y <-chan para mayor seguridad
  • Select: esperar en multiples canales simultaneamente
  • Mutex: proteger datos compartidos con sync.Mutex y sync.RWMutex
  • Patrones: worker pool, fan-in, fan-out
  • Detector de carreras: go run -race para encontrar bugs

En el proximo articulo (parte 8) aprenderemos sobre paquetes, modulos y herramientas en Go: como organizar tu codigo en paquetes, usar Go modules, y dominar las herramientas del ecosistema Go.

Share:
CV

Cristhian Villegas

Software Engineer specializing in Java, Spring Boot, Angular & AWS. Building scalable distributed systems with clean architecture.

Comments

Sign in to leave a comment

No comments yet. Be the first!

Related Articles