Eventos de C# y seguridad de subprocesos

Resuelto Daniel Earwicker asked hace 15 años • 15 respuestas

Con frecuencia escucho/leo los siguientes consejos:

Siempre haga una copia de un evento antes de verificarlo nully dispararlo. Esto eliminará un problema potencial con el subprocesamiento donde el evento se ubica nullen la ubicación justo entre donde verifica si hay nulos y donde activa el evento:

// Copy the event delegate before checking/calling
EventHandler copy = TheEvent;

if (copy != null)
    copy(this, EventArgs.Empty); // Call any handlers on the copied list

Actualizado : al leer sobre optimizaciones, pensé que esto también podría requerir que el miembro del evento sea volátil, pero Jon Skeet afirma en su respuesta que CLR no optimiza la copia.

Pero mientras tanto, para que este problema ocurra, otro hilo debe haber hecho algo como esto:

// Better delist from event - don't want our handler called from now on:
otherObject.TheEvent -= OnTheEvent;
// Good, now we can be certain that OnTheEvent will not run...

La secuencia real podría ser esta combinación:

// Copy the event delegate before checking/calling
EventHandler copy = TheEvent;

// Better delist from event - don't want our handler called from now on:
otherObject.TheEvent -= OnTheEvent;    
// Good, now we can be certain that OnTheEvent will not run...

if (copy != null)
    copy(this, EventArgs.Empty); // Call any handlers on the copied list

El punto es que OnTheEventse ejecuta después de que el autor se haya dado de baja y, sin embargo, simplemente se dio de baja específicamente para evitar que eso suceda. Seguramente lo que realmente se necesita es una implementación de evento personalizada con una sincronización adecuada en los descriptores de acceso addy remove. Y además existe el problema de posibles interbloqueos si se mantiene un bloqueo mientras se activa un evento.

Entonces, ¿esto es programación de culto a la carga ? Eso parece: mucha gente debe dar este paso para proteger su código de múltiples subprocesos, cuando en realidad me parece que los eventos requieren mucho más cuidado que este antes de poder usarse como parte de un diseño de múltiples subprocesos. . En consecuencia, las personas que no toman ese cuidado adicional también podrían ignorar este consejo; simplemente no es un problema para los programas de un solo subproceso y, de hecho, dada la ausencia de volatileen la mayoría de los códigos de ejemplo en línea, es posible que el consejo no tenga efecto en absoluto.

(¿Y no es mucho más sencillo simplemente asignar el vacío delegate { }en la declaración de miembro para que nunca tengas que verificarlo nullen primer lugar?)

Actualizado: en caso de que no estuviera claro, entendí la intención del consejo: evitar una excepción de referencia nula en todas las circunstancias. Mi punto es que esta excepción de referencia nula en particular solo puede ocurrir si otro hilo se elimina de la lista del evento, y la única razón para hacerlo es garantizar que no se reciban más llamadas a través de ese evento, lo que claramente NO se logra con esta técnica. . Estarías ocultando una condición de carrera; ¡sería mejor revelarla! Esa excepción nula ayuda a detectar un abuso de su componente. Si desea que su componente esté protegido contra abusos, puede seguir el ejemplo de WPF: almacenar el ID del subproceso en su constructor y luego generar una excepción si otro subproceso intenta interactuar directamente con su componente. O implementar un componente verdaderamente seguro para subprocesos (no es una tarea fácil).

Así que sostengo que simplemente hacer este modismo de copiar/verificar es una programación de culto a la carga, que agrega desorden y ruido a su código. Para protegerse realmente contra otros hilos se requiere mucho más trabajo.

Actualización en respuesta a las publicaciones del blog de Eric Lippert:

Entonces, hay una cosa importante que me había perdido acerca de los controladores de eventos: "se requiere que los controladores de eventos sean robustos ante ser llamados incluso después de que se haya cancelado la suscripción al evento" y, obviamente, por lo tanto, solo debemos preocuparnos por la posibilidad del evento. delegar el ser null. ¿Ese requisito sobre los controladores de eventos está documentado en alguna parte?

Y entonces: "Hay otras formas de resolver este problema; por ejemplo, inicializar el controlador para que tenga una acción vacía que nunca se elimine. Pero hacer una verificación nula es el patrón estándar".

Entonces, el único fragmento restante de mi pregunta es: ¿ por qué la verificación nula explícita es el "patrón estándar"? La alternativa, asignar el delegado vacío, sólo requiere = delegate {}que se agregue a la declaración del evento, y esto elimina esos pequeños montones de ceremonias apestosas de cada lugar donde se plantea el evento. Sería fácil asegurarse de que sea barato crear una instancia del delegado vacío. ¿O todavía me falta algo?

