Goroutine y channel: bases de concurrencia en Go

1. Resumen

Go fue diseñado para escribir programas concurrentes de manera simple, sin necesidad de callback hell ni thread pools complicados. Dos bloques de construcción fundamentales son goroutine (unidad de ejecución ligera) y channel (canal de comunicación entre goroutines). Después de leer este artículo, sabrás cómo iniciar goroutines, sincronizarlas usando sync.WaitGroup, comunicarte de forma segura a través de channels, usar select para manejar múltiples channels simultáneamente, y evitar errores comunes como goroutine leaks o deadlocks.

Todos los ejemplos usan Go 1.22+, sin librerías externas — solo necesitas go run main.go para ejecutar.

2. ¿Qué es una goroutine?

Una goroutine es una función que se ejecuta de forma independiente, administrada por el runtime de Go en lugar del sistema operativo. El costo de crear una goroutine es muy bajo (pocos KB de stack, se expande automáticamente), así que puedes crear decenas de miles de goroutines sin preocuparte por agotar recursos como los threads del sistema.

package main

import (
	"fmt"
	"time"
)

func sayHello(name string) {
	fmt.Println("Hola,", name)
}

func main() {
	go sayHello("Order Service")
	go sayHello("Payment Service")

	time.Sleep(100 * time.Millisecond) // esperar a que las goroutines terminen
	fmt.Println("Main termina")
}

La palabra clave go antes de una llamada a función ejecutará esa función en una nueva goroutine, sin bloquear main. Como main podría terminar antes de que las dos goroutines logren imprimir, debemos usar time.Sleep temporalmente para esperar — esta es una forma no confiable, solo para ilustrar. La salida podría ser:

Hola, Payment Service
Hola, Order Service
Main termina

El orden de impresión no es fijo porque el scheduler decide qué goroutine se ejecuta primero. Esta es la naturaleza de la concurrencia: múltiples tareas suceden “simultáneamente” pero no hay garantía de orden.

Nota: Usar time.Sleep para sincronizar goroutines en código real es un anti-pattern. La sección 4 presentará sync.WaitGroup como reemplazo.

3. Channel: canal de comunicación entre goroutines

Un channel es un tubo type-safe para que las goroutines envíen y reciban datos. La filosofía de Go es: “No comuniques compartiendo memoria; comparte memoria comunicándote.”

package main

import "fmt"

func calculateTotal(amount float64, result chan<- float64) {
	tax := amount * 0.1
	result <- amount + tax
}

func main() {
	result := make(chan float64)

	go calculateTotal(1_000_000, result)

	total := <-result
	fmt.Printf("Total del pedido: %.0f\n", total)
}
  • make(chan float64) crea un unbuffered channel de tipo float64.
  • chan<- float64 en el parámetro de la función significa que el channel solo se usa para enviar, ayudando al compilador a detectar errores si accidentalmente intentas leer de él.
  • result <- amount + tax envía un valor al channel.
  • <-result en main se bloqueará hasta que haya datos, actuando como sincronización natural — sin necesidad de Sleep.
Total del pedido: 1100000

3.1. Unbuffered vs buffered channel

Un unbuffered channel (make(chan T)) requiere que el remitente y el receptor se “encuentren” al mismo tiempo — el envío se bloqueará hasta que alguien reciba. Un buffered channel (make(chan T, n)) permite enviar hasta n valores sin necesidad de que el receptor esté listo.

package main

import "fmt"

func main() {
	orders := make(chan string, 3)

	orders <- "Order-1"
	orders <- "Order-2"
	orders <- "Order-3"
	close(orders)

	for order := range orders {
		fmt.Println("Procesando:", order)
	}
}

Como el buffer tiene capacidad de 3, los tres envíos no se bloquean. close(orders) indica que no hay más datos, permitiendo que el bucle range termine automáticamente en lugar de bloquearse indefinidamente esperando el siguiente valor.

Procesando: Order-1
Procesando: Order-2
Procesando: Order-3

Nota: Solo el lado remitente puede close un channel. Enviar a un channel cerrado causa panic send on closed channel; leer de un channel cerrado aún es válido, devuelve el valor cero y ok == false.

4. Sincronizar múltiples goroutines con WaitGroup

Cuando necesitas esperar a que múltiples goroutines terminen sin preocuparte por los valores de retorno, sync.WaitGroup es una opción más limpia que usar channels.

package main

import (
	"fmt"
	"sync"
)

func processOrder(id int, wg *sync.WaitGroup) {
	defer wg.Done()
	fmt.Printf("Pedido procesado-%d\n", id)
}

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go processOrder(i, &wg)
	}

	wg.Wait()
	fmt.Println("Todos los pedidos han sido procesados")
}
  • wg.Add(1) incrementa el contador antes de que la goroutine se ejecute — debe llamarse antes de go, no dentro de la goroutine, para evitar race conditions.
  • defer wg.Done() decrementa el contador cuando la goroutine termina.
  • wg.Wait() bloquea main hasta que el contador llega a 0.
Pedido procesado-2
Pedido procesado-1
Pedido procesado-3
Todos los pedidos han sido procesados

El orden de las tres primeras líneas no es fijo, pero la última línea siempre se imprime al final — eso es lo que WaitGroup garantiza.

4.1. Probar el comportamiento concurrente

Puedes escribir tests para confirmar que todas las goroutines se completan, usando un contador de resultados en lugar de depender del orden de impresión:

package orderpkg

