¿El acceso a una variable en C # es una operación atómica?

Me criaron para creer que si varios subprocesos pueden acceder a una variable, todas las lecturas y escrituras de esa variable deben estar protegidas por un código de sincronización, como una instrucción de “locking”, porque el procesador podría cambiar a otro subproceso a la mitad un escrito.

Sin embargo, estaba buscando a través de System.Web.Security.Membership usando Reflector y encontré un código como este:

public static class Membership { private static bool s_Initialized = false; private static object s_lock = new object(); private static MembershipProvider s_Provider; public static MembershipProvider Provider { get { Initialize(); return s_Provider; } } private static void Initialize() { if (s_Initialized) return; lock(s_lock) { if (s_Initialized) return; // Perform initialization... s_Initialized = true; } } } 

¿Por qué se lee el campo s_Initialized fuera del locking? ¿No podría otro hilo estar tratando de escribir al mismo tiempo? Son lecturas y escrituras de variables atómicas?

Para la respuesta definitiva ve a la especificación. 🙂

Partición I, Sección 12.6.6 de la especificación CLI: “Una CLI conforme debe garantizar que el acceso de lectura y escritura a ubicaciones de memoria alineadas correctamente no mayores que el tamaño de la palabra nativa es atómico cuando todos los accesos de escritura a una ubicación son del mismo tamaño ”

Así que eso confirma que s_Initialized nunca será inestable, y que leer y escribir en primitve tipos más pequeños que 32 bits son atómicos.

En particular, no se garantiza que el double y el long ( Int64 y UInt64 ) sean atómicos en una plataforma de 32 bits. Puede usar los métodos en la clase Interlocked para protegerlos.

Además, aunque las lecturas y escrituras son atómicas, existe una condición de carrera con sum, resta y tipos primitivos de incremento y decremento, ya que deben leerse, operarse y reescribirse. La clase interconectada le permite proteger estos usando los métodos CompareExchange e Increment .

El enclavamiento crea una barrera de memoria para evitar que el procesador reordena las lecturas y escrituras. El locking crea la única barrera requerida en este ejemplo.

¡Esta es una forma (mala) del patrón de locking de doble verificación que no es seguro para subprocesos en C #!

Hay un gran problema en este código:

s_Initialized no es volátil. Esto significa que las escrituras en el código de inicialización pueden moverse después de que s_Initialized se establece en verdadero y otros hilos pueden ver el código no inicializado, incluso si s_Initialized es verdadero para ellos. Esto no se aplica a la implementación de Microsoft del Marco porque cada escritura es una escritura volátil.

Pero también en la implementación de Microsoft, las lecturas de los datos no inicializados se pueden reordenar (es decir, son extraídas previamente por la CPU), por lo que si s_Initialized es verdadero, leer los datos que se deben inicializar puede dar como resultado datos antiguos no inicializados debido a cache-hits (es decir .las lecturas se reordenan).

Por ejemplo:

 Thread 1 reads s_Provider (which is null) Thread 2 initializes the data Thread 2 sets s\_Initialized to true Thread 1 reads s\_Initialized (which is true now) Thread 1 uses the previously read Provider and gets a NullReferenceException 

Mover la lectura de s_Provider antes de la lectura de s_Initialized es perfectamente legal porque no hay lectura volátil en ninguna parte.

Si s_Initialized sería volátil, la lectura de s_Provider no se podría mover antes de la lectura de s_Initialized y también la inicialización del proveedor no se puede mover después de que s_Initialized se establece en true y todo está bien ahora.

Joe Duffy también escribió un artículo sobre este problema: Variantes rotas en el locking con doble verificación

Espera – la pregunta que está en el título definitivamente no es la verdadera pregunta que Rory está haciendo.

La pregunta nominal tiene la respuesta simple de “No”, pero esto no es de ninguna ayuda cuando ves la pregunta real, que no creo que nadie haya dado una respuesta simple.

La verdadera pregunta que Rory hace se presenta mucho más tarde y es más pertinente al ejemplo que da.

¿Por qué se lee el campo s_Initialized fuera del locking?

La respuesta a esto también es simple, aunque completamente ajena a la atomicidad del acceso variable.

El campo s_Initialized se lee fuera del locking porque los lockings son costosos .

Como el campo s_Initialized es esencialmente “write once”, nunca devolverá un falso positivo.

Es económico leerlo fuera de la cerradura.

Esta es una actividad de bajo costo con una alta probabilidad de tener un beneficio.

Es por eso que se lee fuera de la cerradura, para evitar pagar el costo de usar una cerradura a menos que esté indicado.

Si las cerraduras fueran baratas, el código sería más simple y omitiría esa primera comprobación.

(editar: buena respuesta de rory sigue. Yeh, las lecturas booleanas son muy atómicas. Si alguien construyera un procesador con lecturas booleanas no atómicas, aparecerían en el DailyWTF).

La respuesta correcta parece ser, “Sí, principalmente”.

