Los Proverbios de Go: Heurísticas de Diseño en Producción

Los proverbios de Rob Pike no son reglas de sintaxis, sino heurísticas de diseño que dictan cómo estructurar sistemas concurrentes y APIs robustas. No son leyes matemáticas, sino observaciones de ingeniería sobre cómo evitar la complejidad cognitiva y los errores de concurrencia.

Estos principios actúan como una brújula cuando el sistema escala. Si tratas los errores como excepciones (usando panic para controlar el flujo), rompes la predictibilidad del programa y dificultas la recuperación ante fallos transitorios. Si intentas sincronizar estados complejos mediante sync.Mutex para que múltiples goroutines modifiquen una estructura compartida, terminas con un laberinto de bloqueos y condiciones de carrera (race conditions) que el scheduler de Go no puede mitigar por ti. Si ignoras la utilidad del “valor cero”, obligas al consumidor de tu API a realizar inicializaciones manuales que aumentan la superficie de error.

Aplicar estos principios hace que el código sea predecible y escalable. Ignorarlos provoca que el sistema falle de forma errática bajo carga, especialmente en condiciones de alta contención o en los bordes de los errores de red o I/O.

package main

import (
	"context"
	"errors"
	"fmt"
	"sync"
)

// Result encapsula el resultado de una operación.
// Aplicamos "Errors are values" al tratar el error como un dato
// que fluye a través del canal, en lugar de un flujo de control externo.
type Result struct {
	Value int
	Err   error
}

// Metrics permite rastrear el procesamiento.
// Aplicamos "Make the zero value useful" ya que el valor cero de 
// sync.Mutex es un mutex desbloqueado y listo para su uso.
type Metrics struct {
	mu    sync.Mutex
	count int
}

func (m *Metrics) Inc() {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.count++
}

func (m *Metrics) Value() int {
	m.mu.Lock()
	defer m.mu.Unlock()
	return m.count
}

// Processor gestiona el flujo de trabajo.
type Processor struct {
	metrics *Metrics
}

func (p *Processor) Run(ctx context.Context, inputs []int) <-chan Result {
	// Aplicamos "Don't communicate by sharing memory" usando canales
	// para orquestar la transferencia de datos entre goroutines.
	results := make(chan Result)
	tasks := make(chan int)

	// Worker pool
	var wg sync.WaitGroup
	for i := 0; i < 3; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for n := range tasks {
				res := p.compute(n)
				
				// Si hay un error, lo enviamos como parte del flujo normal.
				if res.Err != nil {
					results <- res
					continue
				}

				p.metrics.Inc()
				results <- res
			}
		}()
	}

	// Orquestador de la vida útil
	go func() {
		for _, n := range inputs {
			select {
			case <-ctx.Done():
				return
			case tasks <- n:
			}
		}
		close(tasks)
		wg.Wait()
		close(results)
	}()

	return results
}

func (p *Processor) compute(n int) Result {
	if n < 0 {
		return Result{Err: errors.New("valor negativo")
	}
	return Result{Value: n * 2}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	// El valor cero de Metrics es perfectamente válido y seguro para usar.
	m := &Metrics{}
	p := Processor{metrics: m}

	inputs := []int{2, 4, -1, 8, 10}
	results := p.Run(ctx, inputs)

	for res := range results {
		if res.Err != nil {
			fmt.Printf("Error detectado: %v\n", res.Err)
			continue
		}
		fmt.Printf("Resultado: %d\n", res.Value)
	}

	fmt.Printf("Total procesados con éxito: %d\n", m.Value())
}

Análisis del diseño

En el ejemplo anterior, el flujo de datos está gobernado por la comunicación mediante canales, no por la manipulación de una estructura compartida. En el método Run, la goroutine que consume tasks no escribe en un slice global protegido por un mutex para guardar resultados; en su lugar, envía un Result a través de results. Esto aplica “Don’t communicate by sharing memory”, ya que la propiedad de la información se transfiere de forma explícita de una goroutine a otra.

El uso de la estructura Result es la implementación práctica de “Errors are values”. En un pipeline concurrente, no puedes usar el valor de retorno tradicional (int, error) porque los canales solo pueden enviar un tipo. Al empaquetar el error como parte del mensaje, permitimos que el error sea tratado como cualquier otro dato, manteniendo la continuidad del pipeline y evitando que un error detenga el procesamiento de los demás elementos o requiera lógica de recuperación compleja fuera del flujo.

Fíjate en la estructura Metrics. Al incluir sync.Mutex como un campo, aprovechamos que su “zero value” es un estado válido y listo para operar. No necesitamos una función NewMetrics() que inicialice el mutex; un simple m := Metrics{} es suficiente para que m.Inc() funcione correctamente. Esto simplifica enormemente la creación de objetos y reduce la carga cognitiva del programador que consume la API.

El error frecuente

Un error clásico al ignorar el principio de comunicación es intentar usar un map compartido para recolectar resultados de varias goroutines sin una protección de memoria estricta.

// ERROR: Esto causará un "fatal error: concurrent map writes"
resultsMap := make(map[int]int)
go func() {
    for i := 0; i < 100; i++ {
        resultsMap[i] = i * 2 // Race condition crítica
    }
}()
go func() {
    for i := 0; i < 100; i++ {
        fmt.Println(resultsMap[i]) // Race condition crítica
    }
}()

Aunque podrías solucionar esto con un sync.Mutex, si la lógica de negocio requiere que los resultados se procesen en el orden en que terminan, el uso de canales (compartir memoria comunicando) es inherentemente más limpio y menos propenso a errores de lógica de sincronización que un mapa con bloqueos manuales.

234

Dejar un comentario

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

Scroll al inicio