Cómo ‘esperar’ al levantar un evento EventHandler

A veces, el patrón de eventos se usa para generar eventos en aplicaciones MVVM o un modelo de vista secundario para enviar un mensaje a su modelo de vista principal de una manera poco acoplada como esta.

Padres ViewModel

searchWidgetViewModel.SearchRequest += (s,e) => { SearchOrders(searchWidgitViewModel.SearchCriteria); }; 

SearchWidget ViewModel

 public event EventHandler SearchRequest; SearchCommand = new RelayCommand(() => { IsSearching = true; if (SearchRequest != null) { SearchRequest(this, EventArgs.Empty); } IsSearching = false; }); 

Al refactorizar mi aplicación para .NET4.5 estoy haciendo tanto como sea posible el código para usar async y await . Sin embargo, lo siguiente no funciona (bueno, realmente no lo esperaba)

  await SearchRequest(this, EventArgs.Empty); 

El marco definitivamente hace esto para llamar a los controladores de eventos como este , pero no estoy seguro de cómo lo hace?

 private async void button1_Click(object sender, RoutedEventArgs e) { textBlock1.Text = "Click Started"; await DoWork(); textBlock2.Text = "Click Finished"; } 

Todo lo que he encontrado sobre el tema de plantear eventos de forma asíncrona es antiguo, pero no puedo encontrar algo en el marco que lo respalde.

¿Cómo puedo await la convocatoria de un evento pero permanecer en el hilo de la interfaz de usuario?

Los eventos no encajan perfectamente con async y await , como has descubierto.

La forma en que las IU manejan los eventos async es diferente de lo que estás tratando de hacer. La IU proporciona un SynchronizationContext a sus eventos async , lo que les permite reanudarse en el subproceso de la IU. Nunca los “espera”.

Mejor Solución (IMO)

Creo que la mejor opción es crear su propio sistema de pub / subs async amigable, utilizando AsyncCountdownEvent para saber cuándo se han completado todos los manejadores.

Solución menor # 1

async void métodos de async void notifican a SynchronizationContext cuando comienzan y terminan (incrementando / disminuyendo el conteo de operaciones asíncronas). Todos los UI SynchronizationContext s ignoran estas notificaciones, pero podría crear un contenedor que lo rastree y devuelva cuando el recuento sea cero.

Aquí hay un ejemplo, usando AsyncContext de mi biblioteca AsyncEx :

 SearchCommand = new RelayCommand(() => { IsSearching = true; if (SearchRequest != null) { AsyncContext.Run(() => SearchRequest(this, EventArgs.Empty)); } IsSearching = false; }); 

Sin embargo, en este ejemplo, el subproceso de la interfaz de usuario no está bombeando mensajes mientras está en Run .

Solución menor # 2

También puede crear su propio SynchronizationContext basado en un marco de Dispatcher nested que se abre cuando el recuento de operaciones asíncronas llega a cero. Sin embargo, luego introduces problemas de re-entrada; DoEvents fue dejado fuera de WPF a propósito.

Edición: esto no funciona bien para múltiples suscriptores, así que a menos que solo tenga uno, no recomendaría usarlo.


Se siente un poco raro, pero nunca he encontrado nada mejor:

Declara un delegado. Esto es idéntico a EventHandler pero devuelve una tarea en lugar de un vacío

 public delegate Task AsyncEventHandler(object sender, EventArgs e); 

Luego puede ejecutar lo siguiente y siempre que el controlador declarado en el padre use async y await correctamente, esto se ejecutará de forma asíncrona:

 if (SearchRequest != null) { Debug.WriteLine("Starting..."); await SearchRequest(this, EventArgs.Empty); Debug.WriteLine("Completed"); } 

Controlador de muestras:

  // declare handler for search request myViewModel.SearchRequest += async (s, e) => { await SearchOrders(); }; 

Nota: nunca he probado esto con varios suscriptores y no estoy seguro de cómo funcionará; por lo tanto, si necesita varios suscriptores, asegúrese de probarlo con cuidado.

