A continuación te presento una visión general de los conceptos fundamentales que exploraremos en este documento:
- Concurrencia vs Paralelismo ➡️ Gestionar múltiples tareas en progreso vs ejecutar tareas simultáneamente
- Goroutines ➡️ Funciones que se ejecutan concurrentemente, más ligeras que los hilos tradicionales
- Sincronización ➡️ Coordinar la ejecución entre goroutines para evitar problemas
- WaitGroups ➡️ Esperar a que múltiples goroutines terminen
- Mutexes ➡️ Proteger el acceso a datos compartidos
- Channels ➡️ Comunicar y transferir datos entre goroutines
- Productor-Consumidor ➡️ Coordinación entre generadores y procesadores de datos
- Filósofos Comensales ➡️ Gestión de recursos compartidos evitando deadlocks
- Fan-out/Fan-in ➡️ Distribución y recolección de trabajo paralelo
- Worker Pools ➡️ Grupo de goroutines que procesan tareas de una cola
- Race Conditions ➡️ Acceso simultáneo descoordinado a datos compartidos
- Deadlocks ➡️ Bloqueo mutuo donde todas las goroutines quedan esperando
- Goroutines Huérfanas ➡️ Goroutines que nunca terminan
- Race Detector ➡️
go run -race
ogo test -race
para detectar problemas - Pprof ➡️ Perfilador para identificar cuellos de botella
Este documento te llevará desde los conceptos básicos hasta patrones avanzados con ejemplos prácticos, consejos de implementación y soluciones a problemas clásicos de concurrencia. Cada sección está diseñada para construir sobre la anterior, proporcionándote una comprensión completa de cómo Go maneja la concurrencia de forma elegante y eficiente.
Go fue diseñado desde el principio para abordar los desafíos de la programación concurrente y distribuida en un mundo cada vez más paralelo. Los creadores de Go (Rob Pike, Ken Thompson y Robert Griesemer) tenían una visión clara: hacer que la concurrencia sea accesible, práctica y segura.
💡 Idea central: Hacer que la concurrencia sea tan simple como añadir una palabra clave.
- Extremadamente ligeras: Miles de goroutines pueden ejecutarse en un solo hilo del sistema operativo
- Programación automática: El runtime de Go se encarga de distribuir goroutines entre hilos
- Sintaxis simple: Solo añade
go
antes de una llamada a función
go function() // ¡Así de simple!
💡 Idea central: "No comuniques compartiendo memoria; comparte memoria comunicándote."
- Paso de mensajes tipados: Transferencia segura de datos entre goroutines
- Sincronización integrada: Coordinación automática entre emisor y receptor
- Concepto de propiedad: Canales facilitan la transferencia clara de "propiedad" de datos
ch := make(chan int)
ch <- 42 // Envía dato (puede bloquear)
value := <-ch // Recibe dato (puede bloquear)
💡 Idea central: Manejar múltiples canales de comunicación de forma no determinista.
- Coordinación multi-canal: Esperar por múltiples operaciones de canales
- Timeout y cancelación: Patrones integrados para control de tiempo y finalización
- No determinismo controlado: Selección aleatoria cuando múltiples casos están disponibles
select {
case msg := <-ch1:
// Manejar mensaje de ch1
case ch2 <- value:
// Enviar a ch2
case <-time.After(1 * time.Second):
// Timeout después de 1 segundo
}
💡 Idea central: Proporcionar primitivas de sincronización cuando los canales no son suficientes.
- WaitGroup: Coordinar la finalización de múltiples goroutines
- Mutex/RWMutex: Proteger acceso a datos compartidos (cuando sea necesario)
- Once: Garantizar que una operación se ejecute exactamente una vez
- Pool: Reutilizar recursos costosos
- Cond: Variables de condición para situaciones complejas
var wg sync.WaitGroup
wg.Add(5) // Esperaremos 5 goroutines
go func() {
defer wg.Done() // Decrementa el contador
// ...trabajo...
}()
wg.Wait() // Bloquea hasta que el contador llegue a cero
Los creadores de Go visualizaron un sistema donde:
-
Las goroutines son la unidad básica de concurrencia: pequeñas, independientes y fáciles de crear.
-
Los channels son la forma primaria de comunicación: la transferencia explícita de datos es preferible a los estados compartidos y complejos sistemas de bloqueo.
-
El paquete sync complementa, no reemplaza: cuando necesitas sincronización de bajo nivel, está disponible pero no es el enfoque principal.
Go evita abstracciones complejas (como promesas, futuros o callbacks anidados) en favor de un modelo mental simple.
Las goroutines y channels pueden combinarse en patrones potentes sin necesidad de frameworks complejos.
La sincronización y comunicación son claras y visibles en el código, no ocultas en abstracciones.
Go proporciona tanto canales (modelo CSP) como primitivas de sincronización tradicionales (mutex), reconociendo que diferentes problemas necesitan diferentes herramientas.
Los patrones de concurrencia en Go surgieron naturalmente de estas herramientas:
- Worker Pools: Grupos de goroutines consumiendo tareas de un canal
- Fan-out/Fan-in: Distribución y recolección de trabajo entre múltiples goroutines
- Pipelines: Etapas conectadas por canales para procesar flujos de datos
- Cancelación por context: Propagación de señales de cancelación a través de árboles de llamadas
Lo que hace especial al modelo de concurrencia de Go es su equilibrio entre poder y simplicidad:
- Suficientemente poderoso para construir sistemas distribuidos complejos
- Suficientemente simple para ser entendido y usado correctamente
- Suficientemente seguro para evitar errores comunes de concurrencia
- Suficientemente eficiente para escalar a miles de goroutines
La concurrencia en Go no es solo un conjunto de herramientas, sino una filosofía:
"Go no intenta resolver todos los problemas de concurrencia, pero ofrece un conjunto coherente y práctico de primitivas que permiten abordar una amplia gama de problemas concurrentes de manera eficiente y con menos errores."
Esta filosofía pragmática, combinada con herramientas bien diseñadas, hace que la programación concurrente en Go sea notablemente más accesible y robusta que en muchos otros lenguajes.
La concurrencia es uno de los puntos fuertes de Go y una razón clave por la que muchos desarrolladores eligen este lenguaje. Pero, ¿qué es exactamente?
La concurrencia es la capacidad de un programa para manejar múltiples tareas en progreso al mismo tiempo. Es importante distinguirla del paralelismo:
- Concurrencia ➡️ Gestionar múltiples tareas en curso (no necesariamente ejecutándose exactamente al mismo tiempo)
- Paralelismo ➡️ Ejecutar múltiples tareas simultáneamente (requiere múltiples procesadores)
💡 Analogía: La concurrencia es como un chef que prepara varios platos a la vez. Mientras uno se hornea, está cortando verduras para otro. No está haciendo todo literalmente al mismo tiempo, pero avanza con todas las tareas eficientemente.
En Go, la concurrencia se implementa principalmente mediante goroutines y channels. En esta guía nos centraremos en goroutines y su sincronización.
Las goroutines son funciones que se ejecutan concurrentemente con otras goroutines, incluyendo la función principal (main
). Son extremadamente ligeras - puedes crear miles de ellas sin problemas.
Para ejecutar una función como goroutine, simplemente añade la palabra clave go
antes de la llamada:
package main
import (
"fmt"
"time"
)
func main() {
// Ejecutamos la función como goroutine
go sayHello()
// Esperamos para dar tiempo a que la goroutine se ejecute
time.Sleep(1 * time.Second)
fmt.Println("Programa terminado")
}
func sayHello() {
fmt.Println("¡Hola, mundo concurrente!")
}
En el ejemplo anterior, usamos time.Sleep()
para dar tiempo a que la goroutine termine. Esto es una mala práctica por varias razones:
- No sabemos cuánto tiempo exactamente necesita la goroutine
- Es ineficiente esperar un tiempo arbitrario
- En situaciones complejas, este enfoque se vuelve inmanejable
Veamos otro ejemplo:
package main
import (
"fmt"
"time"
)
func printSomething(s string) {
fmt.Println(s)
}
func main() {
words := []string{
"alpha", "beta", "gamma", "delta", "phi",
"zeta", "eta", "theta", "epsilon",
}
// Lanzamos múltiples goroutines
for i, word := range words {
go printSomething(fmt.Sprintf("%d: %s", i, word))
}
// Esperamos arbitrariamente - ¡mala práctica!
time.Sleep(1 * time.Second)
}
Al ejecutar este código, notarás que:
- Las palabras no se imprimen en orden
- No tenemos garantía de que todas las goroutines terminen antes del
time.Sleep
Para resolver el problema de sincronización, Go proporciona sync.WaitGroup
. Un WaitGroup espera a que un conjunto de goroutines finalice, actuando como un contador:
package main
import (
"fmt"
"sync"
)
func printSomething(wg *sync.WaitGroup, s string) {
// Indicamos que esta goroutine ha terminado cuando la función finalice
defer wg.Done()
fmt.Println(s)
}
func main() {
// Creamos un WaitGroup
var wg sync.WaitGroup
words := []string{
"alpha", "beta", "gamma", "delta", "phi",
"zeta", "eta", "theta", "epsilon",
}
// Indicamos cuántas goroutines vamos a esperar
wg.Add(len(words))
for i, word := range words {
go printSomething(&wg, fmt.Sprintf("%d: %s", i, word))
}
// Bloqueamos hasta que todas las goroutines terminen
wg.Wait()
fmt.Println("¡Todas las goroutines han terminado!")
}
wg.Add(n)
: Incrementa el contador enn
(número de goroutines a esperar)wg.Done()
: Decrementa el contador en 1 (una goroutine ha terminado)wg.Wait()
: Bloquea hasta que el contador llegue a 0 (todas han terminado)
⚠️ Advertencia: Asegúrate de que los valores deAdd()
yDone()
estén equilibrados. Un exceso deAdd()
causará un deadlock, y un exceso deDone()
podría causar un panic.
Probar código concurrente puede ser desafiante. Una forma de probar funciones que usan goroutines es capturar su salida:
package main
import (
"fmt"
"io"
"os"
"strings"
"sync"
"testing"
)
func Test_printSomething(t *testing.T) {
// Guardamos stdout original
originalStdout := os.Stdout
// Creamos un pipe para capturar la salida
r, w, _ := os.Pipe()
os.Stdout = w
// Preparamos el WaitGroup
var wg sync.WaitGroup
wg.Add(1)
// Ejecutamos la función como goroutine
go printSomething(&wg, "Mensaje de prueba")
// Esperamos a que termine
wg.Wait()
// Cerramos la escritura del pipe
w.Close()
// Leemos lo que se imprimió
var buf strings.Builder
io.Copy(&buf, r)
output := buf.String()
// Restauramos stdout
os.Stdout = originalStdout
// Verificamos la salida
expected := "Mensaje de prueba"
if !strings.Contains(output, expected) {
t.Errorf("Esperaba '%s', pero obtuve '%s'", expected, output)
}
}
-
Race conditions: Cuando múltiples goroutines acceden a la misma variable
// Incorrecto - race condition var counter int go func() { counter++ }() go func() { counter++ }()
-
Deadlocks: Cuando todas las goroutines están esperando y ninguna puede avanzar
// Incorrecto - deadlock var wg sync.WaitGroup wg.Add(1) // Olvidamos llamar a wg.Done() wg.Wait() // ¡Se bloqueará para siempre!
-
Goroutines huérfanas: Goroutines que continúan ejecutándose después de que main termina
-
Usa
sync.Mutex
para proteger datos compartidos:var ( counter int mu sync.Mutex ) func incrementCounter() { mu.Lock() defer mu.Unlock() counter++ }
-
Pasa el WaitGroup por referencia, no por valor:
func worker(wg *sync.WaitGroup) { // ✅ Correcto defer wg.Done() // ... }
-
Usa
go vet
ygo run -race
para detectar problemas:go vet ./... go run -race main.go
Modifica este código para que use goroutines y WaitGroups adecuadamente:
package main
import (
"fmt"
)
var msg string
func updateMessage(s string) {
msg = s
}
func printMessage() {
fmt.Println(msg)
}
func main() {
msg = "Hello, world!"
updateMessage("Hello, universe!")
printMessage()
updateMessage("Hello, cosmos!")
printMessage()
updateMessage("Hello, world!")
printMessage()
}
package main
import (
"fmt"
"sync"
)
var msg string
var mu sync.Mutex // Para proteger accesos concurrentes a 'msg'
func updateMessage(wg *sync.WaitGroup, s string) {
defer wg.Done()
mu.Lock()
msg = s
mu.Unlock()
}
func printMessage() {
mu.Lock()
fmt.Println(msg)
mu.Unlock()
}
func main() {
var wg sync.WaitGroup
// Mensaje inicial
msg = "Hello, world!"
// Primer cambio
wg.Add(1)
go updateMessage(&wg, "Hello, universe!")
wg.Wait()
printMessage()
// Segundo cambio
wg.Add(1)
go updateMessage(&wg, "Hello, cosmos!")
wg.Wait()
printMessage()
// Tercer cambio
wg.Add(1)
go updateMessage(&wg, "Hello, world!")
wg.Wait()
printMessage()
}
package main
import (
"bytes"
"io"
"os"
"strings"
"sync"
"testing"
)
func Test_updateMessage(t *testing.T) {
var wg sync.WaitGroup
// Estado inicial
oldMsg := msg
newMsg := "Test message"
// Actualizamos el mensaje
wg.Add(1)
go updateMessage(&wg, newMsg)
wg.Wait()
if msg != newMsg {
t.Errorf("updateMessage() = %v, quería %v", msg, newMsg)
}
// Restauramos el estado
msg = oldMsg
}
func Test_printMessage(t *testing.T) {
// Guardamos stdout original
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
// Establecemos un mensaje conocido
testMsg := "Test output message"
msg = testMsg
// Llamamos a la función
printMessage()
// Restauramos stdout
w.Close()
os.Stdout = oldStdout
// Leemos la salida
var out bytes.Buffer
io.Copy(&out, r)
// Verificamos
if !strings.Contains(out.String(), testMsg) {
t.Errorf("printMessage() = %v, quería %v", out.String(), testMsg)
}
}
func Test_fullProgram(t *testing.T) {
// Guardamos stdout
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
// Ejecutamos el programa
main()
// Restauramos stdout
w.Close()
os.Stdout = oldStdout
// Leemos la salida
var out bytes.Buffer
io.Copy(&out, r)
output := out.String()
// Verificamos las tres líneas
expected := []string{
"Hello, universe!",
"Hello, cosmos!",
"Hello, world!",
}
for _, exp := range expected {
if !strings.Contains(output, exp) {
t.Errorf("main() debería imprimir '%s'", exp)
}
}
}
Una race condition ocurre cuando dos o más goroutines acceden a la misma memoria simultáneamente y al menos una está escribiendo. Esto causa comportamientos impredecibles y errores difíciles de detectar.
💡 Analogía: Imagina dos personas escribiendo en la misma línea de un documento al mismo tiempo. El resultado será una mezcla confusa de ambas escrituras.
Veamos un ejemplo clásico de race condition:
package main
import (
"fmt"
"sync"
)
func main() {
counter := 0
var wg sync.WaitGroup
// Lanzamos 1000 goroutines que incrementan el contador
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // ⚠️ RACE CONDITION: múltiples goroutines modifican 'counter'
}()
}
wg.Wait()
fmt.Println("Valor final:", counter) // Casi nunca será 1000
}
Si ejecutas este código varias veces, obtendrás diferentes resultados. Esto ocurre porque counter++
no es una operación atómica, sino que implica:
- Leer el valor actual de counter
- Incrementarlo en 1
- Escribir el nuevo valor en counter
Cuando múltiples goroutines ejecutan estos pasos simultáneamente, pueden pisar los cambios de las demás.
Mutex
significa "Mutual Exclusion" (Exclusión Mutua). Es una estructura que permite que solo una goroutine acceda a un código o recurso a la vez.
Un mutex tiene dos operaciones principales:
- Lock(): Adquiere el bloqueo (si otra goroutine ya lo tiene, espera)
- Unlock(): Libera el bloqueo para que otras goroutines puedan adquirirlo
Corrigiendo el ejemplo anterior:
package main
import (
"fmt"
"sync"
)
func main() {
counter := 0
var wg sync.WaitGroup
var mu sync.Mutex // 🔒 Creamos un mutex
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock() // 🔒 Bloqueamos - solo esta goroutine puede acceder
counter++
mu.Unlock() // 🔓 Liberamos para que otras goroutines puedan acceder
}()
}
wg.Wait()
fmt.Println("Valor final:", counter) // Ahora siempre será 1000
}
Un patrón muy común es usar defer
con Unlock()
:
mu.Lock()
defer mu.Unlock()
// código que accede a recursos compartidos
Esto garantiza que el mutex se libere incluso si ocurre un panic o return temprano.
package main
import (
"fmt"
"sync"
)
var msg string
var wg sync.WaitGroup
func updateMessage(s string, mu *sync.Mutex) {
defer wg.Done()
mu.Lock() // 🔒 Bloqueamos acceso exclusivo a msg
msg = s // Modificamos la variable compartida
mu.Unlock() // 🔓 Liberamos el acceso
}
func main() {
msg = "Hello, world!"
var mu sync.Mutex
wg.Add(2)
go updateMessage("Hello, universe!", &mu)
go updateMessage("Hello, cosmos!", &mu)
wg.Wait()
// El resultado será uno de los dos mensajes, pero sin race condition
fmt.Println(msg)
}
Go incluye un detector de race conditions integrado. Para usarlo:
# Al ejecutar un programa
go run -race tuarchivo.go
# Al ejecutar tests
go test -race ./...
El detector agregará instrumentación al código que identifica posibles race conditions durante la ejecución.
Veamos un ejemplo que simula ingresos semanales desde diferentes fuentes:
package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup
type Income struct {
Source string
Amount int
}
func main() {
// Variable para el saldo bancario
var bankBalance int
var balanceMutex sync.Mutex
// Mostramos el saldo inicial
fmt.Printf("Starting bank balance: $%d\n", bankBalance)
// Definimos fuentes de ingreso semanales
incomes := []Income {
{Source: "Main job", Amount: 500},
{Source: "Side hustle", Amount: 200},
{Source: "Freelance project", Amount: 50},
{Source: "Selling stuff online", Amount: 100},
}
wg.Add(len(incomes))
// Simulamos 52 semanas de ingresos para cada fuente
for i, income := range incomes {
go func(i int, income Income) {
defer wg.Done()
for week := 1; week <= 52; week++ {
balanceMutex.Lock()
// Operación crítica: actualizar el saldo
temp := bankBalance
temp += income.Amount
bankBalance = temp
balanceMutex.Unlock()
fmt.Printf("Week %d: +$%d from %s\n",
week, income.Amount, income.Source)
}
}(i, income) // Pasamos los valores por copia, no por referencia
}
wg.Wait()
// Mostramos el saldo final
fmt.Printf("Final bank balance: $%d\n", bankBalance)
}
⚠️ Nota: Es muy importante pasari
eincome
como parámetros a la goroutine y no usar directamente las variables del bucle, ya que estas cambiarán mientras la goroutine se ejecuta.
Los channels son el mecanismo de Go para comunicar goroutines entre sí. Mientras que los mutexes protegen el acceso a la memoria compartida, los channels siguen la filosofía de Go:
"No comuniques compartiendo memoria; comparte memoria comunicándote."
- Son tipos de datos tipados (solo transmiten un tipo específico)
- Son thread-safe (no necesitas mutex para usarlos)
- Pueden ser bufferados o no bufferados
- Pueden bloquearse hasta que otra goroutine lea/escriba
package main
import (
"fmt"
"time"
)
func main() {
// Creamos un channel de enteros
ch := make(chan int)
// Goroutine que envía datos
go func() {
fmt.Println("Goroutine: Enviando datos...")
ch <- 42 // Enviamos el valor 42 al channel
}()
// Leemos del channel en la goroutine principal
fmt.Println("Main: Esperando datos...")
valor := <-ch // Esta operación bloquea hasta recibir un valor
fmt.Println("Main: Recibido", valor)
}
- No bufferados: Sincronizan las goroutines que envían y reciben (el envío bloquea hasta que alguien recibe)
- Bufferados: Pueden almacenar un número limitado de valores sin bloquear al remitente
// Channel no bufferado (capacidad 0)
ch1 := make(chan int)
// Channel bufferado (capacidad 5)
ch2 := make(chan int, 5)
package main
import (
"fmt"
"time"
)
func productor(ch chan<- int) {
for i := 0; i < 5; i++ {
fmt.Printf("Productor: enviando %d\n", i)
ch <- i // Enviar dato al channel
time.Sleep(100 * time.Millisecond)
}
close(ch) // Importante: cerrar el channel cuando terminemos
}
func consumidor(ch <-chan int, done chan<- bool) {
for valor := range ch { // range con channel itera hasta que se cierre
fmt.Printf("Consumidor: recibido %d\n", valor)
}
fmt.Println("Consumidor: channel cerrado, terminando")
done <- true
}
func main() {
ch := make(chan int)
done := make(chan bool)
go productor(ch)
go consumidor(ch, done)
<-done // Esperar a que el consumidor termine
fmt.Println("Programa finalizado")
}
- Necesites proteger datos compartidos entre goroutines
- Tengas operaciones atómicas simple sobre variables
- Quieras implementar tu propio mecanismo de sincronización
- Necesites transferir propiedad de datos entre goroutines
- Quieras distribuir unidades de trabajo entre worker pools
- Necesites comunicar resultados a través de goroutines
- Quieras implementar el patrón productor-consumidor
Para probar código concurrente con race conditions:
package main
import (
"strings"
"testing"
"os"
"io"
)
func Test_main(t *testing.T) {
// Capturamos la salida estándar
stdOut := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
// Ejecutamos la función principal
main()
// Restauramos la salida y leemos el resultado
w.Close()
result, _ := io.ReadAll(r)
output := string(result)
os.Stdout = stdOut
// Verificamos los resultados
if !strings.Contains(output, "Starting bank balance: $0") {
t.Errorf("Expected 'Starting bank balance: $0' but got %s", output)
}
// Importante: ejecutar este test con go test -race
// para detectar posibles race conditions
}
- Usa
-race
regularmente para detectar race conditions - Minimiza el alcance del bloqueo - mantén los mutex bloqueados el menor tiempo posible
- Considera RWMutex cuando tengas muchas lecturas y pocas escrituras
- Cierra los channels cuando no enviarás más datos
- Evita el uso de variables globales - pasa los datos necesarios como parámetros
// Mejor que bloquear toda la función:
mu.Lock()
defer mu.Unlock()
// toda la función bloqueada...
// Es más eficiente:
// código no crítico aquí...
mu.Lock()
// Solo el código crítico aquí
mu.Unlock()
// más código no crítico...
El problema del productor-consumidor (también conocido como bounded-buffer problem) es uno de los clásicos desafíos de sincronización en programación concurrente. Consiste en coordinar dos tipos de procesos:
- 👨🍳 Productores: Generan datos y los colocan en un buffer compartido
- 🧑🤝🧑 Consumidores: Toman datos del buffer compartido y los procesan
- 🗄️ Buffer: Espacio limitado donde se almacenan temporalmente los datos
💡 El desafío real es coordinar estos procesos para evitar race conditions, asegurar que el buffer no se desborde, y garantizar que los consumidores no intenten tomar datos de un buffer vacío.
Imaginemos una pizzería donde:
- 👨🍳 Cocineros (productores): Preparan pizzas y las colocan en la ventana de servicio
- 🧑💼 Camareros (consumidores): Toman las pizzas de la ventana y las entregan a los clientes
- 🪟 Ventana de servicio (buffer): Espacio limitado donde se colocan las pizzas listas
Si los cocineros producen pizzas más rápido de lo que los camareros pueden entregarlas, la ventana de servicio se llena. Si no hay pizzas listas, los camareros deben esperar.
Go resuelve este problema elegantemente mediante channels, que funcionan como un buffer sincronizado entre goroutines:
// Creando un channel bufferado (tamaño 5)
pizzaBuffer := make(chan Pizza, 5)
// Productor: cocina pizzas y las coloca en el buffer
go func() {
for {
pizza := prepararPizza()
pizzaBuffer <- pizza // Se bloquea si el buffer está lleno
}
}()
// Consumidor: toma pizzas del buffer y las entrega
go func() {
for {
pizza := <-pizzaBuffer // Se bloquea si el buffer está vacío
entregarPizza(pizza)
}
}()
Veamos una implementación completa de una pizzería usando concurrencia en Go:
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
// 🍕 Representa un pedido de pizza
type PizzaOrder struct {
orderNumber int
pizzaName string
customer string
ready bool
success bool
}
// 👨🍳 Productor: La cocina que prepara pizzas
func cocinero(orders chan<- PizzaOrder, wg *sync.WaitGroup) {
defer wg.Done()
pizzaTypes := []string{"Margherita", "Pepperoni", "Vegetariana", "Hawaiana", "Cuatro Quesos"}
customers := []string{"Carlos", "Ana", "Luis", "Elena", "Miguel"}
// Preparamos 10 pizzas
for i := 1; i <= 10; i++ {
// Simulamos tiempo de preparación
time.Sleep(time.Millisecond * time.Duration(rand.Intn(500)+300))
// Creamos el pedido
success := rand.Float32() > 0.2 // 20% de probabilidad de fallar
order := PizzaOrder{
orderNumber: i,
pizzaName: pizzaTypes[rand.Intn(len(pizzaTypes))],
customer: customers[rand.Intn(len(customers))],
ready: true,
success: success,
}
fmt.Printf("👨🍳 Cocinero: Preparando pizza %s para %s (Orden #%d)\n",
order.pizzaName, order.customer, order.orderNumber)
// Enviamos la pizza al buffer (ventana de servicio)
orders <- order
if !success {
fmt.Printf("⚠️ Cocinero: ¡Ups! La pizza %s para %s salió mal\n",
order.pizzaName, order.customer)
} else {
fmt.Printf("✅ Cocinero: ¡Pizza %s para %s lista!\n",
order.pizzaName, order.customer)
}
}
fmt.Println("👨🍳 Cocinero: ¡Terminé todos los pedidos por hoy!")
close(orders) // Cerramos el canal cuando no hay más pedidos
}
// 🧑💼 Consumidor: El camarero que entrega las pizzas
func camarero(orders <-chan PizzaOrder, wg *sync.WaitGroup) {
defer wg.Done()
// Atendemos pedidos hasta que la cocina cierre
for order := range orders {
// Simulamos tiempo de entrega
time.Sleep(time.Millisecond * time.Duration(rand.Intn(300)+200))
if order.success {
fmt.Printf("🧑💼 Camarero: Entregando pizza %s a %s (Orden #%d) ✅\n",
order.pizzaName, order.customer, order.orderNumber)
} else {
fmt.Printf("🧑💼 Camarero: Disculpándose con %s por la pizza %s fallida (Orden #%d) ❌\n",
order.customer, order.pizzaName, order.orderNumber)
}
}
fmt.Println("🧑💼 Camarero: No hay más pedidos, me voy a casa")
}
func main() {
// Semilla para números aleatorios
rand.Seed(time.Now().UnixNano())
fmt.Println("🍕 ¡La pizzería está abierta! 🍕")
// Creamos un canal bufferado (nuestra ventana de servicio)
// Puede contener hasta 3 pizzas a la vez
pizzaBuffer := make(chan PizzaOrder, 3)
var wg sync.WaitGroup
wg.Add(2) // Un cocinero y un camarero
// Iniciamos el cocinero (productor)
go cocinero(pizzaBuffer, &wg)
// Iniciamos el camarero (consumidor)
go camarero(pizzaBuffer, &wg)
// Esperamos a que terminen sus tareas
wg.Wait()
fmt.Println("🏁 ¡La pizzería ha cerrado por hoy! 🏁")
}
- Creamos un buffer limitado (el channel
pizzaBuffer
) con capacidad para 3 pizzas - El cocinero (productor) genera pizzas y las coloca en el buffer:
- Si el buffer está lleno, el cocinero se bloquea hasta que haya espacio
- Cuando termina todos los pedidos, cierra el channel
- El camarero (consumidor) toma pizzas del buffer y las entrega:
- Si el buffer está vacío, el camarero se bloquea hasta que haya una pizza
- El range loop termina automáticamente cuando el channel se cierra
- Sincronización integrada: Los channels manejan automáticamente la sincronización
- Bloqueo natural: El productor se bloquea cuando el buffer está lleno, y el consumidor cuando está vacío
- Comunicación clara: El patrón de paso de mensajes hace el código más fácil de entender
- Cierre elegante: Usando
close(channel)
podemos indicar "fin de la producción"
Puedes expandir este patrón para casos más complejos:
// Lanzamos varios cocineros
for i := 1; i <= 3; i++ {
wg.Add(1)
go cocinero(i, pizzaBuffer, &wg)
}
// Lanzamos varios camareros
for i := 1; i <= 2; i++ {
wg.Add(1)
go camarero(i, pizzaBuffer, &wg)
}
select {
case normalPizza := <-normalOrders:
// Procesar pedido normal
case urgentPizza := <-urgentOrders:
// Procesar pedido urgente
case <-time.After(30 * time.Second):
// Timeout si no hay pedidos por 30 segundos
}
- Un buffer limitado previene que el productor sobrecargue al consumidor
- Los channels en Go son una implementación natural del buffer para este problema
- El bloqueo ocurre automáticamente cuando un channel está lleno o vacío
- Cerrar un channel es la forma de indicar "no más producción"
- Un range sobre un channel itera hasta que el channel se cierra
Este patrón es fundamental en sistemas concurrentes y lo encontrarás en muchas aplicaciones del mundo real, desde procesamiento de solicitudes web hasta sistemas de streaming de datos.
El Problema de los Filósofos Comensales es uno de los ejemplos clásicos en la teoría de concurrencia, introducido por el científico computacional Edsger W. Dijkstra en 1965. Este problema ilustra los desafíos de asignación de recursos y prevención de interbloqueos (deadlocks).
💭 La metáfora: Cinco filósofos están sentados alrededor de una mesa redonda. Cada uno tiene un plato de espaguetis y necesita dos tenedores para comer. Sin embargo, solo hay cinco tenedores en total, uno entre cada par de filósofos.
El problema presenta un clásico escenario de interbloqueo:
- Cada filósofo necesita DOS recursos (tenedores) para realizar su actividad (comer)
- Si todos los filósofos toman su tenedor izquierdo simultáneamente, ninguno podrá tomar el derecho
- Resultado: todos quedan esperando indefinidamente → deadlock 🔒
La solución implementada usa una técnica llamada "rompimiento de simetría": algunos filósofos toman primero el tenedor de menor número, mientras que otros toman primero el de mayor número.
// Si el tenedor izquierdo tiene un número mayor, tomar primero el derecho
if philosopher.leftFork > philosopher.rightFork {
forks[philosopher.rightFork].Lock()
// ...
forks[philosopher.leftFork].Lock()
} else { // De lo contrario, tomar primero el izquierdo
forks[philosopher.leftFork].Lock()
// ...
forks[philosopher.rightFork].Lock()
}
package main
import (
"fmt"
"sync"
"time"
)
// Estructura para representar a un filósofo
type Philosopher struct {
name string
rightFork int
leftFork int
}
// Lista de filósofos con sus tenedores asignados
var philosophers = []Philosopher{
{name: "Kant", leftFork: 4, rightFork: 0},
{name: "Hume", leftFork: 0, rightFork: 1},
{name: "Descartes", leftFork: 1, rightFork: 2},
{name: "Nietzsche", leftFork: 2, rightFork: 3},
{name: "Wittgenstein", leftFork: 3, rightFork: 4},
}
// Variables para configurar el comportamiento
var hunger = 3 // Cuántas veces comerá cada filósofo
var eatTime = 1 * time.Second // Tiempo dedicado a comer
var thinkTime = 3 * time.Second // Tiempo dedicado a pensar
var sleepTime = 1 * time.Second // Pausa inicial para mejor visualización
var orderMutex sync.Mutex // Mutex para proteger la lista orderFinished
var orderFinished []string // Registro del orden en que terminan de comer
func main() {
// Mensaje de bienvenida
fmt.Println("El Problema de los Filósofos Comensales")
fmt.Println("--------------------------------------")
fmt.Println("La mesa está vacía.")
time.Sleep(sleepTime)
// Comenzamos la cena
dine()
// Mensaje de finalización
fmt.Println("La mesa está vacía.")
fmt.Println("--------------------------------------")
// Podríamos mostrar el orden en que terminaron
fmt.Println("Orden de finalización:", orderFinished)
}
func dine() {
// WaitGroup para esperar a que todos terminen de comer
wg := &sync.WaitGroup{}
wg.Add(len(philosophers))
// WaitGroup para que todos estén sentados antes de comenzar
seated := &sync.WaitGroup{}
seated.Add(len(philosophers))
// Creamos los mutex para cada tenedor
var forks = make(map[int]*sync.Mutex)
for i := 0; i < len(philosophers); i++ {
forks[i] = &sync.Mutex{}
}
// Lanzamos una goroutine para cada filósofo
for i := 0; i < len(philosophers); i++ {
go diningProblem(philosophers[i], wg, forks, seated)
}
// Esperamos a que todos terminen
wg.Wait()
}
func diningProblem(philosopher Philosopher, wg *sync.WaitGroup, forks map[int]*sync.Mutex, seated *sync.WaitGroup) {
defer wg.Done() // Señalamos que el filósofo terminó cuando salga la función
// El filósofo se sienta
fmt.Printf("%s se sienta a la mesa.\n", philosopher.name)
seated.Done()
// Esperamos a que todos estén sentados
seated.Wait()
// Ciclo de comer (según el hambre configurada)
for i := hunger; i > 0; i-- {
// 🔑 SOLUCIÓN AL DEADLOCK: Romper la simetría en cómo toman los tenedores
if philosopher.leftFork > philosopher.rightFork {
// Algunos filósofos toman primero el tenedor derecho
forks[philosopher.rightFork].Lock()
fmt.Printf("%s tiene el tenedor derecho.\n", philosopher.name)
forks[philosopher.leftFork].Lock()
fmt.Printf("%s tiene el tenedor izquierdo.\n", philosopher.name)
} else {
// Otros toman primero el izquierdo
forks[philosopher.leftFork].Lock()
fmt.Printf("%s tiene el tenedor izquierdo.\n", philosopher.name)
forks[philosopher.rightFork].Lock()
fmt.Printf("%s tiene el tenedor derecho.\n", philosopher.name)
}
// El filósofo come
fmt.Printf("🍝 %s está comiendo.\n", philosopher.name)
time.Sleep(eatTime)
// El filósofo piensa
fmt.Printf("🤔 %s está pensando.\n", philosopher.name)
time.Sleep(thinkTime)
// Suelta los tenedores
forks[philosopher.leftFork].Unlock()
forks[philosopher.rightFork].Unlock()
fmt.Printf("🍴 %s dejó los tenedores.\n", philosopher.name)
}
// El filósofo termina y se va
fmt.Printf("✅ %s terminó de comer.\n", philosopher.name)
fmt.Printf("👋 %s se retira de la mesa.\n", philosopher.name)
// Registramos el orden de finalización (protegido por mutex)
orderMutex.Lock()
orderFinished = append(orderFinished, philosopher.name)
orderMutex.Unlock()
}
La solución implementada evita el deadlock mediante tres estrategias clave:
-
Rompimiento de simetría 🔄: Al hacer que algunos filósofos tomen los tenedores en orden diferente (izquierdo-derecho vs derecho-izquierdo), se rompe la condición circular que causa el deadlock.
-
Mutex para cada tenedor 🔒: Cada tenedor está modelado como un mutex, lo que garantiza acceso exclusivo.
-
Coordinación para sentarse ⏱️: Usamos
seated
WaitGroup para asegurar que todos los filósofos estén listos antes de comenzar a competir por los tenedores.
-
Deadlock (interbloqueo): Situación donde un grupo de procesos espera indefinidamente por recursos que otro proceso del mismo grupo posee.
-
Livelock: Situación donde los procesos cambian de estado continuamente pero no avanzan (como dos personas en un pasillo tratando de ceder el paso).
-
Inanición (starvation): Cuando un proceso nunca recibe el recurso que necesita porque otros procesos siempre tienen prioridad.
-
Exclusión mutua: Garantía de que sólo un proceso puede utilizar un recurso a la vez.
El código de prueba verifica que la solución funcione correctamente:
func Test_dine(t *testing.T) {
// Aceleramos la ejecución para las pruebas
eatTime = 0 * time.Second
sleepTime = 0 * time.Second
thinkTime = 0 * time.Second
// Ejecutamos 10 veces para asegurarnos de que no hay deadlock
for i := 0; i < 10; i++ {
orderFinished = []string{}
dine()
if len(orderFinished) != 5 {
t.Errorf("Esperaba que 5 filósofos terminaran de comer, obtuve %d", len(orderFinished))
}
}
}
También se prueba con diferentes tiempos para asegurar robustez:
func Test_dineWithVaryingDelays(t *testing.T) {
var theTest = []struct{
name string
delay time.Duration
} {
{"rápido", 0 * time.Second},
{"lento", 1 * time.Second},
{"muy lento", 2 * time.Second},
}
// Prueba con diferentes velocidades
for _, test := range theTest {
eatTime = test.delay
sleepTime = test.delay
thinkTime = test.delay
for i := 0; i < 10; i++ {
orderFinished = []string{}
dine()
if len(orderFinished) != 5 {
t.Errorf("Esperaba que 5 filósofos terminaran de comer, obtuve %d", len(orderFinished))
}
}
}
}
Existen diferentes enfoques para resolver este problema:
-
Camarero: Introducir un actor centralizado (camarero) que controla quién puede tomar los tenedores.
-
Jerarquía de recursos: Numerar todos los recursos y requerir que los procesos los adquieran en orden numérico.
-
Limitación de comensales: Permitir solo 4 filósofos a la mesa simultáneamente (evitando la condición de deadlock).
-
Tenedores compartidos: Usar un único mutex para controlar simultáneamente ambos tenedores.
El problema de los Filósofos Comensales es importante porque:
- Modela situaciones reales de sistemas operativos, bases de datos y otros sistemas concurrentes
- Demuestra las dificultades de la asignación de recursos compartidos
- Ilustra claramente los peligros del deadlock
- Proporciona un marco para enseñar y evaluar soluciones de sincronización
Este problema, aunque simple en su concepto, captura la esencia de muchos desafíos que encontramos en sistemas concurrentes modernos, desde servidores web hasta sistemas distribuidos.
El problema de los filósofos comensales plantea un escenario donde cinco filósofos se sientan alrededor de una mesa circular. Cada uno necesita dos tenedores para comer, pero hay solo un tenedor entre cada par de filósofos. El desafío es diseñar un algoritmo que permita a todos comer sin caer en un deadlock.
Introducimos un "camarero" como coordinador central que decide quién puede tomar los tenedores. Los filósofos deben "pedir permiso" antes de intentar tomar los tenedores.
package main
import (
"fmt"
"sync"
"time"
)
type Philosopher struct {
name string
rightFork int
leftFork int
}
// Estructura para representar al camarero
type Waiter struct {
sync.Mutex
}
func (w *Waiter) RequestForks(philosopher *Philosopher, forks map[int]*sync.Mutex) bool {
w.Lock()
defer w.Unlock()
// El camarero verifica si ambos tenedores están disponibles
if !forks[philosopher.leftFork].TryLock() {
return false
}
if !forks[philosopher.rightFork].TryLock() {
// Si el segundo no está disponible, suelta el primero
forks[philosopher.leftFork].Unlock()
return false
}
fmt.Printf("🧑🍳 Camarero: %s puede tomar ambos tenedores.\n", philosopher.name)
return true
}
func (w *Waiter) ReleaseForks(philosopher *Philosopher, forks map[int]*sync.Mutex) {
w.Lock()
defer w.Unlock()
forks[philosopher.leftFork].Unlock()
forks[philosopher.rightFork].Unlock()
fmt.Printf("🧑🍳 Camarero: %s ha devuelto los tenedores.\n", philosopher.name)
}
func diningWithWaiter() {
var wg sync.WaitGroup
wg.Add(len(philosophers))
// Creamos los tenedores
var forks = make(map[int]*sync.Mutex)
for i := 0; i < len(philosophers); i++ {
forks[i] = &sync.Mutex{}
}
// Creamos al camarero
waiter := &Waiter{}
// Lanzamos los filósofos
for i := 0; i < len(philosophers); i++ {
go func(i int) {
defer wg.Done()
philosopher := philosophers[i]
fmt.Printf("%s se sienta a la mesa.\n", philosopher.name)
// Cada filósofo intenta comer 'hunger' veces
for j := 0; j < hunger; j++ {
// El filósofo piensa
fmt.Printf("🤔 %s está pensando...\n", philosopher.name)
time.Sleep(thinkTime)
// Solicita permiso al camarero para tomar los tenedores
for {
if waiter.RequestForks(&philosopher, forks) {
break
}
// Si no puede tomar los tenedores, espera un poco
time.Sleep(100 * time.Millisecond)
}
// Ahora puede comer
fmt.Printf("🍝 %s está comiendo.\n", philosopher.name)
time.Sleep(eatTime)
// Devuelve los tenedores
waiter.ReleaseForks(&philosopher, forks)
}
fmt.Printf("✅ %s ha terminado y se retira de la mesa.\n", philosopher.name)
}(i)
}
wg.Wait()
}
- Previene deadlock de forma centralizada
- Fácil de entender y razonar sobre su corrección
- Permite priorizar a ciertos filósofos si es necesario
- El camarero puede convertirse en un cuello de botella
- Reduce el paralelismo al centralizar las decisiones
- Mayor complejidad de implementación
Asignamos un número único a cada tenedor y requerimos que los filósofos tomen los tenedores en orden numérico ascendente. Esta estrategia es una generalización de la solución en el código original.
package main
import (
"fmt"
"sort"
"sync"
"time"
)
type Philosopher struct {
name string
forkIDs []int // IDs ordenados de los tenedores que usa
}
func diningWithHierarchy() {
var wg sync.WaitGroup
wg.Add(len(philosophers))
// Creamos los tenedores
var forks = make(map[int]*sync.Mutex)
for i := 0; i < len(philosophers); i++ {
forks[i] = &sync.Mutex{}
}
// Preparamos a los filósofos con sus tenedores en orden
hierarchicalPhilosophers := []Philosopher{
{name: "Kant", forkIDs: []int{0, 4}},
{name: "Hume", forkIDs: []int{0, 1}},
{name: "Descartes", forkIDs: []int{1, 2}},
{name: "Nietzsche", forkIDs: []int{2, 3}},
{name: "Wittgenstein", forkIDs: []int{3, 4}},
}
// Ordenamos los tenedores para cada filósofo
for i := range hierarchicalPhilosophers {
sort.Ints(hierarchicalPhilosophers[i].forkIDs)
}
// Lanzamos los filósofos
for i := 0; i < len(hierarchicalPhilosophers); i++ {
go func(i int) {
defer wg.Done()
philosopher := hierarchicalPhilosophers[i]
fmt.Printf("%s se sienta a la mesa.\n", philosopher.name)
// Cada filósofo intenta comer 'hunger' veces
for j := 0; j < hunger; j++ {
// El filósofo piensa
fmt.Printf("🤔 %s está pensando...\n", philosopher.name)
time.Sleep(thinkTime)
// Toma el tenedor con el número menor primero
fmt.Printf("%s intenta tomar el tenedor %d.\n",
philosopher.name, philosopher.forkIDs[0])
forks[philosopher.forkIDs[0]].Lock()
fmt.Printf("%s tiene el tenedor %d.\n",
philosopher.name, philosopher.forkIDs[0])
// Luego toma el tenedor con el número mayor
fmt.Printf("%s intenta tomar el tenedor %d.\n",
philosopher.name, philosopher.forkIDs[1])
forks[philosopher.forkIDs[1]].Lock()
fmt.Printf("%s tiene el tenedor %d.\n",
philosopher.name, philosopher.forkIDs[1])
// Ahora puede comer
fmt.Printf("🍝 %s está comiendo.\n", philosopher.name)
time.Sleep(eatTime)
// Suelta los tenedores en cualquier orden
forks[philosopher.forkIDs[0]].Unlock()
forks[philosopher.forkIDs[1]].Unlock()
fmt.Printf("🍴 %s ha soltado ambos tenedores.\n", philosopher.name)
}
fmt.Printf("✅ %s ha terminado y se retira de la mesa.\n", philosopher.name)
}(i)
}
wg.Wait()
}
- Previene deadlock sin necesidad de un coordinador central
- Permite mayor paralelismo que la solución del camarero
- Conceptualmente simple
- Puede llevar a inanición (starvation) de algunos filósofos
- Requiere una numeración consistente de todos los recursos
- Difícil de extender a sistemas distribuidos
Limitamos el número de filósofos que pueden intentar comer simultáneamente. Con 5 filósofos y 5 tenedores, si solo permitimos que 4 filósofos intenten comer a la vez, garantizamos que al menos uno podrá obtener ambos tenedores.
package main
import (
"fmt"
"sync"
"time"
)
func diningWithLimitation() {
var wg sync.WaitGroup
wg.Add(len(philosophers))
// Creamos los tenedores
var forks = make(map[int]*sync.Mutex)
for i := 0; i < len(philosophers); i++ {
forks[i] = &sync.Mutex{}
}
// Semáforo que limita el número de filósofos comiendo simultáneamente
// Con 5 filósofos, limitamos a 4 para evitar deadlock
maxEating := sync.NewCond(&sync.Mutex{})
eating := 0
maxAllowed := len(philosophers) - 1
// Lanzamos los filósofos
for i := 0; i < len(philosophers); i++ {
go func(i int) {
defer wg.Done()
philosopher := philosophers[i]
fmt.Printf("%s se sienta a la mesa.\n", philosopher.name)
// Cada filósofo intenta comer 'hunger' veces
for j := 0; j < hunger; j++ {
// El filósofo piensa
fmt.Printf("🤔 %s está pensando...\n", philosopher.name)
time.Sleep(thinkTime)
// Solicita permiso para intentar comer
maxEating.L.Lock()
for eating >= maxAllowed {
fmt.Printf("⌛ %s espera permiso para intentar comer...\n", philosopher.name)
maxEating.Wait()
}
eating++
maxEating.L.Unlock()
// Toma los tenedores en cualquier orden
fmt.Printf("%s intenta tomar los tenedores.\n", philosopher.name)
forks[philosopher.leftFork].Lock()
forks[philosopher.rightFork].Lock()
// Ahora puede comer
fmt.Printf("🍝 %s está comiendo.\n", philosopher.name)
time.Sleep(eatTime)
// Suelta los tenedores
forks[philosopher.leftFork].Unlock()
forks[philosopher.rightFork].Unlock()
fmt.Printf("🍴 %s ha soltado los tenedores.\n", philosopher.name)
// Notifica que ya no está comiendo
maxEating.L.Lock()
eating--
maxEating.Signal() // Notifica a un filósofo en espera
maxEating.L.Unlock()
}
fmt.Printf("✅ %s ha terminado y se retira de la mesa.\n", philosopher.name)
}(i)
}
wg.Wait()
}
- Garantiza que no habrá deadlock
- Simple de implementar y entender
- Permite cierta flexibilidad en el acceso a los recursos
- Limita el paralelismo potencial
- Puede llevar a una subutilización de recursos
- Posible inanición de algunos filósofos
En lugar de controlar cada tenedor individualmente, controlamos el acceso a ambos tenedores de un filósofo como una unidad atómica usando un único mutex.
package main
import (
"fmt"
"sync"
"time"
)
func diningWithSharedForks() {
var wg sync.WaitGroup
wg.Add(len(philosophers))
// En lugar de mutex individuales, creamos "pares de tenedores"
// Cada par representa los dos tenedores necesarios para un filósofo
var forkPairs = make(map[string]*sync.Mutex)
for i := 0; i < len(philosophers); i++ {
// Creamos un identificador único para cada par de tenedores
pairID := fmt.Sprintf("%d-%d", philosophers[i].leftFork, philosophers[i].rightFork)
forkPairs[pairID] = &sync.Mutex{}
}
// Lanzamos los filósofos
for i := 0; i < len(philosophers); i++ {
go func(i int) {
defer wg.Done()
philosopher := philosophers[i]
fmt.Printf("%s se sienta a la mesa.\n", philosopher.name)
// Identificador para este par de tenedores
pairID := fmt.Sprintf("%d-%d", philosopher.leftFork, philosopher.rightFork)
// Cada filósofo intenta comer 'hunger' veces
for j := 0; j < hunger; j++ {
// El filósofo piensa
fmt.Printf("🤔 %s está pensando...\n", philosopher.name)
time.Sleep(thinkTime)
// Intenta tomar ambos tenedores a la vez
fmt.Printf("%s intenta tomar sus tenedores (par %s).\n",
philosopher.name, pairID)
forkPairs[pairID].Lock()
// Si llegamos aquí, tiene ambos tenedores
fmt.Printf("%s tiene ambos tenedores.\n", philosopher.name)
// Ahora puede comer
fmt.Printf("🍝 %s está comiendo.\n", philosopher.name)
time.Sleep(eatTime)
// Suelta ambos tenedores a la vez
forkPairs[pairID].Unlock()
fmt.Printf("🍴 %s ha soltado sus tenedores.\n", philosopher.name)
}
fmt.Printf("✅ %s ha terminado y se retira de la mesa.\n", philosopher.name)
}(i)
}
wg.Wait()
}
- Elimina la posibilidad de deadlock al hacer atómica la adquisición de recursos
- Simplifica el razonamiento sobre la corrección del algoritmo
- Evita problemas de adquisición parcial de recursos
- Puede reducir el paralelismo al bloquear pares de tenedores juntos
- No representa fielmente la granularidad del problema original
- Puede llevar a una subutilización de recursos
Aprovechamos los canales de Go para modelar los tenedores como recursos que se pasan entre los filósofos.
package main
import (
"fmt"
"sync"
"time"
)
func diningWithChannels() {
var wg sync.WaitGroup
wg.Add(len(philosophers))
// Creamos canales para cada tenedor
forks := make([]chan struct{}, len(philosophers))
for i := 0; i < len(philosophers); i++ {
forks[i] = make(chan struct{}, 1)
// Inicialmente cada tenedor está en la mesa
forks[i] <- struct{}{}
}
// Función helper para tomar un tenedor
takeFork := func(fork chan struct{}, name string, forkID int) {
<-fork
fmt.Printf("%s toma el tenedor %d.\n", name, forkID)
}
// Función helper para dejar un tenedor
putFork := func(fork chan struct{}, name string, forkID int) {
fork <- struct{}{}
fmt.Printf("%s deja el tenedor %d.\n", name, forkID)
}
// Lanzamos los filósofos
for i := 0; i < len(philosophers); i++ {
go func(i int) {
defer wg.Done()
philosopher := philosophers[i]
fmt.Printf("%s se sienta a la mesa.\n", philosopher.name)
// Prevenimos deadlock asegurando un orden consistente
leftForkID := philosopher.leftFork
rightForkID := philosopher.rightFork
if leftForkID > rightForkID {
leftForkID, rightForkID = rightForkID, leftForkID
}
// Cada filósofo intenta comer 'hunger' veces
for j := 0; j < hunger; j++ {
// El filósofo piensa
fmt.Printf("🤔 %s está pensando...\n", philosopher.name)
time.Sleep(thinkTime)
// Toma los tenedores en orden numérico para evitar deadlock
takeFork(forks[leftForkID], philosopher.name, leftForkID)
takeFork(forks[rightForkID], philosopher.name, rightForkID)
// Ahora puede comer
fmt.Printf("🍝 %s está comiendo.\n", philosopher.name)
time.Sleep(eatTime)
// Deja los tenedores
putFork(forks[rightForkID], philosopher.name, rightForkID)
putFork(forks[leftForkID], philosopher.name, leftForkID)
}
fmt.Printf("✅ %s ha terminado y se retira de la mesa.\n", philosopher.name)
}(i)
}
wg.Wait()
}
- Utiliza el modelo de comunicación nativo de Go
- Los canales facilitan la visualización del paso de recursos
- Código expresivo y conciso
- Puede ser menos eficiente que una implementación con mutex
- Requiere cuidadosa consideración del tamaño del buffer del canal
- El orden de adquisición sigue siendo crucial para evitar deadlocks
Solución | Previene Deadlock | Nivel de Paralelismo | Complejidad | Equidad |
---|---|---|---|---|
Original (Dijkstra) | ✅ | Alto | Media | Media |
Camarero | ✅ | Bajo-Medio | Media | Alta (configurable) |
Jerarquía | ✅ | Alto | Baja | Baja |
Limitación | ✅ | Medio | Baja | Media |
Tenedores Compartidos | ✅ | Medio | Baja | Media |
Canales | ✅ | Alto | Media | Media |
Para probar exhaustivamente estas soluciones, podríamos implementar un test que verifica:
package main
import (
"testing"
"time"
)
func TestAllSolutions(t *testing.T) {
// Configuración rápida para testing
originalHunger := hunger
originalEatTime := eatTime
originalThinkTime := thinkTime
hunger = 3
eatTime = 10 * time.Millisecond
thinkTime = 10 * time.Millisecond
// Restauramos los valores originales al finalizar
defer func() {
hunger = originalHunger
eatTime = originalEatTime
thinkTime = originalThinkTime
}()
testCases := []struct{
name string
solution func()
}{
{"Original (Dijkstra)", dine},
{"Camarero", diningWithWaiter},
{"Jerarquía", diningWithHierarchy},
{"Limitación", diningWithLimitation},
{"Tenedores Compartidos", diningWithSharedForks},
{"Canales", diningWithChannels},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Ejecutamos cada solución varias veces para verificar consistencia
for i := 0; i < 5; i++ {
orderFinished = nil
tc.solution()
if len(orderFinished) != len(philosophers) {
t.Errorf("%s: No todos los filósofos terminaron (iteración %d)", tc.name, i)
}
}
})
}
}
El problema de los filósofos comensales ilustra desafíos fundamentales en concurrencia:
- Evitar deadlocks es primordial en sistemas concurrentes
- La coordinación puede lograrse tanto de forma centralizada como descentralizada
- El equilibrio entre paralelismo y control es una consideración de diseño importante
Al elegir una solución:
- En sistemas pequeños, la solución del camarero puede ser la más simple y clara
- Para alta disponibilidad, las soluciones descentralizadas como la jerarquía son preferibles
- En Go específicamente, la solución basada en canales alinea mejor con la filosofía del lenguaje
- Para rendimiento óptimo, la solución original de Dijkstra suele ofrecer el mejor balance
Estos patrones de diseño concurrente aparecen en muchos sistemas modernos:
- Administradores de conexiones de base de datos
- Sistemas de gestión de recursos en la nube
- Planificadores de tareas
- Sistemas de archivos distribuidos
La comprensión profunda de estas soluciones proporciona una base sólida para diseñar sistemas concurrentes robustos y eficientes.
Los channels son una de las características más poderosas y distintivas de Go. Actúan como conductos tipados que permiten a las goroutines comunicarse entre sí, siguiendo el principio fundamental:
"No comuniques compartiendo memoria; comparte memoria comunicándote"
Un channel en Go es una estructura de datos tipada que funciona como una tubería por la que pueden fluir valores entre goroutines:
// Creando un channel básico que transmite enteros
ch := make(chan int)
// Enviando un valor al channel
ch <- 42
// Recibiendo un valor del channel
valor := <-ch
Los channels proporcionan:
- Sincronización: Coordinan la ejecución entre goroutines
- Comunicación: Permiten el intercambio seguro de datos
- Garantías de memoria: Aseguran la visibilidad de los cambios entre goroutines
ch := make(chan string) // Channel sin buffer
- Comportamiento: Las operaciones de envío bloquean hasta que otra goroutine recibe el valor
- Analogía: Como pasar un testigo en una carrera de relevos - debes esperar a que alguien tome el testigo
// Ejemplo de channel sin buffer
func main() {
ch := make(chan string)
go func() {
msg := <-ch // Esta goroutine debe ejecutarse primero y esperar
fmt.Println("Recibido:", msg)
}()
time.Sleep(time.Second) // Damos tiempo a que la goroutine se inicie
ch <- "Hola mundo" // Se bloquea hasta que alguien reciba
fmt.Println("Mensaje enviado")
}
ch := make(chan string, 5) // Channel con buffer de tamaño 5
- Comportamiento: Las operaciones de envío solo bloquean cuando el buffer está lleno
- Analogía: Como un buzón con capacidad limitada - puedes dejar varias cartas sin esperar
// Ejemplo de channel con buffer
func main() {
ch := make(chan string, 3)
ch <- "Uno" // No bloquea
ch <- "Dos" // No bloquea
ch <- "Tres" // No bloquea
// ch <- "Cuatro" // ¡Bloquearía porque el buffer está lleno!
fmt.Println(<-ch) // "Uno"
fmt.Println(<-ch) // "Dos"
fmt.Println(<-ch) // "Tres"
}
ch := make(chan string) // Puede enviar y recibir
var sendCh chan<- string // Solo puede enviar
sendCh = ch // Conversión válida de bidireccional a send-only
var receiveCh <-chan string // Solo puede recibir
receiveCh = ch // Conversión válida de bidireccional a receive-only
- Beneficio: Restricción explícita de operaciones permitidas, lo que mejora la seguridad
// Ejemplo de canales direccionales
func productor(out chan<- int) {
for i := 0; i < 5; i++ {
out <- i
}
close(out)
}
func consumidor(in <-chan int) {
for num := range in {
fmt.Println("Consumido:", num)
}
}
func main() {
ch := make(chan int)
go productor(ch)
consumidor(ch)
}
ch <- valor // Envía 'valor' al channel 'ch'
- En un channel sin buffer: bloquea hasta que el valor es recibido
- En un channel con buffer: bloquea solo si el buffer está lleno
valor := <-ch // Asigna el valor recibido a 'valor'
valor, ok := <-ch // 'ok' es false si el canal está cerrado
<-ch // Descarta el valor (útil para sincronización)
- Bloquea hasta que haya un valor disponible o el canal se cierre
close(ch) // Señaliza que no se enviarán más valores
- Importante: Solo el remitente debe cerrar un canal, nunca el receptor
- Después de cerrar: las operaciones de recepción devuelven el valor cero del tipo del canal y
ok=false
- Enviar a un canal cerrado causa pánico
for valor := range ch {
// Procesa cada valor hasta que el canal se cierre
fmt.Println(valor)
}
- Termina automáticamente cuando el canal se cierra
La declaración select
permite esperar en múltiples operaciones de channel:
select {
case v1 := <-ch1:
fmt.Println("Recibido de ch1:", v1)
case v2 := <-ch2:
fmt.Println("Recibido de ch2:", v2)
case ch3 <- valor:
fmt.Println("Enviado a ch3")
case <-time.After(1 * time.Second):
fmt.Println("Timeout después de 1 segundo")
default:
fmt.Println("Ninguna operación lista (no bloqueante)")
}
- Si múltiples casos están listos, uno se elige al azar
- Con
default
: la operación es no bloqueante - Sin
default
: bloquea hasta que un caso esté listo
// Ejemplo de select con timeout
func worker(job int) int {
// Simulamos trabajo que toma tiempo aleatorio
time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
return job * 2
}
func main() {
rand.Seed(time.Now().UnixNano())
ch := make(chan int)
go func() {
ch <- worker(5)
}()
select {
case result := <-ch:
fmt.Println("El trabajo completó con resultado:", result)
case <-time.After(500 * time.Millisecond):
fmt.Println("El trabajo tomó demasiado tiempo")
}
}
done := make(chan struct{})
go func() {
// Hacer trabajo...
done <- struct{}{} // Señaliza terminación
}()
<-done // Espera la señal
select {
case result := <-workCh:
// Procesar resultado
case <-time.After(5 * time.Second):
// Manejar timeout
}
for _, task := range tasks {
go worker(task, resultCh)
}
merged := make(chan int)
go func() {
defer close(merged)
for i := 0; i < len(channels); i++ {
for val := range channels[i] {
merged <- val
}
}
}()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go worker(ctx, ...)
// Limitar concurrencia a 3 operaciones simultáneas
sem := make(chan struct{}, 3)
for i := 0; i < 10; i++ {
sem <- struct{}{} // Adquiere el semáforo
go func(i int) {
defer func() { <-sem }() // Libera el semáforo
heavyWork(i)
}(i)
}
El Barbero Dormilón es un problema clásico de concurrencia que podemos resolver elegantemente con channels.
- Una barbería con un barbero, una silla de barbero y varias sillas de espera
- Si no hay clientes, el barbero se duerme
- Cuando llega un cliente:
- Si el barbero está dormido, lo despierta
- Si hay sillas disponibles, espera
- Si no hay sillas, se va
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
// Configuración
const (
sillasEspera = 3 // Capacidad de la sala de espera
tiempoCorte = 1000 // ms que tarda un corte
tiempoLlegada = 300 // ms promedio entre llegadas de clientes
tiempoBarberia = 10000 // ms que permanece abierta la barbería
)
func main() {
rand.Seed(time.Now().UnixNano())
// Channels para comunicación
salaEspera := make(chan int, sillasEspera) // Clientes en espera (bufferizado)
barberoDispo := make(chan bool, 1) // Señal cuando el barbero está disponible
cerrado := make(chan bool) // Señal cuando cierra la barbería
var wg sync.WaitGroup
// Estadísticas
var clientesAtendidos int
var clientesPerdidos int
var mu sync.Mutex
// Iniciamos al barbero
wg.Add(1)
fmt.Println("💈 Barbería abierta")
go func() {
defer wg.Done()
for {
// Si no hay clientes, el barbero "se duerme"
fmt.Println("💤 Barbero esperando clientes...")
select {
case id, open := <-salaEspera:
if !open {
fmt.Println("🏁 Barbero termina su jornada")
return
}
// Atender al cliente
fmt.Printf("💇♂️ Barbero atendiendo al cliente %d\n", id)
time.Sleep(time.Duration(rand.Intn(tiempoCorte/2) + tiempoCorte/2) * time.Millisecond)
fmt.Printf("✅ Cliente %d atendido\n", id)
mu.Lock()
clientesAtendidos++
mu.Unlock()
}
}
}()
// Generamos clientes
go func() {
clienteID := 1
end := time.After(time.Duration(tiempoBarberia) * time.Millisecond)
// Generamos clientes hasta que cierre la barbería
for {
select {
case <-end:
fmt.Println("🔒 Barbería cerrando sus puertas")
close(cerrado)
return
case <-time.After(time.Duration(rand.Intn(tiempoLlegada*2)) * time.Millisecond):
// Llegó un nuevo cliente
id := clienteID
clienteID++
// Cliente intenta entrar a la sala de espera
select {
case salaEspera <- id:
fmt.Printf("👨 Cliente %d entró a la sala de espera\n", id)
default:
// Sala de espera llena
mu.Lock()
clientesPerdidos++
mu.Unlock()
fmt.Printf("🚶 Cliente %d se fue, sala de espera llena\n", id)
}
}
}
}()
// Esperamos a que cierre la barbería
<-cerrado
// Atendemos a los clientes restantes
fmt.Println("⌛ Atendiendo a los últimos clientes...")
close(salaEspera)
// Esperamos que terminen todas las goroutines
wg.Wait()
// Mostramos estadísticas
fmt.Println("\n📊 ESTADÍSTICAS FINALES")
fmt.Printf("✅ Clientes atendidos: %d\n", clientesAtendidos)
fmt.Printf("❌ Clientes perdidos: %d\n", clientesPerdidos)
fmt.Printf("💰 Ingresos del día: $%d\n", clientesAtendidos*20)
fmt.Println("💈 Barbería cerrada por hoy")
}
salaEspera
como channel bufferizado: Representa las sillas de espera con capacidad limitadaselect
condefault
: Permite implementar la llegada no bloqueante de clientes (se van si está lleno)- Cierre de channel: Señaliza que no llegarán más clientes
- Coordinación con WaitGroup: Asegura que todos los clientes sean atendidos antes de salir
Un ejemplo práctico que muestra el poder de los channels en una aplicación más compleja:
package main
import (
"fmt"
"sync"
"time"
)
// Tipo de mensaje
type Message struct {
sender string
content string
timestamp time.Time
}
// Cliente de chat
type Client struct {
name string
incoming chan Message // Mensajes entrantes
outgoing chan<- Message // Mensajes salientes
quit chan struct{} // Señal para desconectar
}
// Centro de distribución de mensajes
type Hub struct {
clients map[string]*Client
register chan *Client
unregister chan string
broadcast chan Message
mutex sync.RWMutex
}
// Inicializar un nuevo hub
func NewHub() *Hub {
return &Hub{
clients: make(map[string]*Client),
register: make(chan *Client),
unregister: make(chan string),
broadcast: make(chan Message),
}
}
// Ejecutar el hub
func (h *Hub) Run() {
for {
select {
case client := <-h.register:
// Registrar nuevo cliente
h.mutex.Lock()
h.clients[client.name] = client
h.mutex.Unlock()
fmt.Printf("👋 %s se ha conectado\n", client.name)
case name := <-h.unregister:
// Eliminar cliente
h.mutex.Lock()
if client, ok := h.clients[name]; ok {
close(client.incoming)
delete(h.clients, name)
}
h.mutex.Unlock()
fmt.Printf("👋 %s se ha desconectado\n", name)
case msg := <-h.broadcast:
// Distribuir mensaje a todos los clientes excepto al remitente
h.mutex.RLock()
for name, client := range h.clients {
if name != msg.sender {
select {
case client.incoming <- msg:
// Mensaje enviado exitosamente
default:
// Canal lleno, desregistramos al cliente
h.mutex.RUnlock()
h.unregister <- name
h.mutex.RLock()
}
}
}
h.mutex.RUnlock()
}
}
}
// Crear nuevo cliente
func NewClient(name string, hub *Hub) *Client {
client := &Client{
name: name,
incoming: make(chan Message, 10),
outgoing: hub.broadcast,
quit: make(chan struct{}),
}
hub.register <- client
return client
}
// Enviar mensaje
func (c *Client) Send(content string) {
msg := Message{
sender: c.name,
content: content,
timestamp: time.Now(),
}
c.outgoing <- msg
fmt.Printf("[%s] %s: %s\n",
msg.timestamp.Format("15:04:05"), c.name, content)
}
// Procesar mensajes entrantes
func (c *Client) HandleMessages() {
for {
select {
case msg, ok := <-c.incoming:
if !ok {
return
}
fmt.Printf("[%s] 📬 %s recibió de %s: %s\n",
msg.timestamp.Format("15:04:05"), c.name, msg.sender, msg.content)
case <-c.quit:
return
}
}
}
// Desconectar cliente
func (c *Client) Disconnect(hub *Hub) {
hub.unregister <- c.name
close(c.quit)
}
func main() {
hub := NewHub()
go hub.Run()
// Creamos clientes
alice := NewClient("Alice", hub)
bob := NewClient("Bob", hub)
charlie := NewClient("Charlie", hub)
// Manejamos mensajes en goroutines
go alice.HandleMessages()
go bob.HandleMessages()
go charlie.HandleMessages()
// Simulamos una conversación
time.Sleep(500 * time.Millisecond)
alice.Send("¡Hola a todos!")
time.Sleep(800 * time.Millisecond)
bob.Send("Hola Alice, ¿cómo estás?")
time.Sleep(1000 * time.Millisecond)
charlie.Send("¡Yo también estoy aquí!")
time.Sleep(700 * time.Millisecond)
// Bob se desconecta
bob.Disconnect(hub)
time.Sleep(500 * time.Millisecond)
alice.Send("¿Bob sigues ahí?")
time.Sleep(1000 * time.Millisecond)
// Limpieza
alice.Disconnect(hub)
charlie.Disconnect(hub)
fmt.Println("Demostración finalizada")
}
- Canales como propiedades de structs
- Canales direccionales
- Select para manejo de múltiples canales
- Patrón de registro/desregistro
- Comunicación broadcast
- Señalización para terminación
- Manejo de cierres de canales
-
Deadlock: Todas las goroutines bloqueadas
ch := make(chan int) ch <- 1 // ¡Deadlock! Nadie está recibiendo
-
Enviar a un canal cerrado: Causa panic
close(ch) ch <- 42 // panic: send on closed channel
-
Olvidar cerrar canales: Causa fugas de memoria o goroutines bloqueadas
-
Cerrar un canal más de una vez: Causa panic
close(ch) close(ch) // panic: close of closed channel
-
Cerrar canales desde el receptor: Va contra el patrón recomendado
-
El emisor debe cerrar el canal, no el receptor
-
Usa canales direccionales en firmas de funciones para claridad
func productor(out chan<- int) { /* ... */ } func consumidor(in <-chan int) { /* ... */ }
-
Documenta quién es responsable de cerrar cada canal
-
Usa context para cancelación en sistemas complejos
ctx, cancel := context.WithCancel(context.Background()) defer cancel()
-
Prefiere canales sin buffer a menos que necesites específicamente el buffering
-
Usa nil channels para deshabilitar casos en select
var ch chan int // ch es nil select { case <-ch: // Este caso nunca será seleccionado case <-otroCanal: // ... }
-
Usa
for-range
para consumir canales hasta que se cierrenfor valor := range ch { // Procesa cada valor }
Un ejemplo simple pero completo que ilustra la comunicación bidireccional:
package main
import (
"fmt"
"strings"
"time"
)
// shout recibe del canal ping y envía al canal pong
func shout(ping <-chan string, pong chan<- string) {
for {
// Recibe mensaje, verificando si el canal está cerrado
msg, ok := <-ping
if !ok {
fmt.Println("🔇 Canal ping cerrado, terminando")
return
}
// Procesa y envía respuesta
time.Sleep(500 * time.Millisecond)
respuesta := fmt.Sprintf("🔊 ECO: %s!!!", strings.ToUpper(msg))
pong <- respuesta
}
}
func main() {
fmt.Println("🏓 Iniciando ejemplo de Ping-Pong")
// Crear canales
ping := make(chan string)
pong := make(chan string)
// Iniciar goroutine
go shout(ping, pong)
fmt.Println("✏️ Escribe algo (q para salir):")
for {
fmt.Print("👉 ")
var entrada string
fmt.Scanln(&entrada)
if strings.ToLower(entrada) == "q" {
fmt.Println("👋 Saliendo...")
break
}
// Enviar y recibir
ping <- entrada
respuesta := <-pong
fmt.Println(respuesta)
}
fmt.Println("🏁 Terminando, cerrando canales")
close(ping)
// No cerramos pong para evitar potenciales panics
}
-
Piensa en los channels como transferencia de propiedad: Cuando envías un valor, estás cediendo la propiedad a quien lo recibe
-
Comienza con la solución más simple: Generalmente canales sin buffer y patrones básicos
-
Usa channels para comunicación, mutexes para estado compartido
-
Prueba con
-race
detector: Ayuda a encontrar race conditions -
El cierre del canal es una señal importante: Representa "fin de datos" o "completado"
Los channels son la característica distintiva del modelo de concurrencia de Go. Dominarlos te permitirá escribir código concurrente limpio, seguro y eficiente, aprovechando al máximo lo que el lenguaje tiene para ofrecer.