Manejo de Condiciones de Carrera en Go: Implementación de Mutex

Clase 6 de 19Curso de Go Avanzado: Concurrencia y Patrones de Diseño

Resumen

¿Cómo crear un sistema de caché concurrente en Go?

La eficiencia en el cálculo de la serie de Fibonacci demuestra los beneficios de un sistema de caché bien diseñado. Sin embargo, cuando introducimos concurrencia, la ejecución puede llevar a condiciones de carrera que afectan la consistencia y fiabilidad del programa. Aquí exploraremos cómo abordar estos desafíos y optimizar un sistema de caché concurrente usando la robusta capacidad de concurrencia de Go.

¿Qué cambios hicimos en nuestra función principal para manejar concurrencia?

Para manipular concurrente las funciones de nuestro sistema de caché, debimos realizar ajustes en la función main:

  • Creación de WaitGroup: Este grupo nos permite sincronizar las goroutines y asegurarnos de que todas se completen antes de finalizar el programa.

  • Uso de Funciones Anónimas: Introducimos funciones anónimas junto con la palabra clave go para ejecutar las operaciones en nuevas goroutines. Este enfoque permite que cada cálculo de Fibonacci se realice de manera concurrente.

  • Manejo de Contadores: Se utilizó wg.Add(1) para incrementar el contador al iniciar una goroutine y wg.Done() para decrementar al finalizar, garantizando la correcta espera del programa principal mediante wg.Wait().

var wg sync.WaitGroup
for i, n := range numbers {
    wg.Add(1)
    go func(index, num int) {
        defer wg.Done()
        calcularFibonacci(num)
    }(i, n)
}
wg.Wait()

¿Cómo resolvimos las condiciones de carrera con logs?

Las condiciones de carrera pueden manifestarse cuando varias goroutines acceden a recursos compartidos de forma desincronizada. Para mitigar este problema, implementamos sync.Mutex en nuestro código:

  • Sincronización con Mutex: Antes de acceder a datos compartidos, usamos un lock para asegurar la exclusividad y desbloqueamos justo antes de retornar los resultados. Esto evita que múltiples goroutines modifiquen datos simultáneamente, lo que podría llevar a inconsistencias.
memory := struct{
    cache map[int]int
    lock  sync.Mutex
}{
    cache: make(map[int]int),
}

func (m *memory) Get(key int) (int, bool) {
    m.lock.Lock()
    defer m.lock.Unlock()
    val, exists := m.cache[key]
    return val, exists
}
  • Optimización del uso de locks: Inicialmente, bloqueamos todo el bloque de funcionalidad. Sin embargo, al mejorar, bloqueamos sólo cuando es necesario. Movimos el lock para después de verificar la existencia del valor, reduciendo así el impacto negativo en el rendimiento.

¿Cómo mejoramos el rendimiento del sistema?

Después de establecer los locks, notamos un decremento en el rendimiento debido al bloqueo excesivo:

  • Reanching los locks: Ajustamos los lugares donde se aplicaban los locks, aumentando la eficiencia. Al hacerlo, evitamos bloquear completamente el programa durante operaciones que no lo requieren.

  • Pruebas de carrera: Usamos la herramienta -race de Go para verificar que no se presenten nuevas condiciones de carrera, garantizando así la correcta implementación de nuestras mejoras.

go run -race main.go

Este enfoque resultó en un sistema más eficiente que previene el recalculo innecesario de datos y optimiza el uso de memoria.

Con estos cambios, hemos creado un sistema de caché concurrente robusto que utiliza únicamente las librerías estándar de Go. Este sistema, que ahora maneja la concurrencia de manera efectiva, permite almacenar resultados de cálculos previos para evitar cálculos repetitivos y mejorar el rendimiento significativamente. ¡Continúa explorando los vastos horizontes de la programación concurrente en Go!