  1. La respuesta de John que hace referencia a la especificación CLI indica que los accesos a variables que no superan los 32 bits en un procesador de 32 bits son atómicos.
  2. Confirmación adicional de la especificación C #, sección 5.5, Atomicidad de las referencias variables :

    Las lecturas y escrituras de los siguientes tipos de datos son atómicos: bool, char, byte, sbyte, corto, ushort, uint, int, float y tipos de referencia. Además, las lecturas y escrituras de tipos enum con un tipo subyacente en la lista anterior también son atómicas. No se garantiza que las lecturas y escrituras de otros tipos, incluidos long, ulong, double y decimal, así como los tipos definidos por el usuario, sean atómicas.

  3. El código en mi ejemplo fue parafraseado de la clase de Membresía, tal como fue escrito por el equipo de ASP.NET, por lo que siempre fue seguro suponer que la forma en que accede al campo s_Initialized es correcta. Ahora sabemos por qué.

Editar: Como señala Thomas Danecker, aunque el acceso al campo sea atómico, s_Initialized debería marcarse como volátil para asegurarse de que el locking no se interrumpa cuando el procesador reordena las lecturas y escrituras.

La función de inicialización es defectuosa. Debería verse más como esto:

 private static void Initialize() { if(s_initialized) return; lock(s_lock) { if(s_Initialized) return; s_Initialized = true; } } 

Sin el segundo control dentro de la cerradura, es posible que el código de inicialización se ejecute dos veces. Así que el primer control es para que el rendimiento lo salve tomando un locking innecesariamente, y el segundo cheque es para el caso donde un hilo está ejecutando el código de inicialización pero aún no ha establecido el indicador s_Initialized y así un segundo hilo pasaría el primer cheque y espera en la cerradura.

Las lecturas y escrituras de las variables no son atómicas. Debe usar las API de sincronización para emular las lecturas / escrituras atómicas.

Para obtener una referencia impresionante sobre este y muchos otros temas relacionados con la simultaneidad, asegúrate de obtener una copia del último espectáculo de Joe Duffy. Es un destripador!

“¿Es una operación atómica acceder a una variable en C #?”

Nop. Y no es algo de C #, ni siquiera es algo de .net, es algo de procesador.

OJ es consciente de que Joe Duffy es el tipo a quien recurrir para obtener este tipo de información. Y “interbloqueado” es un excelente término de búsqueda para usar si quiere saber más.

Las “lecturas rasgadas” pueden ocurrir en cualquier valor cuyos campos sumen más que el tamaño de un puntero.

@León
Entiendo tu punto: de la manera en que lo hice, y luego comenté, la pregunta permite que se tome de dos maneras diferentes.

Para que quede claro, quería saber si era seguro tener hilos concurrentes para leer y escribir en un campo booleano sin ningún código explícito de sincronización, es decir, acceder a una variable booleana (u otra tipo primitiva) atómica.

Luego utilicé el código de Membresía para dar un ejemplo concreto, pero eso introdujo un montón de distracciones, como el locking de doble verificación, el hecho de que s_Initialized solo se establece una vez, y que comenté el código de inicialización.

Mi error.

También puede decorar s_Initialized con la palabra clave volátil y renunciar al uso de locking por completo.

Eso no es correcto. Todavía se encontrará con el problema de que un segundo hilo pase la verificación antes de que el primer hilo haya tenido la oportunidad de establecer el indicador, lo que dará como resultado múltiples ejecuciones del código de inicialización.

Creo que estás preguntando si s_Initialized podría estar en un estado inestable cuando se lee fuera del locking. La respuesta corta es no. Una asignación / lectura simple se reducirá a una única instrucción de ensamblaje que es atómica en cada procesador que se me ocurre.

No estoy seguro de cuál es el caso para la asignación a variables de 64 bits, depende del procesador, supongo que no es atómico, pero probablemente esté en procesadores modernos de 32 bits y ciertamente en todos los procesadores de 64 bits. La asignación de tipos de valores complejos no será atómica.

Pensé que lo estaban, no estoy seguro del punto del locking en su ejemplo, a menos que también esté haciendo algo con s_Provider al mismo tiempo; luego, el locking garantizaría que estas llamadas ocurrieran juntas.

¿Eso //Perform initialization lleva a cabo la portada de comentarios de //Perform initialization creando s_Provider? Por ejemplo

