¿Por qué la asignación de enteros en una variable naturalmente alineada es atómica en x86?

He estado leyendo este artículo sobre operaciones atómicas y menciona que la asignación de enteros de 32 bits es atómica en x86, siempre que la variable esté naturalmente alineada.

¿Por qué la alineación natural asegura la atomicidad?

La alineación “natural” significa alineada con su propio ancho de letra . Por lo tanto, la carga / almacenamiento nunca se dividirá a través de ningún tipo de límite más amplio que sí mismo (por ejemplo, página, línea de caché o un tamaño de fragmento aún más pequeño utilizado para transferencias de datos entre diferentes cachés).

Las CPU a menudo hacen cosas como acceso de caché o transferencias de línea de caché entre núcleos, en fragmentos de tamaño de potencia de 2, por lo que los límites de alineación más pequeños que una línea de caché sí importan. (Ver los comentarios de @ BeeOnRope a continuación). Consulte también Atomicity en x86 para obtener más detalles sobre cómo las CPU implementan cargas atómicas o almacena internamente, y ¿puede num ++ ser atómico para ‘int num’? para obtener más información sobre cómo las operaciones de RMW atómicas como atomic::fetch_add() / lock xadd se implementan internamente.


Primero, esto supone que int se actualiza con una única instrucción de tienda, en lugar de escribir diferentes bytes por separado. Esto es parte de lo que std::atomic garantiza, pero ese simple C o C ++ no lo garantiza. Sin embargo, normalmente será el caso. El sistema V-ABI x86-64 no prohíbe a los comstackdores realizar accesos a variables int no atómicas, aunque requiere int ser 4B con una alineación predeterminada de 4B. Por ejemplo, x = a<<16 | b x = a<<16 | b podría comstackr en dos tiendas separadas de 16 bits si el comstackdor quisiera.

Las carreras de datos son Comportamiento no definido tanto en C como en C ++, por lo que los comstackdores pueden suponer, y lo hacen, que la memoria no se modifica de forma asincrónica. Para el código que se garantiza que no se romperá, use C11 stdatomic o C ++ 11 std :: atomic . De lo contrario, el comstackdor mantendrá un valor en un registro en lugar de volver a cargarlo cada vez que lo lea , como volatile pero con las garantías reales y el respaldo oficial del estándar de idioma.

Antes de C + + 11, las operaciones atómicas solían realizarse con volatile u otras, y una dosis saludable de "funciona en comstackdores que nos importan", por lo que C ++ 11 fue un gran paso adelante. Ahora ya no tiene que preocuparse por lo que hace un comstackdor para plain int ; solo use atomic . Si encuentras guías antiguos que hablan sobre la atomicidad de int , probablemente sean anteriores a C ++ 11.

 std::atomic shared; // shared variable (compiler ensures alignment) int x; // local variable (compiler can keep it in a register) x = shared.load(std::memory_order_relaxed); shared.store(x, std::memory_order_relaxed); // shared = x; // don't do that unless you actually need seq_cst, because MFENCE or XCHG is much slower than a simple store 

Nota al .is_lock_free() : para atomic más grande que la CPU puede hacer atómicamente (por lo que .is_lock_free() es falso), vea ¿Dónde está el locking de un std :: atomic? . int e int64_t / uint64_t están libres de lockings en todos los principales comstackdores x86.


Por lo tanto, solo necesitamos hablar sobre el comportamiento de un ins como mov [shared], eax .


TL; DR: El ISA x86 garantiza que las tiendas y cargas naturalmente alineadas son atómicas, de hasta 64 bits de ancho. Por lo tanto, los comstackdores pueden usar tiendas / cargas normales siempre que se aseguren de que std::atomic tenga una alineación natural.