Seguramente debe ser que (como sugirió Jon Skeet) esto es solo un consejo de .NET 1.x que no ha desaparecido, como debería haber sucedido en 2005.


ACTUALIZAR

A partir de C# 6, la respuesta a esta pregunta es:

SomeEvent?.Invoke(this, e);
Daniel Earwicker avatar Apr 24 '09 22:04 Daniel Earwicker
Aceptado

El JIT no puede realizar la optimización de la que hablas en la primera parte debido a la condición. Sé que esto se planteó como un espectro hace un tiempo, pero no es válido. (Lo comprobé con Joe Duffy o Vance Morrison hace un tiempo; no recuerdo cuál).

Sin el modificador volátil es posible que la copia local tomada esté desactualizada, pero eso es todo. No causará un NullReferenceException.

Y sí, ciertamente existe una condición de carrera, pero siempre la habrá. Supongamos que simplemente cambiamos el código a:

TheEvent(this, EventArgs.Empty);

Ahora supongamos que la lista de invocación para ese delegado tiene 1000 entradas. Es perfectamente posible que la acción al comienzo de la lista se haya ejecutado antes de que otro hilo cancele la suscripción de un controlador cerca del final de la lista. Sin embargo, ese controlador aún se ejecutará porque será una lista nueva. (Los delegados son inmutables). Hasta donde puedo ver, esto es inevitable.

El uso de un delegado vacío ciertamente evita la verificación de nulidad, pero no soluciona la condición de carrera. Tampoco garantiza que siempre "vea" el último valor de la variable.

Jon Skeet avatar Apr 24 '2009 15:04 Jon Skeet

Veo que mucha gente opta por el método de extensión para hacer esto...

public static class Extensions   
{   
  public static void Raise<T>(this EventHandler<T> handler, 
    object sender, T args) where T : EventArgs   
  {   
    if (handler != null) handler(sender, args);   
  }   
}

Eso te da una sintaxis mejor para generar el evento...

MyEvent.Raise( this, new MyEventArgs() );

Y también elimina la copia local, ya que se captura en el momento de la llamada al método.

JP Alioto avatar Apr 24 '2009 15:04 JP Alioto

"¿Por qué la verificación nula explícita es el 'patrón estándar'?"

Sospecho que la razón de esto podría ser que la verificación nula tiene más rendimiento.

Si siempre suscribes un delegado vacío a tus eventos cuando se crean, habrá algunos gastos generales:

  • Costo de construir el delegado vacío.
  • Costo de construir una cadena de delegados para contenerlo.
  • Costo de invocar al delegado inútil cada vez que se plantea el evento.

(Tenga en cuenta que los controles de la interfaz de usuario a menudo tienen una gran cantidad de eventos, a la mayoría de los cuales nunca se suscribe. Tener que crear un suscriptor ficticio para cada evento y luego invocarlo probablemente afectaría significativamente el rendimiento).

Hice algunas pruebas de rendimiento superficiales para ver el impacto del enfoque de suscripción-delegado-vacío, y aquí están mis resultados:

Executing 50000000 iterations . . .
OnNonThreadSafeEvent took:      432ms
OnClassicNullCheckedEvent took: 490ms
OnPreInitializedEvent took:     614ms <--
Subscribing an empty delegate to each event . . .
Executing 50000000 iterations . . .
OnNonThreadSafeEvent took:      674ms
OnClassicNullCheckedEvent took: 674ms
OnPreInitializedEvent took:     2041ms <--
Subscribing another empty delegate to each event . . .
Executing 50000000 iterations . . .
OnNonThreadSafeEvent took:      2011ms
OnClassicNullCheckedEvent took: 2061ms
OnPreInitializedEvent took:     2246ms <--
Done

Tenga en cuenta que para el caso de cero o un suscriptor (común para los controles de UI, donde los eventos son abundantes), el evento preinicializado con un delegado vacío es notablemente más lento (más de 50 millones de iteraciones...)

Para obtener más información y código fuente, visite esta publicación de blog sobre seguridad de subprocesos de invocación de eventos .NET que publiqué justo el día antes de que se hiciera esta pregunta (!)

(Mi configuración de prueba puede tener fallas, así que no dude en descargar el código fuente e inspeccionarlo usted mismo. Cualquier comentario será muy apreciado).

Daniel Fortunov avatar May 11 '2009 06:05 Daniel Fortunov