Basándome en la respuesta de Simon_Weaver, creé una clase de ayuda que puede manejar múltiples suscriptores y tiene una syntax similar a los eventos c #.

 public class AsyncEvent where TEventArgs : EventArgs { private readonly List> invocationList; private readonly object locker; private AsyncEvent() { invocationList = new List>(); locker = new object(); } public static AsyncEvent operator +( AsyncEvent e, Func callback) { if (callback == null) throw new NullReferenceException("callback is null"); //Note: Thread safety issue- if two threads register to the same event (on the first time, ie when it is null) //they could get a different instance, so whoever was first will be overridden. //A solution for that would be to switch to a public constructor and use it, but then we'll 'lose' the similar syntax to c# events if (e == null) e = new AsyncEvent(); lock (e.locker) { e.invocationList.Add(callback); } return e; } public static AsyncEvent operator -( AsyncEvent e, Func callback) { if (callback == null) throw new NullReferenceException("callback is null"); if (e == null) return null; lock (e.locker) { e.invocationList.Remove(callback); } return e; } public async Task InvokeAsync(object sender, TEventArgs eventArgs) { List> tmpInvocationList; lock (locker) { tmpInvocationList = new List>(invocationList); } foreach (var callback in tmpInvocationList) { //Assuming we want a serial invocation, for a parallel invocation we can use Task.WhenAll instead await callback(sender, eventArgs); } } } 

Para usarlo, lo declaras en tu clase, por ejemplo:

 public AsyncEvent SearchRequest; 

Para suscribir un controlador de eventos, usarás la syntax familiar (la misma que en la respuesta de Simon_Weaver):

 myViewModel.SearchRequest += async (s, e) => { await SearchOrders(); }; 

Para invocar el evento, use el mismo patrón que usamos para los eventos c # (solo con InvokeAsync):

 var eventTmp = SearchRequest; if (eventTmp != null) { await eventTmp.InvokeAsync(sender, eventArgs); } 

Si usa c # 6, uno debería poder usar el operador condicional nulo y escribir esto en su lugar:

 await (SearchRequest?.InvokeAsync(sender, eventArgs) ?? Task.CompletedTask); 

Para responder la pregunta directa: no creo que EventHandler permita que las implementaciones se comuniquen lo suficiente de nuevo con el invocador para permitir la espera adecuada. Es posible que pueda realizar trucos con un contexto de sincronización personalizado, pero si le importa esperar a los manejadores, es mejor que los manejadores puedan devolver sus Task al invocador. Al hacer esta parte de la firma del delegado, es más claro que el delegado estará a la await .

Sugiero usar el enfoque Delgate.GetInvocationList() descrito en la respuesta de Ariel mezclado con ideas de la respuesta de tzachs . Defina su propio AsyncEventHandler que devuelve una Task . Luego use un método de extensión para ocultar la complejidad de invocarlo correctamente. Creo que este patrón tiene sentido si desea ejecutar un grupo de controladores de eventos asíncronos y esperar sus resultados.

 using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; public delegate Task AsyncEventHandler( object sender, TEventArgs e) where TEventArgs : EventArgs; public static class AsyncEventHandlerExtensions { public static IEnumerable> GetHandlers( this AsyncEventHandler handler) where TEventArgs : EventArgs => handler.GetInvocationList().Cast>(); public static Task InvokeAllAsync( this AsyncEventHandler handler, object sender, TEventArgs e) where TEventArgs : EventArgs => Task.WhenAll( handler.GetHandlers() .Select(handleAsync => handleAsync(sender, e))); } 

Esto le permite crear un event estilo .net normal. Solo suscríbete como lo harías normalmente.

 public event AsyncEventHandler SomethingHappened; public void SubscribeToMyOwnEventsForNoReason() { SomethingHappened += async (sender, e) => { SomethingSynchronous(); // Safe to touch e here. await SomethingAsynchronousAsync(); // No longer safe to touch e here (please understand // SynchronizationContext well before trying fancy things). SomeContinuation(); }; } 

Luego simplemente recuerde usar los métodos de extensión para invocar el evento en lugar de invocarlos directamente. Si desea más control en su invocación, puede usar la extensión GetHandlers() . Para el caso más común de esperar a que se completen todos los manejadores, solo use el envoltorio de conveniencia InvokeAllAsync() . En muchos patrones, los eventos no producen nada en lo que el interlocutor esté interesado o se comunican con el interlocutor modificando el EventArgs en EventArgs . (Tenga en cuenta que si puede asumir un contexto de sincronización con la serialización de estilo dispatcher, sus controladores de eventos pueden mutar el EventArgs manera segura dentro de sus bloques síncronos porque las continuaciones se convertirán en el hilo del dispatcher. Esto sucederá mágicamente si, por ejemplo, invoca y await el evento desde un subproceso de la interfaz de usuario en winforms o WPF. De lo contrario, es posible que tenga que usar el locking al mutar EventArgs en caso de que alguna de sus mutaciones ocurra en una continuación que se ejecute en el conjunto de subprocesos).

 public async Task Run(string[] args) { if (SomethingHappened != null) await SomethingHappened.InvokeAllAsync(this, EventArgs.Empty); } 

Esto te acerca a algo que parece una invocación de evento normal, excepto que tienes que usar .InvokeAllAsync() . Y, por supuesto, todavía tiene los problemas normales que vienen con eventos como la necesidad de proteger las invocaciones de eventos sin suscriptores para evitar una NullArgumentException .

Tenga en cuenta que no estoy utilizando await SomethingHappened?.InvokeAllAsync(this, EventArgs.Empty) porque await explota en null . Puede usar el siguiente patrón de llamada si lo desea, pero se puede argumentar que los parens son feos y que el estilo if es generalmente mejor por varias razones:

 await (SomethingHappened?.InvokeAllAsync(this, EventArgs.Empty) ?? Task.CompletedTask); 

No tengo claro lo que quiere decir con “¿Cómo puedo await la convocatoria de un evento, pero permanecer en el hilo de la interfaz de usuario”. ¿Desea que el controlador de eventos se ejecute en el subproceso de la interfaz de usuario? Si ese es el caso, entonces puedes hacer algo como esto:

 var h = SomeEvent; if (h != null) { await Task.Factory.StartNew(() => h(this, EventArgs.Empty), Task.Factory.CancellationToken, Task.Factory.CreationOptions, TaskScheduler.FromCurrentSynchronizationContext()); } 

Lo que envuelve la invocación del controlador en un objeto de Task para que pueda usar await , ya que no puede usar await con un método void , que es de donde proviene el error de comstackción.

Pero, no estoy seguro de qué beneficio espera obtener de eso.

Creo que hay un problema de diseño fundamental allí. Está bien comenzar un trabajo de fondo en un evento de clic y puede implementar algo que admita await . Pero, ¿cuál es el efecto sobre cómo se puede utilizar la interfaz de usuario? por ejemplo, si tiene un controlador de Click que inicia una operación que demora 2 segundos, ¿desea que el usuario pueda hacer clic en ese botón mientras la operación está pendiente? Cancelación y tiempo de espera son complejidades adicionales. Creo que se necesita hacer mucho más entendimiento de los aspectos de usabilidad aquí.

Como los delegates (y los eventos son delegates) implementan el Modelo de progtwigción asíncrono (APM), puede usar el método TaskFactory.FromAsync . (Consulte también Tareas y el Modelo de progtwigción asíncrono (APM)) .

 public event EventHandler SearchRequest; public async Task SearchCommandAsync() { IsSearching = true; if (SearchRequest != null) { await Task.Factory.FromAsync(SearchRequest.BeginInvoke, SearchRequest.EndInvoke, this, EventArgs.Empty, null); } IsSearching = false; } 

Sin embargo, el código anterior invocará el evento en un subproceso de grupo de subprocesos, es decir, no capturará el contexto de sincronización actual. Si esto es un problema, puede modificarlo de la siguiente manera:

 public event EventHandler SearchRequest; private delegate void OnSearchRequestDelegate(SynchronizationContext context); private void OnSearchRequest(SynchronizationContext context) { context.Send(state => SearchRequest(this, EventArgs.Empty), null); } public async Task SearchCommandAsync() { IsSearching = true; if (SearchRequest != null) { var search = new OnSearchRequestDelegate(OnSearchRequest); await Task.Factory.FromAsync(search.BeginInvoke, search.EndInvoke, SynchronizationContext.Current, null); } IsSearching = false; } 

Para continuar con la respuesta de Simon Weaver , probé lo siguiente

  if (SearchRequest != null) { foreach (AsyncEventHandler onSearchRequest in SearchRequest.GetInvocationList()) { await onSearchRequest(null, EventArgs.Empty); } } 

Esta costuras para hacer el truco.

 public static class FileProcessEventHandlerExtensions { public static Task InvokeAsync(this FileProcessEventHandler handler, object sender, FileProcessStatusEventArgs args) => Task.WhenAll(handler.GetInvocationList() .Cast() .Select(h => h(sender, args)) .ToArray()); } 

Si está utilizando controladores de eventos personalizados, es posible que desee echar un vistazo a DeferredEvents , ya que le permitirá boost y esperar a los controladores de un evento, como este:

 await MyEvent.InvokeAsync(sender, DeferredEventArgs.Empty); 

El controlador de eventos hará algo como esto:

 public async void OnMyEvent(object sender, DeferredEventArgs e) { var deferral = e.GetDeferral(); await DoSomethingAsync(); deferral.Complete(); } 

Alternativamente, puedes usar el patrón de using como este:

 public async void OnMyEvent(object sender, DeferredEventArgs e) { using (e.GetDeferral()) { await DoSomethingAsync(); } } 

Puedes leer sobre los Eventos Deferred aquí .