Concurrencia segura: Confinement, Inmutabilidad y Diseño

Diseñar software concurrente requiere entender la propiedad de los datos (ownership) antes de escribir la primera línea de lógica. No se trata solo de añadir go o sync.Mutex donde parece que “podría haber un problema”; se trata de definir, desde el diseño inicial, quién es el dueño de cada pieza de información y cómo se transfiere la propiedad entre goroutines.

Para razonar correctamente sobre la concurrencia, debemos basarnos en tres pilares: el confinement (aislamiento), donde un dato es accesible solo por una goroutine (ya sea por estar en un ámbito local o por ser transferido mediante un canal), la inmutabilidad, donde los datos que no cambian una vez creados son seguros de leer por cualquier número de goroutines, y la sincronización explícita, necesaria únicamente cuando la mutabilidad y el acceso compartido coexisten.

Si ignoras estos principios, no solo introduces errores de lógica, sino que creas data races (carreras de datos). Estas no son errores de compilación; son heisenbugs: comportamientos que funcionan perfectamente en tu máquina local pero colapsan en producción cuando el sistema tiene más carga, más núcleos de CPU o una latencia de memoria distinta, debido a cómo los procesadores gestionan sus cachés y reordenan instrucciones. Para evitarlo, el diseño debe ser top-down: identifica qué datos se comparten y decide su mecanismo de sincronización antes de implementar la lógica de negocio.

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
	"time"
)

// Config es inmutable. Una vez creada, no se toca.
// Esto la hace segura para que cualquier goroutine la lea sin locks.
type Config struct {
	Timeout time.Duration
	Retries int
}

// Aggregator gestiona el estado compartido mediante sincronización explícita.
type Aggregator struct {
	mu           sync.Mutex
	totalSuccess uint64
}

func (a *Aggregator) Inc() {
	a.mu.Lock()
	defer a.mu.Unlock()
	a.totalSuccess++
}

func main() {
	// 1. Inmutabilidad: Config se define una vez y se pasa por valor/puntero a todos.
	cfg := Config{Timeout: 100 * time.Millisecond, Retries: 3}

	// 2. Estado compartido: Requiere un mecanismo de sincronización.
	// Usaremos un tipo atómico para el contador global para alta performance.
	var globalCounter atomic.Uint64

	aggregator := &Aggregator{}
	var wg sync.WaitGroup

	for i := 1; i <= 5; i++ {
		wg.Add(1)
		// Confinement: Cada goroutine recibe su propio ID y trabaja con su propia lógica.
		go func(workerID int, c Config) {
			defer wg.Done()

			// workerLocalStats es un ejemplo de Confinement por espacio de nombres.
			// Nadie más puede ver esta variable. No necesita locks.
			var workerLocalStats int

			for j := 0; j < c.Retries; j++ {
				// Simulamos trabajo
				time.Sleep(c.Timeout / 2)
				workerLocalStats++
			}

			// Al terminar, transferimos el resultado al estado compartido.
			// Primero al agregador (con Mutex) y luego al contador atómico.
			aggregator.Inc()
			globalCounter.Add(uint64(workerLocalStats))

			fmt.Printf("Worker %d completó %d tareas\n", workerID, workerLocalStats)
		}(i, cfg)
	}

	wg.Wait()

	fmt.Printf("Total en Agregador: %d\n", aggregator.totalSuccess)
	fmt.Printf("Total en Contador Atómico: %d\n", globalCounter.Load())
}

En el ejemplo anterior, hemos aplicado los tres pilares para evitar la necesidad de locks innecesarios. La estructura Config es inmutable; dado que ninguna goroutine intenta modificar Timeout o Retries, el hardware puede cachear estos valores en los núcleos sin preocuparse por la consistencia de la memoria.

Dentro de la goroutine anónima, la variable workerLocalStats es un ejemplo de confinement. Al ser una variable definida dentro del cuerpo de la función que se ejecuta en la goroutine, su ciclo de vida y su acceso están restringidos a ese hilo de ejecución; es imposible que otra goroutine cause una carrera de datos sobre ella.

Cuando la necesidad de compartir datos es inevitable, como con Aggregator, aplicamos un diseño de arriba hacia abajo: decidimos que totalSuccess se protegería con un sync.Mutex antes de implementar los métodos. Para el contador global, hemos optado por atomic.Uint64 [disponible desde Go 1.19], que utiliza instrucciones de hardware directas para asegurar la visibilidad de la escritura entre núcleos sin el overhead de un mutex, ideal para contadores de alta contención.

El error frecuente

El error más peligroso es intentar añadir sincronización de forma reactiva (afterthought). Es común escribir lógica compleja donde múltiples goroutines manipulan un mapa o un slice y, al notar que el programa falla ocasionalmente en un servidor con más CPUs, añadir un sync.Mutex de forma desordenada.

// ERROR: Carrera de datos clásica
data := make([]int, 0)

// Goroutine A intenta expandir el slice
go func() {
    for i := 0; i < 1000; i++ {
        data = append(data, i) // ERROR: append no es atómico y puede reasignar el array subyacente
    }
}()

// Goroutine B intenta leer el slice
go func() {
    for i := 0; i < len(data); i++ {
        _ = data[i] // ERROR: Puede intentar leer una posición que aún no es válida
    }
}()

En este caso, append no solo escribe un valor, sino que puede decidir que la capacidad del slice es insuficiente, crear un nuevo array en memoria y copiar los datos. Si otra goroutine está leyendo el array viejo mientras se realiza la copia o la reasignación, el puntero interno del slice queda en un estado inconsistente, provocando un panic o, lo que es peor, una lectura de memoria corrupta que no se detecta en un entorno de desarrollo con una sola CPU.

151

Dejar un comentario

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

Scroll al inicio