# Multihilo en Go: Cómo la CPU, las cachés y el planificador afectan el rendimiento
Las aplicaciones modernas en Go hacen un uso intensivo del paralelismo, pero muchos desarrolladores no comprenden del todo qué sucede bajo el capó al trabajar con múltiples núcleos de CPU. Este artículo profundiza en los mecanismos de bajo nivel que impactan el rendimiento de los programas multihilo: desde los detalles del cambio de contexto hasta la coherencia de caché. Explicaremos por qué existen las operaciones atómicas y las barreras de memoria, y cómo sus peculiaridades se manifiestan en escenarios del mundo real.
Mecanismos de multitarea: Cooperativa vs. Preemptiva
El principal desafío en sistemas de un solo núcleo es manejar múltiples tareas al mismo tiempo. Las soluciones llegaron en dos variedades:
- Multitarea cooperativa — las tareas ceden voluntariamente la CPU mediante llamadas al sistema como
sched_yield(). La desventaja es obvia: una tarea colgada congela todo el sistema (como en Windows 3.1).
- Multitarea preemptiva — las tareas son interrumpidas forzosamente por un temporizador. Etapas clave:
- Generación de una interrupción de hardware
- Guardado del contexto de la tarea actual en RAM
- Selección de una nueva tarea por el planificador
- Restauración del contexto
Es crucial entender que el contexto de tarea incluye:
- Valores de registros (RAX, RBX, etc.)
- Puntero de instrucción (RIP)
- Puntero de pila (RSP)
- Registro de flags (RFLAGS)
- Datos de la pila
El cambio de contexto requiere guardar estos datos en RAM, lo que genera sobrecarga. En CPUs modernas, un cambio de contexto toma 200–300 ciclos, equivalente a ejecutar docenas de instrucciones.
Arquitectura de sistemas multi-núcleo y jerarquía de caché
El paso a procesadores multi-núcleo introdujo un nuevo problema: sincronizar el acceso a la memoria compartida. Para entender las soluciones, desglosemos la jerarquía de caché:
Caché L1
- Tamaño: 32–64 KB
- Latencia: ~4 ciclos
- Dividida en instrucciones (L1i) y datos (L1d)
- Vinculada a un núcleo físico
Caché L2
- Tamaño: 256 KB – 1 MB
- Latencia: 10–15 ciclos
- Generalmente vinculada a un núcleo
Caché L3
- Tamaño: 8–40 MB
- Latencia: 30–50 ciclos
- Compartida entre todos los núcleos
Punto clave: el procesador gestiona las cachés mediante un controlador de hardware. El SO y las aplicaciones no pueden controlar directamente la colocación de datos. Esto lleva a situaciones en las que diferentes núcleos trabajan con copias de datos obsoletos.
Protocolo MESI y coherencia de caché
La coherencia de caché se maneja mediante el protocolo MESI (Modificado, Exclusivo, Compartido, Inválido). Cada línea de caché (típicamente 64 bytes) tiene uno de cuatro estados:
- Modificado: datos cambiados en esta caché y diferentes de RAM
- Exclusivo: datos coinciden con RAM, accesibles solo por este núcleo
- Compartido: datos coinciden con RAM y pueden estar en otras cachés
- Inválido: datos obsoletos y no utilizables
Ejemplo de operación de escritura:
- Núcleo A lee la variable → estado Compartido
- Núcleo B lee la misma variable → estado Compartido
- Núcleo A escribe un nuevo valor → envía solicitud de invalidación
- Núcleo B recibe la solicitud y pone la línea en Inválido
Este mecanismo introduce retrasos ocultos: escrituras competidoras obligan a los núcleos a intercambiar señales por el bus, lo que puede tomar 100–300 ciclos.
Implicaciones prácticas para desarrolladores de Go
Comprender estos mecanismos es esencial al trabajar con:
- Operaciones atómicas: instrucciones
LOCK XCHGaseguran atomicidad mediante bloqueo de bus - Barreras de memoria:
mfencecontrola el orden de ejecución de instrucciones - Afinidad de CPU: vinculación de gorutinas a núcleos específicos mediante
GOMAXPROCS
Ejemplo de compartición falsa:
var data [2]int64
// Goroutine 1
for {
data[0]++
}
// Goroutine 2
for {
data[1]++
}
Aunque acceden a elementos diferentes del array, caen en la misma línea de caché (64 bytes). Escribir en data[0] invalida la línea para el segundo núcleo, provocando sincronizaciones constantes. Solución: agregar relleno:
var data [2]struct{
value int64
_ [56]byte // Padding to 64 bytes
}
Puntos clave
- El cambio de contexto guarda el estado en RAM, causando retrasos de ~200 ciclos
- La coherencia de caché mediante MESI añade sobrecarga en escrituras competidoras
- La compartición falsa puede reducir el rendimiento en 2–3 órdenes de magnitud
- Las operaciones atómicas usan bloqueo de bus o protocolos de caché (dependiente de la arquitectura)
- Comprender la jerarquía de caché permite optimizar estructuras de datos al tamaño de línea de caché
Los desarrolladores de Go a menudo se topan con estos problemas al construir servicios de alta carga. Por ejemplo, en sistemas distribuidos, un mal manejo de caché puede causar latencia inesperada al procesar miles de solicitudes por segundo. Lección clave: el paralelismo efectivo exige considerar no solo la lógica de la aplicación, sino también las peculiaridades del hardware.
Para profundizar más, revisa la salida de ensamblador del compilador de Go con la bandera -S, y usa perfiladores como perf para medir fallos de caché y cambios de contexto. Esto revela cuellos de botella invisibles a nivel de código fuente.
— Editorial Team
Aún no hay comentarios.