Channels vs Mutexes: ¿Ownership o Protección de Estado?

En Go, la concurrencia se gestiona principalmente de dos formas: comunicando procesos mediante mensajes o bloqueando el acceso a la memoria. Para decidir qué usar, no busques una regla dogmática, busca entender la naturaleza del dato que estás manipulando.

Si tu problema es la transferencia de propiedad (ownership transfer) o la señalización (signaling), la respuesta es un channel. Si el dato debe permanecer en un lugar fijo y múltiples goroutines necesitan consultarlo o modificarlo, necesitas un mutex.

Un chan es una tubería: cuando envías un valor, le estás diciendo a otra goroutine: “Toma, esto es tuyo ahora”. Una vez que el valor sale por el canal, la goroutine emisora no debería tocarlo más. Por otro lado, un sync.Mutex es un guardián: el dato no se mueve, pero el guardián asegura que solo una goroutine pase a tocarlo a la vez.

Debes usar canales cuando necesites orquestar el flujo de trabajo (ej. un worker procesando tareas) o notificar eventos. Usa mutexes cuando implementes una estructura de datos interna que deba ser segura para su uso concurrente (ej. un mapa de contadores). Si usas canales para gestionar un simple estado compartido (como un contador), estarás introduciendo una latencia innecesaria por el overhead del scheduler y la comunicación de canales. Si usas un mutex para pasar la propiedad de un objeto complejo entre procesos, corres el riesgo de que el programador que recibe el objeto olvide la semántica de propiedad y ambos intenten modificarlo, causando una condición de carrera (race condition).

package main

import (
	"fmt"
	"sync"
)

// Task representa el trabajo a realizar. 
// Al pasarla por un canal, transferimos su propiedad al worker.
type Task struct {
	ID       int
	Payload string
}

// Metrics protege el estado compartido de la aplicación.
// Usamos un mutex porque el mapa de resultados es un estado que 
// reside en un único lugar y es accedido por múltiples workers.
type Metrics struct {
	mu             sync.Mutex
	tasksPerWorker map[int]int
}

// RecordIncrementa el contador para un worker específico de forma segura.
func (m *Metrics) Record(workerID int) {
	m.mu.Lock()
	// La protección debe ser lo más breve posible.
	m.tasksPerWorker[workerID]++
	m.mu.Unlock()
}

func worker(id int, tasks <-chan Task, stats *Metrics, wg *sync.WaitGroup) {
	defer wg.Done()

	for task := range tasks {
		// El worker ha tomado la propiedad de 'task'.
		// No hay riesgo de race si el sender no la vuelve a usar.
		_ = fmt.Sprintf("Procesando tarea %d: %s", task.ID, task.Payload)

		// Actualizamos el estado compartido.
		stats.Record(id)
	}
}

func main() {
	// Canal para transferencia de propiedad (Ownership transfer).
	tasks := make(chan Task, 10)
	
	stats := &Metrics{
		tasksPerWorker: make(map[int]int),
	}

	var wg sync.WaitGroup

	// Iniciamos un pool de workers.
	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go worker(i, tasks, stats, &wg)
	}

	// Enviamos tareas al canal.
	for i := 1; i <= 15; i++ {
		tasks <- Task{ID: i, Payload: "Datos de carga"}
	}
	
	// Cerramos el canal para indicar que no habrá más tareas.
	close(tasks)

	// Usamos WaitGroup para la sincronización de finalización (no canales).
	wg.Wait()

	fmt.Printf("Estadísticas finales: %v\n", stats.tasksPerWorker)
}

Análisis del diseño

En el ejemplo, la comunicación de Task a través del canal tasks es un patrón de transferencia de propiedad. Una vez que tasks <- Task{...} se ejecuta, el main ha delegado la responsabilidad de la vida útil y la modificación de esa tarea al worker. Si intentáramos usar un mutex para enviar estas tareas, estaríamos obligados a mantener un buffer complejo o una cola gestionada manualmente, lo cual es exactamente lo que el canal ya hace de forma nativa y eficiente.

Por el contrario, Metrics es un ejemplo de protección de estado compartido. El mapa tasksPerWorker no “viaja”; vive en la memoria de la goroutine principal y es consultado por los workers. No tiene sentido enviar el mapa entero por un canal cada vez que queremos sumar 1. En su lugar, el sync.Mutex dentro de Record garantiza que la operación de lectura-modificación-escritura sobre el mapa sea atómica, evitando que dos workers corrompan la estructura interna del mapa de Go.

Finalmente, hemos utilizado sync.WaitGroup para esperar a que los workers terminen. Esto es fundamental: un WaitGroup es una herramienta de sincronización de flujo, no de transferencia de datos. Intentar replicar el comportamiento de un WaitGroup con un canal (por ejemplo, enviando un valor por un canal para cada Done()) añade complejidad innecesaria al runtime.

El error frecuente

Un error común en niveles intermedios es utilizar un canal para realizar tareas que sync.Once o sync.WaitGroup resuelven de forma más directa y eficiente.

// ERROR: Usar un canal solo para esperar a que una función se ejecute una vez.
var done = make(chan bool)

func setup() {
    go func() {
        // Configuración pesada...
        done <- true
    }()
}

func main() {
    setup()
    <-done // Espera innecesariamente mediante comunicación
}

Si solo necesitas garantizar que una inicialización ocurra una vez, usa sync.Once. Si necesitas esperar a que un grupo de procesos finalice, usa sync.WaitGroup. No uses canales para “esperar” si no hay una transferencia de datos o una señalización compleja de múltiples condiciones de por medio; estarás penalizando el rendimiento de tu sistema con el overhead de la gestión de canales cuando un simple contador atómico o un mutex sería suficiente.

142

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio