# Sincronización de Bajo Nivel en Go: Atómicas, Barreras y Reordenamiento de Instrucciones
Comprender los mecanismos de sincronización a nivel de procesador no es un lujo académico, sino una necesidad práctica para los desarrolladores que crean aplicaciones Go multihilo. En este artículo, desglosamos cómo la CPU optimiza la ejecución de código, por qué esto rompe la lógica esperada en programas concurrentes y qué primitivas de hardware ayudan a restaurar el orden.
Cómo el Procesador Engaña al Programador: Reordenamiento y Ejecución Fuera de Orden
Cuando escribes x = 1; ready = true, esperas un orden secuencial estricto. ¿La CPU? Nada de eso. Reordena instrucciones independientes para llenar paradas en el pipeline. Esto se llama instruction reordering. El objetivo: maximizar la utilización de las unidades de ejecución y ocultar latencias de memoria.
Las CPU usan out-of-order execution: las instrucciones se ejecutan no en el orden del programa, sino cuando sus operandos están listos. Por ejemplo:
MOVQ A(SB), AX ; load from memory
MOVQ B(SB), BX ; load from memory
ADDQ DX, CX ; add registers
La CPU puede empezar con ADDQ porque los operandos ya están en registros, mientras envía las cargas al fondo: tardarán cientos de ciclos. El resultado lógico para el hilo actual sigue siendo el mismo, pero otros hilos podrían ver algo diferente.
Etapas del pipeline donde surge el caos:
- Fetch → Decode → Register Rename → Dispatch → Execute → Commit
Etapa clave: Dispatch. Aquí, el programador elige instrucciones con operandos listos, ignorando el orden original. El Reorder Buffer (ROB) asegura que los commits de instrucciones al estado arquitectónico ocurran en la secuencia correcta, pero solo para el núcleo actual.
Reordenamiento de Memoria: Cuando el Buffer de Escritura te Traiciona
El problema empeora a nivel de memoria. Las escrituras en RAM no son instantáneas: caen en el store buffer y luego se vacían de forma asíncrona en caché. Para un núcleo:
x = 1
ready = true
Para otro núcleo, podría verse así:
ready = true
x = 0
Porque la escritura ready=true podría salir del store buffer antes. Esto es memory reordering: reordenamiento de operaciones de memoria visible para otros hilos. Ejemplo clásico de data race:
G1:
x = 1
ready = true
G2:
if ready {
print(x) // might print 0!
}
Barreras de Memoria: MFENCE y Otras Herramientas para Restaurar el Orden
Solución: memory fences (barreras de memoria). Estas instrucciones fuerzan la sincronización de la visibilidad de operaciones entre núcleos. En x86, la barrera principal es MFENCE:
MOVQ $1, x(SB)
MFENCE ; barrier
MOVQ $1, ready(SB)
MFENCE garantiza que todas las stores anteriores sean visibles para todos los núcleos antes de que las operaciones posteriores comiencen a ejecutarse. Prohíbe cuatro tipos de reordenamiento:
- Store → Store
- Load → Load
- Load → Store
- Store → Load
Importante: x86 tiene un modelo de memoria relativamente fuerte; muchos reordenamientos ya están prohibidos por hardware. Pero Store→Load sigue siendo posible, por lo que MFENCE es esencial para sincronización estricta.
Tipos de barreras en diferentes arquitecturas:
- x86: MFENCE (completa), SFENCE (store→store), LFENCE (load→load)
- ARM: DMB (Data Memory Barrier), ISH (inner shareable domain)
- RISC-V: FENCE (flexible, con especificadores de dirección)
Operaciones Atómicas: Abstracción de Alto Nivel sobre LOCK
Go oculta las barreras detrás de operaciones atómicas del paquete sync/atomic. Por debajo, usan el prefijo LOCK, que hace que las operaciones read-modify-write sean indivisibles en todos los núcleos. Ejemplo XADDQ:
TEXT ·Xadd64(SB), NOSPLIT, $0-24
MOVQ ptr+0(FP), BX
MOVQ delta+8(FP), AX
MOVQ AX, CX
LOCK
XADDQ AX, 0(BX) ; atomically: tmp=*ptr; *ptr+=AX; AX=tmp
ADDQ CX, AX
MOVQ AX, ret+16(FP)
RET
LOCK funciona bloqueando la línea de caché en el protocolo MESI:
- El núcleo adquiere derechos exclusivos a la línea de caché
- Realiza la operación
- Libera la línea: los cambios se vuelven visibles para otros núcleos
Atomicidad ≠ ordenamiento. Una operación atómica garantiza indivisibilidad, pero no necesariamente crea una barrera de memoria. En x86, load/store simples son atómicas pero no proporcionan ordenamiento.
Escribiendo Nuestra Propia Atómica AND: En Práctica con el Ensamblador de Go
La biblioteca estándar de Go no proporciona atomic.And. Implementémosla nosotros mismos. Crea el archivo Xand8_x86.s:
#include "textflag.h"
TEXT ·And8(SB), NOSPLIT, $0-9
MOVQ ptr+0(FP), BX // BX = pointer
MOVB mask+8(FP), AX // AX = mask (8 bits)
LOCK
ANDB AX, 0(BX) // *ptr &= mask
RET
Y el main.go correspondiente:
package main
import "C"
func And8(ptr *uint8, mask uint8)
func main() {
var smth uint8 = 3
And8(&smth, uint8(2))
println("Result:", smth) // outputs 2
}
Compila y ejecuta con go run .: el archivo ASM se detectará automáticamente. Nota: usamos ANDB con el prefijo LOCK, aunque x86 no tiene instrucción XAND. Funciona: LOCK es compatible con operaciones lógicas básicas.
Puntos Clave
- Instruction reordering — Optimización de la CPU que cambia el orden de ejecución de instrucciones independientes para mejorar el rendimiento.
- Memory reordering — Desincronización de la visibilidad de operaciones de escritura entre núcleos debido al store buffer.
- Memory fences (barreras) — Instrucciones que sincronizan por fuerza el orden de operaciones de memoria entre hilos.
- Atomic operations — Acciones read-modify-write indivisibles, implementadas vía LOCK y que encapsulan barreras.
- LOCK + ANDB — Una forma funcional de implementar una operación atómica bitwise ausente en la biblioteca estándar de Go.
— Editorial Team
Aún no hay comentarios.