¿Por qué el patrón estándar de invocación de eventos de C # es seguro para subprocesos sin una barrera de memoria o invalidación de caché? ¿Qué pasa con el código similar?

En C #, este es el código estándar para invocar un evento de una manera segura para subprocesos:

var handler = SomethingHappened; if(handler != null) handler(this, e); 

Donde, potencialmente en otro subproceso, el método de adición generado por el comstackdor usa Delegate.Combine para crear una nueva instancia de delegado de multidifusión, que luego establece en el campo generado por el comstackdor (usando el intercambio de comparación entrelazado).

(Nota: para los fines de esta pregunta, no nos importa el código que se ejecuta en los suscriptores del evento. Supongamos que es seguro para subprocesos y robusto en la cara de la eliminación).


En mi propio código, quiero hacer algo similar, en este sentido:

 var localFoo = this.memberFoo; if(localFoo != null) localFoo.Bar(localFoo.baz); 

Donde this.memberFoo podría ser establecido por otro hilo. (Es solo un hilo, así que no creo que deba estar interbloqueado, pero ¿quizás haya un efecto secundario aquí?)

(Y, obviamente, supongamos que Foo es “lo suficientemente inmutable” que no lo estamos modificando activamente mientras está en uso en este hilo).


Ahora entiendo la razón obvia por la que esto es seguro para subprocesos : las lecturas de los campos de referencia son atómicas. Copiar a un local asegura que no obtengamos dos valores diferentes. ( Aparentemente solo está garantizado desde .NET 2.0, pero ¿asumo que es seguro en cualquier implementación de .NET?)


Pero lo que no entiendo es: ¿qué pasa con la memoria ocupada por la instancia de objeto a la que se hace referencia? Particularmente en lo que respecta a la coherencia de caché? Si un hilo “escritor” hace esto en una CPU:

 thing.memberFoo = new Foo(1234); 

¿Qué garantiza que la memoria donde se asigna el nuevo Foo no se encuentre en la memoria caché de la CPU en la que se ejecuta el “lector”, con valores sin inicializar? ¿Qué garantiza que localFoo.baz (arriba) no lea basura? (¿Y qué tan bien garantizado está esto en todas las plataformas? ¿En Mono? ¿En ARM?)

¿Y qué pasa si el nuevo foo creado viene de una piscina?

 thing.memberFoo = FooPool.Get().Reset(1234); 

Esto no parece ser diferente, desde la perspectiva de la memoria, a una asignación nueva, pero ¿tal vez el asignador .NET haga algo de magia para hacer que el primer caso funcione?


Mi pensamiento, al preguntar esto, es que se necesitaría una barrera de memoria para asegurar, no tanto que los accesos a la memoria no se puedan mover, dado que la lectura es dependiente, sino como una señal a la CPU para vaciar cualquier invalidación de caché.

Mi fuente para esto es Wikipedia , así que haz de eso lo que quieras.