(Pero tenga en cuenta que i386 gcc -m32 no puede hacer eso para C11 _Atomic 64 bits tipos, solo alinearlos a 4B, por lo atomic_llong no es en realidad atómico. https://gcc.gnu.org/bugzilla/show_bug.cgi?id = 65146 # c4 ). g++ -m32 con std::atomic está bien, al menos en g ++ 5 porque https://gcc.gnu.org/bugzilla/show_bug.cgi?id=65147 se corrigió en 2015 mediante un cambio en encabezamiento. Eso no cambió el comportamiento de C11, sin embargo).


IIRC, había SMP 386 sistemas, pero la semántica de la memoria actual no se estableció hasta 486. Es por eso que el manual dice "486 y más reciente".

De los "Intel® 64 and IA-32 Architectures Software Developer Manuales, volumen 3", con mis notas en cursiva . (ver también la wiki de la etiqueta x86 para enlaces: versiones actuales de todos los volúmenes, o enlace directo a la página 256 del vol3 pdf de diciembre de 2015 )

En la terminología x86, una "palabra" es dos bytes de 8 bits. 32 bits son una palabra doble, o DWORD.

Sección 8.1.1 Operaciones atómicas garantizadas

El procesador Intel486 (y los procesadores más nuevos desde entonces) garantiza que las siguientes operaciones básicas de memoria siempre se llevarán a cabo atómicamente:

  • Leer o escribir un byte
  • Leer o escribir una palabra alineada en un límite de 16 bits
  • Leer o escribir una doble palabra alineada en un límite de 32 bits (esta es otra forma de decir "alineación natural")

El último punto que en negrita es la respuesta a su pregunta: este comportamiento es parte de lo que se requiere para que un procesador sea una CPU x86 (es decir, una implementación de la ISA).


El rest de la sección proporciona más garantías para las CPU Intel más nuevas: Pentium amplía esta garantía a 64 bits .

El procesador Pentium (y los procesadores más nuevos desde entonces) garantiza que las siguientes operaciones adicionales de memoria siempre se llevarán a cabo atómicamente:

  • Leer o escribir un quadword alineado en un límite de 64 bits (por ejemplo, x87 load / store of a double , o cmpxchg8b (que era nuevo en Pentium P5))
  • Accesos de 16 bits a ubicaciones de memoria no guardadas en caché que se ajustan a un bus de datos de 32 bits.

La sección continúa para señalar que no se garantiza que los accesos divididos entre líneas de caché (y límites de página) sean atómicos, y:

"Se puede implementar una instrucción x87 o una SSE que acceda a datos más grandes que un quadword utilizando múltiples accesos de memoria".


El manual de AMD concuerda con Intel sobre que las cargas / tiendas alineadas de 64 bits y más estrechas son atómicas

Así que entero, x87 y MMX / SSE carga / almacena hasta 64b, incluso en modo de 32 bits o 16 bits (por ejemplo, movsd , movhps , pinsrq , extractps , extractps , etc.) son atómicos si los datos están alineados. gcc -m32 usa movq xmm, [mem] para implementar cargas atómicas de 64 bits para cosas como std::atomic . Clang4.0 -m32 desafortunadamente usa el error de lock cmpxchg8b 33109 .

En algunas CPU con rutas de datos internas de 128b o 256b (entre unidades de ejecución y L1, y entre diferentes cachés), las cargas / tiendas vectoriales 128b e incluso 256b son atómicas, pero esto no está garantizado por ningún estándar o puede consultarse fácilmente en tiempo de ejecución. desafortunadamente para los comstackdores que implementan std::atomic<__int128> o 16B structs .

Si desea atómica 128b en todos los sistemas x86, debe usar el lock cmpxchg16b (disponible solo en el modo de 64 bits). (Y no estaba disponible en las CPU x86-64 de primera generación. Necesitas usar -mcx16 con gcc / clang para que lo emitan ).

Incluso las CPU que internamente hacen cargas / almacenes atómicos de 128b pueden exhibir un comportamiento no atómico en sistemas multi-socket con un protocolo de coherencia que opera en trozos más pequeños: por ejemplo, AMD Opteron 2435 (K10) con subprocesos conectados a sockets separados, conectados con HyperTransport .


Los manuales de Intel y AMD difieren para el acceso no alineado a la memoria caché . El subconjunto común para todas las CPU x86 es la regla de AMD. Cacheable significa regiones de memoria write-back o write-through, no descartables o combinadas de escritura, como se establece con las regiones PAT o MTRR. No significan que la línea de caché tenga que estar ya caliente en la memoria caché L1.

  • Intel P6 y versiones posteriores garantizan la atomicidad para cargas almacenables / almacena hasta 64 bits siempre que estén dentro de una sola línea de caché (64B o 32B en CPU muy antiguas como PentiumIII).
  • AMD garantiza la atomicidad para cargas / tiendas almacenables que se ajustan a un único segmento alineado en 8B. Eso tiene sentido, porque sabemos por la prueba de la tienda 16B en Opteron de múltiples sockets que HyperTransport solo transfiere en trozos de 8B y no se bloquea durante la transferencia para evitar el desgarro. (Véase más arriba). Creo que el lock cmpxchg16b debe manejarse especialmente.

    Posiblemente relacionado: AMD usa MOESI para compartir líneas de caché sucias directamente entre cachés en diferentes núcleos, por lo que un núcleo puede leer de su copia válida de una línea de caché, mientras que las actualizaciones provienen de otra caché.

    Intel usa MESIF , que requiere que los datos sucios se propaguen a la gran memoria caché L3 compartida que actúa como un respaldo para el tráfico de coherencia. L3 incluye tags de memorias caché L2 / L1 por núcleo, incluso para líneas que tienen que estar en el estado Inválido en L3 porque son M o E en una memoria caché L1 por núcleo. La ruta de datos entre L3 y cachés por núcleo tiene solo 32B de ancho en Haswell / Skylake, por lo que debe almacenarse en búfer o algo para evitar una escritura en L3 desde un núcleo que ocurre entre lecturas de dos mitades de una línea de caché, lo que podría causar desgarro en el límite 32B.

Las secciones relevantes de los manuales:

Los procesadores familiares P6 (y los procesadores Intel más nuevos desde entonces) garantizan que la siguiente operación de memoria adicional siempre se llevará a cabo atómicamente:

  • Acceso no alineado de 16, 32 y 64 bits a la memoria en caché que se ajusta dentro de una línea de caché.

AMD64 Manual 7.3.2 Acceso a la atomicidad
Las cargas individuales o tiendas de hasta cuatro palabras que se pueden alinear con la alineación natural son atómicas en cualquier modelo de procesador, al igual que las cargas desalineadas o las tiendas de menos de un quadword que se encuentran completamente dentro de una quadword naturalmente alineada

Observe que AMD garantiza la atomicidad para cualquier carga menor que una qword, pero Intel solo para tamaños de potencia de 2. El modo protegido de 32 bits y el modo largo de 64 bits pueden cargar un m16:32 48 bits como un operando de memoria en cs:eip con cs:eip call o jmp . (Y las llamadas lejanas empujan cosas en la stack.) IDK si esto cuenta como un acceso único de 48 bits o separado de 16 y 32 bits.

Se han intentado formalizar el modelo de memoria x86, siendo el último el x86-TSO (versión extendida) de 2009 (enlace de la sección de ordenamiento de memoria de la wiki de la etiqueta x86 ). No es útil skimable ya que definen algunos símbolos para express cosas en su propia notación, y no he intentado realmente leerlo. IDK si describe las reglas de atomicidad, o si solo está relacionado con el orden de la memoria.


Atomic Read-Modify-Write

Mencioné cmpxchg8b , pero solo estaba hablando de que la carga y la tienda son atómicas por separado (es decir, no "rasgado", donde la mitad de la carga es de una tienda, la otra mitad de la carga es de una tienda diferente).

Para evitar que el contenido de esa ubicación de memoria se modifique entre la carga y la tienda, necesita lock cmpxchg8b , al igual que necesita lock inc [mem] para que toda la lectura-modificación-escritura sea atómica. También tenga en cuenta que incluso si cmpxchg8b sin lock tiene una sola carga atómica (y opcionalmente una tienda), no es seguro en general usarlo como una carga de 64b con expected = desired. Si el valor en la memoria coincide con el esperado, obtendrá una lectura, modificación y escritura no atómica de esa ubicación.

El prefijo de lock hace que incluso los accesos no alineados que cruzan los límites de la línea de caché o de la página sean atómicos, pero no se puede usar con mov para hacer un almacenamiento desalineado o cargar atómico. Solo se puede usar con las instrucciones de lectura-modificación-escritura del destino de la memoria como add [mem], eax .

( lock está implícito en xchg reg, [mem] , así que no use xchg con mem para guardar el tamaño de código o el conteo de instrucciones a menos que el rendimiento sea irrelevante. Úselo únicamente cuando desee la barrera de memoria y / o el intercambio atómico, o cuando el tamaño del código es lo único que importa, por ejemplo, en un sector de arranque).

Ver también: ¿Puede num ++ ser atómico para 'int num'?


¿Por qué lock mov [mem], reg no existe para tiendas desalineadas atómicas?

Del manual de ins ref (Intel x86 manual vol2), cmpxchg :

Esta instrucción se puede usar con un prefijo LOCK para permitir que la instrucción se ejecute atómicamente. Para simplificar la interfaz con el bus del procesador, el operando de destino recibe un ciclo de escritura sin tener en cuenta el resultado de la comparación. El operando de destino se escribe de nuevo si la comparación falla; de lo contrario, el operando fuente se escribe en el destino. ( El procesador nunca produce una lectura bloqueada sin producir también una escritura bloqueada ).

Esta decisión de diseño redujo la complejidad del conjunto de chips antes de que el controlador de memoria se integrara en la CPU. Todavía puede hacerlo para las instrucciones de lock en las regiones MMIO que golpean el bus PCI-express en lugar de DRAM. Sería confuso que un lock mov reg, [MMIO_PORT] produzca una escritura y una lectura en el registro de E / S mapeado en memoria.

La otra explicación es que no es muy difícil asegurarse de que sus datos tengan una alineación natural, y lock store funcionará horriblemente en comparación con solo asegurarse de que sus datos estén alineados. Sería una tontería gastar transistores en algo que sería tan lento que no valdría la pena usarlo. Si realmente lo necesita (y no le molesta leer la memoria también), podría usar xchg [mem], reg (XCHG tiene un prefijo LOCK implícito), que es incluso más lento que un movimiento de lock mov hipotético.

Usar un prefijo de lock también es una barrera de memoria completa, por lo que impone una sobrecarga de rendimiento más allá del RMW atómico. es decir, x86 no puede hacer RMW atómico relajado (sin descargar el búfer de la tienda). Otros ISA pueden, por lo que usar .fetch_add(1, memory_order_relaxed) puede ser más rápido en non-x86.

Dato mfence : antes de que existiera una mfence , una expresión común era lock add dword [esp], 0 , que es una mfence que no es más que banderas de trituración y que realiza una operación bloqueada. [esp] casi siempre está caliente en la memoria caché L1 y no causará conflicto con ningún otro núcleo. Esta expresión idiomática aún puede ser más eficiente que MFENCE como una barrera de memoria independiente, especialmente en las CPU AMD.

xchg [mem], reg es probablemente la forma más eficiente de implementar una tienda de coherencia secuencial, frente a mov + mfence , tanto en Intel como en AMD. mfence en Skylake al menos bloquea la ejecución fuera de orden de las instrucciones que no son de memoria, pero xchg y otras operaciones de lock no lo hacen. Los comstackdores distintos de gcc usan xchg para las tiendas, incluso cuando no les importa leer el valor anterior.


Motivación para esta decisión de diseño:

Sin él, el software tendría que usar lockings de 1 byte (o algún tipo de tipo atómico disponible) para proteger accesos a enteros de 32 bits, lo que es enormemente ineficiente en comparación con el acceso compartido de lectura atómica para algo así como una variable de marca de tiempo global actualizada por un temporizador de interrupción . Probablemente sea básicamente libre en silicio para garantizar accesos alineados de ancho de bus o más pequeños.

Para que el locking sea posible, se requiere algún tipo de acceso atómico. (En realidad, creo que el hardware podría proporcionar algún tipo de mecanismo de locking asistido por hardware totalmente diferente). Para una CPU que realiza transferencias de 32 bits en su bus de datos externo, tiene sentido tener esa unidad de atomicidad.


Como ofreciste una recompensa, supongo que estabas buscando una respuesta larga que divagara en todos los temas secundarios interesantes. Avíseme si hay cosas que no cubrí y que cree que harían que esta sesión de preguntas y respuestas sea más valiosa para los lectores futuros.

Como usted ha vinculado uno en la pregunta , le recomiendo leer más de las publicaciones de blog de Jeff Preshing . Son excelentes y me ayudaron a juntar las piezas de lo que sabía para entender el orden de la memoria en C / C ++ fuente vs. asm para diferentes architectures de hardware, y cómo / cuándo decirle al comstackdor lo que quiere si no está t escribiendo asm directamente.

Si un objeto de 32 bits o más pequeño se alinea de forma natural dentro de una parte “normal” de la memoria, será posible que cualquier procesador 80386 o compatible que no sea el 80386sx lea o escriba los 32 bits del objeto en una sola operación. Si bien la capacidad de una plataforma para hacer algo de manera rápida y útil no necesariamente significa que la plataforma a veces no lo hará de alguna otra manera por alguna razón, y aunque creo que es posible en muchos, si no en todos, los procesadores x86. tienen regiones de memoria a las que solo se puede acceder 8 o 16 bits a la vez, no creo que Intel haya definido alguna vez condiciones en las que solicitar un acceso alineado de 32 bits a un área de memoria “normal” haga que el sistema lea o escribir parte del valor sin leer o escribir todo, y no creo que Intel tenga la intención de definir alguna cosa así para las áreas de memoria “normales”.

Naturalmente alineado significa que la dirección del tipo es un múltiplo del tamaño del tipo.

Por ejemplo, un byte puede estar en cualquier dirección, un corto (asumiendo 16 bits) debe estar en un múltiplo de 2, un int (suponiendo 32 bits) debe estar en un múltiplo de 4, y un largo (suponiendo 64 bits) debe estar en un múltiplo de 8.

En el caso de que acceda a un dato que no está naturalmente alineado, la CPU generará un error o leerá / escribirá la memoria, pero no como una operación atómica. La acción que tome la CPU dependerá de la architecture.

Por ejemplo, la imagen que tenemos el diseño de la memoria a continuación:

 01234567 ...XXXX. 

y

 int *data = (int*)3; 

Cuando tratamos de leer *data los bytes que componen el valor se distribuyen en 2 bloques de tamaño int, 1 byte en el bloque 0-3 y 3 bytes en el bloque 4-7. Ahora, solo porque los bloques estén lógicamente uno al lado del otro no significa que estén físicamente. Por ejemplo, el bloque 0-3 podría estar al final de una línea de caché de la CPU, mientras que el bloque 3-7 está ubicado en un archivo de página. Cuando la CPU va a acceder al bloque 3-7 para obtener los 3 bytes que necesita, puede ver que el bloque no está en la memoria y señala que necesita la paginación de la memoria. Esto probablemente bloqueará el proceso de llamada mientras que el sistema operativo páginas de la memoria de nuevo

Después de que la memoria ha sido localizada, pero antes de que su proceso sea reactivado, puede aparecer otro y escribir una Y en la dirección 4. Luego, su proceso se reprogtwig y la CPU completa la lectura, pero ahora ha leído XYXX, en lugar de el XXXX que esperabas

Si estuviera preguntando por qué está diseñado así, diría que es un buen producto secundario del diseño de la architecture de la CPU.

De regreso en el tiempo 486, no hay una CPU multi-core o un enlace QPI, entonces la atomicidad no es realmente un requisito estricto en ese momento (¿puede ser que DMA lo requiera?).

En x86, el ancho de datos es de 32 bits (o 64 bits para x86_64), lo que significa que la CPU puede leer y escribir en un solo paso el ancho de datos. Y el bus de datos de memoria es generalmente el mismo o más ancho que este número. Combinado con el hecho de que la lectura / escritura en la dirección alineada se realiza de una sola vez, naturalmente no hay nada que impida que la lectura / escritura sea atómica. Usted gana velocidad / atómico al mismo tiempo.

Para responder a su primera pregunta, una variable se alinea naturalmente si existe en una dirección de memoria que es un múltiplo de su tamaño.

Si solo consideramos – como lo hace el artículo que vincula – las instrucciones de asignación , entonces la alineación garantiza la atomicidad porque MOV (la instrucción de asignación) es atómica por diseño en los datos alineados.

Otros tipos de instrucciones, INC por ejemplo, necesitan ser LOCK ed (un prefijo x86 que da acceso exclusivo a la memoria compartida al procesador actual durante la operación prefijada) incluso si los datos están alineados porque en realidad se ejecutan a través de múltiples pasos (= instrucciones, a saber, carga, inc, almacenar).