 private static void Initialize() { if (s_Initialized) return; lock(s_lock) { s_Provider = new MembershipProvider ( ... ) s_Initialized = true; } } 

De lo contrario, static-get’s va a devolver null de todos modos.

Quizás Interlocked da una pista. Y de lo contrario este es bastante bueno.

Hubiera supuesto que no son atómicos.

Para hacer que su código funcione siempre en architectures ordenadas débilmente, debe colocar un MemoryBarrier antes de escribir s_Initialized.

 s_Provider = new MemershipProvider; // MUST PUT BARRIER HERE to make sure the memory writes from the assignment // and the constructor have been wriitten to memory // BEFORE the write to s_Initialized! Thread.MemoryBarrier(); // Now that we've guaranteed that the writes above // will be globally first, set the flag s_Initialized = true; 

La memoria escribe que sucede en el constructor MembershipProvider y no se garantiza que suceda la escritura en s_Provider antes de escribir en s_Initialized en un procesador débilmente ordenado.

Un montón de pensamiento en este hilo trata de si algo es atómico o no. Ese no es el problema. El problema es el orden en que las escrituras de su hilo son visibles para otros hilos . En architectures ordenadas débilmente, las escrituras en la memoria no ocurren en orden y ESO es el problema real, no si una variable encaja dentro del bus de datos.

EDITAR: En realidad, estoy mezclando plataformas en mis declaraciones. En C #, la especificación de CLR requiere que las escrituras sean visibles a nivel mundial, en orden (usando costosas instrucciones de tienda para cada tienda si es necesario). Por lo tanto, no necesita tener esa barrera de memoria allí. Sin embargo, si fuera C o C ++ donde no existe tal garantía de orden de visibilidad global, y su plataforma objective puede tener memoria débilmente ordenada, y es multiproceso, entonces debería asegurarse de que las escrituras de los constructores estén visibles globalmente antes de actualizar s_Initialized , que se prueba fuera de la cerradura.

Un If (itisso) { check en un booleano es atómico, pero incluso si no lo fuera, no hay necesidad de bloquear el primer cheque.

Si algún hilo ha completado la inicialización, entonces será verdadero. No importa si varios hilos están revisando a la vez. Todos obtendrán la misma respuesta y no habrá conflicto.

La segunda verificación dentro del locking es necesaria porque otro hilo pudo haber agarrado primero el locking y ya completó el proceso de inicialización.

Lo que estás preguntando es si acceder a un campo en un método varias veces atómico, a lo que la respuesta es no.

En el ejemplo anterior, la rutina de inicialización es defectuosa ya que puede dar lugar a una inicialización múltiple. s_Initialized verificar el indicador s_Initialized dentro y fuera del locking, para evitar una condición de carrera en la que varios hilos leen el indicador s_Initialized antes de que ninguno de ellos realmente haga el código de inicialización. P.ej,

 private static void Initialize() { if (s_Initialized) return; lock(s_lock) { if (s_Initialized) return; s_Provider = new MembershipProvider ( ... ) s_Initialized = true; } } 

Ack, no importa … como se señaló, esto de hecho es incorrecto. No evita que un segundo hilo ingrese a la sección de código “inicializar”. Bah.

También puede decorar s_Initialized con la palabra clave volátil y renunciar al uso de locking por completo.