(Podría especular que tal vez el intercambio de comparación entrelazada en el hilo del escritor invalida el caché en el lector. ¿O tal vez todas las lecturas causan la invalidación? ¿O las referencias a los punteros causan la invalidación?


Actualización: solo para hacer más explícito que la pregunta es sobre la invalidación de la memoria caché de la CPU y qué garantías ofrece .NET (y cómo esas garantías pueden depender de la architecture de la CPU):

  • Digamos que tenemos una referencia almacenada en el campo Q (una ubicación de memoria).
  • En la CPU A (escritor) inicializamos un objeto en la ubicación de memoria R , y escribimos una referencia a R en Q
  • En la CPU B (lector), eliminamos el campo Q y recuperamos la ubicación de memoria R
  • Luego, en la CPU B , leemos un valor de R

Supongamos que el GC no se ejecuta en ningún punto. Nada más interesante sucede.

Pregunta: ¿Qué impide que R esté en el caché de B ? Antes de que A lo modificara durante la inicialización, de modo que cuando B lee desde R obtiene valores obsoletos, a pesar de que obtiene una versión nueva de Q para saber dónde está R ¿El primer lugar?

(Redacción alternativa: lo que hace que la modificación a R visible para la CPU B en o antes del punto en que el cambio a Q sea ​​visible para la CPU B ).

(¿Y esto solo se aplica a la memoria asignada con memoria new o a cualquier otra?) +


Nota: He publicado una auto-respuesta aquí .

Esta es una muy buena pregunta. Consideremos tu primer ejemplo.

 var handler = SomethingHappened; if(handler != null) handler(this, e); 

¿Por qué es esto seguro? Para responder a esa pregunta, primero tiene que definir qué quiere decir con “seguro”. ¿Es seguro de una NullReferenceException? Sí, es bastante trivial ver que el almacenamiento en caché de la referencia del delegado elimina localmente esa molesta carrera entre la comprobación nula y la invocación. ¿Es seguro tener más de un hilo tocando al delegado? Sí, los delegates son inmutables, por lo que no hay forma de que un hilo pueda hacer que el delegado entre en un estado a medias. Los dos primeros son obvios. Pero, ¿qué pasa con un escenario en el que el hilo A está haciendo esta invocación en un bucle y el hilo B en algún momento posterior asigna el primer controlador de eventos? ¿Es seguro en el sentido de que el hilo A finalmente verá un valor no nulo para el delegado? La respuesta algo sorprendente a esto es probablemente . La razón es que las implementaciones predeterminadas de los accesores de add y remove para el evento crean barreras de memoria. Creo que la versión anterior del CLR tuvo un lock explícito y las versiones posteriores utilizaron Interlocked.CompareExchange . Si implementó sus propios accesores y omitió una barrera de memoria, la respuesta podría ser no. Creo que en realidad depende en gran medida de si Microsoft agregó barreras de memoria a la construcción del delegado de multidifusión.

En el segundo y más interesante ejemplo.

 var localFoo = this.memberFoo; if(localFoo != null) localFoo.Bar(localFoo.baz); 

No Lo siento, esto en realidad no es seguro. Supongamos que memberFoo es de tipo Foo que se define como la siguiente.

 public class Foo { public int baz = 0; public int daz = 0; public Foo() { baz = 5; daz = 10; } public void Bar(int x) { x / daz; } } 

Y luego asummos que otro hilo hace lo siguiente.

 this.memberFoo = new Foo(); 

A pesar de lo que algunos puedan pensar, no hay nada que obligue a que las instrucciones se ejecuten en el orden en que se definieron en el código, siempre que la intención del progtwigdor se conserve lógicamente. Los comstackdores C # o JIT podrían formular la siguiente secuencia de instrucciones.

 /* 1 */ set register = alloc-memory-and-return-reference(typeof(Foo)); /* 2 */ set register.baz = 0; /* 3 */ set register.daz = 0; /* 4 */ set this.memberFoo = register; /* 5 */ set register.baz = 5; // Foo.ctor /* 6 */ set register.daz = 10; // Foo.ctor 

Observe cómo se produce la asignación a memberFoo antes de ejecutar el constructor. Eso es válido porque no tiene ningún efecto secundario no deseado desde la perspectiva del hilo que lo ejecuta. Sin embargo, podría tener un gran impacto en otros subprocesos. ¿Qué sucede si su comprobación nula de memberFoo en el hilo de lectura ocurrió cuando el hilo de escritura acaba de terminar la instrucción # 4? El lector verá un valor no nulo y luego intentará invocar Bar antes de que la variable daz se establezca en 10. daz aún mantendrá su valor predeterminado de 0, lo que dará lugar a un error de división por cero. Por supuesto, esto es principalmente teórico porque la implementación de CLR por parte de Microsoft crea una versión de lanzamiento en las escrituras que evitarían esto. Pero, la especificación técnicamente lo permitiría. Vea esta pregunta para el contenido relacionado.

Creo que he descubierto cuál es la respuesta. Pero no soy un tipo de hardware, así que estoy abierto a ser corregido por alguien más familiarizado con el funcionamiento de las CPU.


El modelo de memoria .NET 2.0 garantiza :

Las escrituras no pueden moverse más allá de otras escrituras desde el mismo hilo.

Esto significa que la CPU de escritura ( A en el ejemplo), nunca escribirá una referencia a un objeto en la memoria (a Q ), hasta después de que haya escrito el contenido de ese objeto que se está construyendo (a R ). Hasta ahora tan bueno. Esto no puede ser reordenado:

 R =  Q = &R 

Consideremos la CPU de lectura ( B ). ¿Qué es detener la lectura de R antes de leer de Q ?

En una CPU suficientemente ingenua, uno esperaría que fuera imposible leer desde R sin una primera lectura de Q Primero debemos leer Q para obtener la dirección de R (Nota: es seguro asumir que el comstackdor de C # y JIT se comportan de esta manera).

Pero, si la CPU de lectura tiene un caché, ¿no podría tener memoria obsoleta para R en su caché, pero recibir la Q actualizada?

La respuesta parece ser no . Para los protocolos de coherencia de caché sanos, la invalidación se implementa como una cola (de ahí la “cola de invalidación”). Por lo tanto, R siempre se invalidará antes de que Q se invalide.

Aparentemente, el único hardware donde este no es el caso es el DEC Alpha (de acuerdo con la Tabla 1, aquí ). Es la única architecture listada donde las lecturas dependientes se pueden reordenar. ( Lectura adicional .)

Capturar la referencia a un objeto inmutable garantiza la seguridad del hilo (en el sentido de la coherencia, no garantiza que obtenga el último valor).

La lista de manejadores de eventos es inmutable y, por lo tanto, es suficiente para que la seguridad del hilo capture la referencia al valor actual. Todo el objeto sería coherente, ya que nunca cambiará después de la creación inicial.

Su código de muestra no indica explícitamente si Foo es inmutable, por lo que tiene todo tipo de problemas para determinar si el objeto puede cambiar o no, es decir, directamente al establecer las propiedades. Tenga en cuenta que el código sería “inseguro” incluso en un caso de un solo hilo, ya que no puede garantizar que la instancia particular de Foo no cambie.

En cachés de CPU y similares: el único cambio que puede invalidar los datos en la ubicación real en la memoria para un objeto inmutable verdadero es la compactación de GC. Ese código garantiza todos los lockings / consistencia de caché necesarios, por lo que el código administrado nunca observaría cambios en los bytes a los que hace referencia su puntero en caché a un objeto inmutable.

Cuando esto se evalúa:

 thing.memberFoo = new Foo(1234); 

Primero se evalúa el new Foo(1234) , lo que significa que el constructor de Foo ejecuta hasta su finalización. Entonces a thing.memberFoo se le asigna el valor. Esto significa que cualquier otra lectura de hilo de thing.memberFoo no va a leer un objeto incompleto. O bien leerá el valor anterior o leerá la referencia al nuevo objeto Foo vez que el constructor haya terminado. Si este nuevo objeto está en el caché o no es irrelevante; La referencia que se está leyendo no apuntará al nuevo objeto hasta que el constructor haya finalizado.

Lo mismo sucede con el conjunto de objetos. Todo lo que está a la derecha se evalúa completamente antes de que suceda la tarea.

En su ejemplo, B nunca obtendrá la referencia a R antes de que el constructor de R haya ejecutado, porque A no escribe R en Q hasta que A haya terminado de construir R Si B lee Q antes de eso, obtendrá el valor que ya estaba en Q Si el constructor de R lanza una excepción, Q nunca se escribirá en.

C # orden de operaciones garantiza que esto sucederá de esta manera. Los operadores de asignación tienen la prioridad más baja, y los operadores de llamada new y de función tienen la prioridad más alta. Esto garantiza que lo new se evaluará antes de que se evalúe la tarea. Esto es necesario para cosas como excepciones: si el constructor lanza una excepción, el objeto que se asigna estará en un estado no válido y no querrá que se realice esa asignación, independientemente de si está o no multiproceso.

Me parece que deberías usar este artículo en este caso. Esto asegura que el comstackdor no realice optimizaciones que asumn el acceso por un solo hilo.

Los eventos utilizados para usar lockings, pero a partir de C # 4 usan sincronización sin locking: no estoy seguro de qué es exactamente ( consulte este artículo ).

EDITAR: Los métodos de Enclavamiento utilizan barreras de memoria que asegurarán que todos los subprocesos lean el valor actualizado (en cualquier sistema sano). Siempre y cuando realices todas las actualizaciones con Interlocked, puedes leer el valor de cualquier hilo sin una barrera de memoria. Este es el patrón utilizado en las clases System.Collections.Concurrent.