import (
	"sync"
	"sync/atomic"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestProcessOrders_AllCompleted(t *testing.T) {
	var wg sync.WaitGroup
	var processed int32

	for i := 1; i <= 5; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			atomic.AddInt32(&processed, 1)
		}(i)
	}

	wg.Wait()

	assert.Equal(t, int32(5), processed)
}

atomic.AddInt32 evita race conditions cuando múltiples goroutines incrementan la variable de conteo simultáneamente — si usas processed++ directamente, go test -race reportará un data race.

Dependencia para el test (añade a go.mod):

go get github.com/stretchr/testify

5. Select: manejar múltiples channels simultáneamente

select es como switch pero para channels — elige el case que está listo primero para ejecutar. Es muy útil cuando necesitas esperar múltiples fuentes de datos o establecer timeouts.

package main

import (
	"fmt"
	"time"
)

func fetchFromCache(ch chan<- string) {
	time.Sleep(50 * time.Millisecond)
	ch <- "datos desde cache"
}

func fetchFromDB(ch chan<- string) {
	time.Sleep(200 * time.Millisecond)
	ch <- "datos desde database"
}

func main() {
	cacheCh := make(chan string)
	dbCh := make(chan string)

	go fetchFromCache(cacheCh)
	go fetchFromDB(dbCh)

	select {
	case res := <-cacheCh:
		fmt.Println("Recibido:", res)
	case res := <-dbCh:
		fmt.Println("Recibido:", res)
	case <-time.After(100 * time.Millisecond):
		fmt.Println("Timeout, usando datos por defecto")
	}
}

select se bloquea hasta que uno de los cases tenga datos listos. Como el cache devuelve después de 50ms mientras que la DB después de 200ms, el case cacheCh gana:

Recibido: datos desde cache

time.After crea un channel que recibe un valor después del tiempo especificado, usado para implementar timeouts en todo el select.

6. Cancelar goroutines correctamente con context

Un error común es iniciar una goroutine pero no tener forma de detenerla, resultando en un goroutine leak. El paquete context soluciona esto permitiéndote transmitir señales de cancelación a través de la cadena de llamadas.

package main

import (
	"context"
	"fmt"
	"time"
)

func monitorPayment(ctx context.Context, orderID string) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("Deteniendo monitoreo de pedido:", orderID, "-", ctx.Err())
			return
		case <-time.After(500 * time.Millisecond):
			fmt.Println("Verificando estado del pedido:", orderID)
		}
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 1200*time.Millisecond)
	defer cancel()

	go monitorPayment(ctx, "Order-99")

	<-ctx.Done()
	time.Sleep(50 * time.Millisecond) // esperar a que la goroutine termine de imprimir
}
  • context.WithTimeout crea un context que se Done() automáticamente después de 1200ms.
  • Dentro de monitorPayment, select escucha tanto ctx.Done() como un tick periódico. Cuando el context expira, la goroutine se detiene de forma limpia en lugar de ejecutarse indefinidamente.
  • defer cancel() asegura que se liberen los recursos del context incluso si la función termina temprano.
Verificando estado del pedido: Order-99
Verificando estado del pedido: Order-99
Deteniendo monitoreo de pedido: Order-99 - context deadline exceeded

Nota: Siempre llama a cancel() (via defer) aunque uses WithTimeout o WithCancel. Olvidar este paso no causa errores inmediatos pero causa fugas de context en loops de larga ejecución.

7. Errores comunes

Deadlock porque el channel no tiene receptor. Enviar a un unbuffered channel sin que ninguna goroutine lo lea bloqueará el programa y Go runtime lo detectará automáticamente:

func main() {
	ch := make(chan int)
	ch <- 1 // nadie lee → deadlock
}
fatal error: all goroutines are asleep - deadlock!

Goroutine leak. Una goroutine espera indefinidamente leer de un channel porque nadie envía y no hay mecanismo de cancelación — la memoria se retiene hasta que el programa termina. Siempre incluye context o un channel “done” al iniciar goroutines de larga duración.

Closure capturando variable de bucle incorrectamente (antes de Go 1.22). Si usas Go < 1.22, la variable de bucle i en for se comparte entre iteraciones, permitiendo que todas las goroutines vean el mismo valor final. A partir de Go 1.22, cada iteración crea una nueva variable i así que este problema desapareció — pero si tu proyecto usa una versión más antigua, pasa la variable como parámetro como en el ejemplo de la sección 4.1.

Race condition al compartir variables. Dos goroutines leyendo/escribiendo la misma variable sin usar channels o sync/atomic es una race condition clásica. Siempre ejecuta go test -race o go run -race durante el desarrollo para detectar problemas temprano.

Cuándo NO usar goroutines: para tareas cortas, el costo de crear una goroutine y sincronizarla podría ser mayor que el beneficio. Si el trabajo solo toma unos pocos microsegundos, ejecutar secuencialmente es generalmente más rápido y legible.

8. Conclusión

Las goroutines te dan una forma de crear threads de ejecución ligeros con costo ultra bajo, mientras que los channels proporcionan un mecanismo de comunicación seguro en lugar de compartir memoria directamente. Combinados con sync.WaitGroup para sincronización, select para manejar múltiples fuentes, y context para cancelación adecuada, tienes todas las herramientas para construir patrones concurrentes comunes en Go — desde worker pools, pipelines, hasta fan-in/fan-out. El siguiente paso debe ser practicar con go test -race para desarrollar el hábito de detectar race conditions tempranamente.

golanggoroutinechannelconcurrency