¿Es posible esperar un evento en lugar de otro método asíncrono?

156

En mi aplicación de metro C # / XAML, hay un botón que inicia un proceso de larga duración. Entonces, como se recomienda, estoy usando async / await para asegurarme de que el hilo de la interfaz de usuario no se bloquee:

private async void Button_Click_1(object sender, RoutedEventArgs e) 
{
     await GetResults();
}

private async Task GetResults()
{ 
     // Do lot of complex stuff that takes a long time
     // (e.g. contact some web services)
  ...
}

Ocasionalmente, las cosas que suceden dentro de GetResults requerirían una entrada adicional del usuario antes de poder continuar. Para simplificar, digamos que el usuario solo tiene que hacer clic en el botón "continuar".

Mi pregunta es: ¿cómo puedo suspender la ejecución de GetResults de tal manera que aguarde un evento como el clic de otro botón?

Aquí hay una forma fea de lograr lo que estoy buscando: el controlador de eventos para el botón continuar "establece una bandera ...

private bool _continue = false;
private void buttonContinue_Click(object sender, RoutedEventArgs e)
{
    _continue = true;
}

... y GetResults lo sondea periódicamente:

 buttonContinue.Visibility = Visibility.Visible;
 while (!_continue) await Task.Delay(100);  // poll _continue every 100ms
 buttonContinue.Visibility = Visibility.Collapsed;

La encuesta es claramente terrible (espera ocupada / pérdida de ciclos) y estoy buscando algo basado en eventos.

¿Algunas ideas?

Por cierto, en este ejemplo simplificado, una solución sería dividir GetResults () en dos partes, invocar la primera parte desde el botón de inicio y la segunda parte desde el botón continuar. En realidad, las cosas que suceden en GetResults son más complejas y se pueden requerir diferentes tipos de entrada del usuario en diferentes puntos dentro de la ejecución. Por lo tanto, dividir la lógica en múltiples métodos no sería trivial.

Max
fuente

Respuestas:

225

Puede usar una instancia de la clase SemaphoreSlim como señal:

private SemaphoreSlim signal = new SemaphoreSlim(0, 1);

// set signal in event
signal.Release();

// wait for signal somewhere else
await signal.WaitAsync();

Alternativamente, puede usar una instancia de la clase TaskCompletionSource <T> para crear una Tarea <T> que represente el resultado del clic del botón:

private TaskCompletionSource<bool> tcs = new TaskCompletionSource<bool>();

// complete task in event
tcs.SetResult(true);

// wait for task somewhere else
await tcs.Task;
dtb
fuente
77
@DanielHilgarth ManualResetEvent(Slim)no parece apoyar WaitAsync().
svick
3
@DanielHilgarth No, no pudiste. asyncno significa "se ejecuta en un hilo diferente", o algo así. Simplemente significa "puede usar awaiten este método". Y en este caso, el bloqueo interno GetResults()realmente bloquearía el hilo de la interfaz de usuario.
svick
2
@Gabe awaiten sí mismo no garantiza que se cree otro subproceso, pero hace que todo lo demás después de la declaración se ejecute como una continuación de la Taskllamada o en espera de que la llame await. La mayoría de las veces, es una especie de operación asincrónica, que podría ser la finalización de E / S, o algo que está en otro hilo.
casperOne
16
+1. Tuve que buscar esto, así que en caso de que otros estén interesados: SemaphoreSlim.WaitAsyncno solo empuja el Waithilo a un hilo de grupo de hilos. SemaphoreSlimtiene una cola adecuada de Tasks que se utilizan para implementar WaitAsync.
Stephen Cleary
14
TaskCompletionSource <T> + wait .Task + .SetResult () resulta ser la solución perfecta para mi escenario, ¡gracias! :-)
Max
75

Cuando tiene algo inusual que necesita await, la respuesta más fácil es a menudo TaskCompletionSource(o alguna asyncprimitiva basada en TaskCompletionSource).

En este caso, su necesidad es bastante simple, por lo que puede usar TaskCompletionSourcedirectamente:

private TaskCompletionSource<object> continueClicked;

private async void Button_Click_1(object sender, RoutedEventArgs e) 
{
  // Note: You probably want to disable this button while "in progress" so the
  //  user can't click it twice.
  await GetResults();
  // And re-enable the button here, possibly in a finally block.
}

private async Task GetResults()
{ 
  // Do lot of complex stuff that takes a long time
  // (e.g. contact some web services)

  // Wait for the user to click Continue.
  continueClicked = new TaskCompletionSource<object>();
  buttonContinue.Visibility = Visibility.Visible;
  await continueClicked.Task;
  buttonContinue.Visibility = Visibility.Collapsed;

  // More work...
}

private void buttonContinue_Click(object sender, RoutedEventArgs e)
{
  if (continueClicked != null)
    continueClicked.TrySetResult(null);
}

Lógicamente, TaskCompletionSourcees como un async ManualResetEvent, excepto que solo puede "configurar" el evento una vez y el evento puede tener un "resultado" (en este caso, no lo estamos usando, así que simplemente configuramos el resultado en null).

Stephen Cleary
fuente
55
Como analizo "aguardar un evento" básicamente como la misma situación que "envolver EAP en una tarea", definitivamente preferiría este enfoque. En mi humilde opinión, es definitivamente un código más simple / más fácil de razonar.
James Manning
8

Aquí hay una clase de utilidad que uso:

public class AsyncEventListener
{
    private readonly Func<bool> _predicate;

    public AsyncEventListener() : this(() => true)
    {

    }

    public AsyncEventListener(Func<bool> predicate)
    {
        _predicate = predicate;
        Successfully = new Task(() => { });
    }

    public void Listen(object sender, EventArgs eventArgs)
    {
        if (!Successfully.IsCompleted && _predicate.Invoke())
        {
            Successfully.RunSynchronously();
        }
    }

    public Task Successfully { get; }
}

Y así es como lo uso:

var itChanged = new AsyncEventListener();
someObject.PropertyChanged += itChanged.Listen;

// ... make it change ...

await itChanged.Successfully;
someObject.PropertyChanged -= itChanged.Listen;
Anders Skovborg
fuente
1
No sé cómo funciona esto. ¿Cómo está ejecutando asincrónicamente mi método personalizado el método Listen? ¿No new Task(() => { });se completará al instante?
nawfal
5

Clase de ayuda simple:

public class EventAwaiter<TEventArgs>
{
    private readonly TaskCompletionSource<TEventArgs> _eventArrived = new TaskCompletionSource<TEventArgs>();

    private readonly Action<EventHandler<TEventArgs>> _unsubscribe;

    public EventAwaiter(Action<EventHandler<TEventArgs>> subscribe, Action<EventHandler<TEventArgs>> unsubscribe)
    {
        subscribe(Subscription);
        _unsubscribe = unsubscribe;
    }

    public Task<TEventArgs> Task => _eventArrived.Task;

    private EventHandler<TEventArgs> Subscription => (s, e) =>
        {
            _eventArrived.TrySetResult(e);
            _unsubscribe(Subscription);
        };
}

Uso:

var valueChangedEventAwaiter = new EventAwaiter<YourEventArgs>(
                            h => example.YourEvent += h,
                            h => example.YourEvent -= h);
await valueChangedEventAwaiter.Task;
Felix Keil
fuente
1
¿Cómo limpiarías la suscripción example.YourEvent?
Denis P
¿@DenisP quizás pase el evento al constructor de EventAwaiter?
CJBrew
@DenisP Mejoré la versión y ejecuté una prueba corta.
Felix Keil
Pude ver agregar IDisposable también, dependiendo de las circunstancias. Además, para evitar tener que escribir el evento dos veces, también podríamos usar Reflection para pasar el nombre del evento, de modo que el uso sea aún más simple. De lo contrario, me gusta el patrón, gracias.
Denis P
4

Idealmente, no lo haces . Si bien ciertamente puede bloquear el hilo asíncrono, eso es un desperdicio de recursos, y no es ideal.

Considere el ejemplo canónico en el que el usuario va a almorzar mientras el botón espera que se haga clic.

Si ha detenido su código asincrónico mientras espera la entrada del usuario, entonces solo está desperdiciando recursos mientras ese hilo está en pausa.

Dicho esto, es mejor si en su operación asincrónica, establece el estado que necesita mantener hasta el punto donde el botón está habilitado y está "esperando" un clic. En ese punto, su GetResultsmétodo se detiene .

Luego, cuando se hace clic en el botón , según el estado que haya almacenado, comienza otra tarea asincrónica para continuar el trabajo.

Debido a SynchronizationContextque se capturará en el controlador de eventos que llama GetResults(el compilador lo hará como resultado del uso de la awaitpalabra clave que se usa y el hecho de que SynchronizationContext.Current no debe ser nulo, dado que está en una aplicación de interfaz de usuario), puede usar async/ meawait gusta así:

private async void Button_Click_1(object sender, RoutedEventArgs e) 
{
     await GetResults();

     // Show dialog/UI element.  This code has been marshaled
     // back to the UI thread because the SynchronizationContext
     // was captured behind the scenes when
     // await was called on the previous line.
     ...

     // Check continue, if true, then continue with another async task.
     if (_continue) await ContinueToGetResultsAsync();
}

private bool _continue = false;
private void buttonContinue_Click(object sender, RoutedEventArgs e)
{
    _continue = true;
}

private async Task GetResults()
{ 
     // Do lot of complex stuff that takes a long time
     // (e.g. contact some web services)
  ...
}

ContinueToGetResultsAsynces el método que continúa obteniendo los resultados en caso de que se presione el botón. Si no se presiona su botón , su controlador de eventos no hace nada.

casperOne
fuente
¿Qué hilo asíncrono? No hay código que no se ejecute en el hilo de la interfaz de usuario, tanto en la pregunta original como en su respuesta.
svick
@svick No es cierto. GetResultsdevuelve a Task. awaitsimplemente dice "ejecuta la tarea, y cuando la tarea esté terminada, continúa el código después de esto". Dado que hay un contexto de sincronización, la llamada se vuelve a ordenar al hilo de la interfaz de usuario, ya que se captura en el await. noawait es lo mismo que Task.Wait(), en lo más mínimo.
casperOne
No dije nada al respecto Wait(). Pero el código GetResults()se ejecutará en el hilo de la interfaz de usuario aquí, no hay otro hilo. En otras palabras, sí, awaitbásicamente ejecuta la tarea, como usted dice, pero aquí, esa tarea también se ejecuta en el hilo de la interfaz de usuario.
svick
@svick No hay ninguna razón para suponer que la tarea se ejecuta en el hilo de la interfaz de usuario, ¿por qué haces esa suposición? Es posible , pero poco probable. Y la llamada es dos llamadas UI separadas, técnicamente, una hasta awaitel código y luego el código await, no hay bloqueo. El resto del código se vuelve a ordenar en una continuación y se programa a través de SynchronizationContext.
casperOne
1
Para otros que quieran ver más, vea aquí: chat.stackoverflow.com/rooms/17937 - @svick y yo básicamente nos malinterpretamos, pero decían lo mismo.
casperOne
3

Stephen Toub publicó esta AsyncManualResetEventclase en su blog .

public class AsyncManualResetEvent 
{ 
    private volatile TaskCompletionSource<bool> m_tcs = new TaskCompletionSource<bool>();

    public Task WaitAsync() { return m_tcs.Task; } 

    public void Set() 
    { 
        var tcs = m_tcs; 
        Task.Factory.StartNew(s => ((TaskCompletionSource<bool>)s).TrySetResult(true), 
            tcs, CancellationToken.None, TaskCreationOptions.PreferFairness, TaskScheduler.Default); 
        tcs.Task.Wait(); 
    }

    public void Reset() 
    { 
        while (true) 
        { 
            var tcs = m_tcs; 
            if (!tcs.Task.IsCompleted || 
                Interlocked.CompareExchange(ref m_tcs, new TaskCompletionSource<bool>(), tcs) == tcs) 
                return; 
        } 
    } 
}
Drew Noakes
fuente
0

Con extensiones reactivas (Rx.Net)

var eventObservable = Observable
            .FromEventPattern<EventArgs>(
                h => example.YourEvent += h,
                h => example.YourEvent -= h);

var res = await eventObservable.FirstAsync();

Puede agregar Rx con Nuget Package System.

Muestra probada:

    private static event EventHandler<EventArgs> _testEvent;

    private static async Task Main()
    {
        var eventObservable = Observable
            .FromEventPattern<EventArgs>(
                h => _testEvent += h,
                h => _testEvent -= h);

        Task.Delay(5000).ContinueWith(_ => _testEvent?.Invoke(null, new EventArgs()));

        var res = await eventObservable.FirstAsync();

        Console.WriteLine("Event got fired");
    }
Felix Keil
fuente
0

Estoy usando mi propia clase AsyncEvent para eventos pendientes.

public delegate Task AsyncEventHandler<T>(object sender, T args) where T : EventArgs;

public class AsyncEvent : AsyncEvent<EventArgs>
{
    public AsyncEvent() : base()
    {
    }
}

public class AsyncEvent<T> where T : EventArgs
{
    private readonly HashSet<AsyncEventHandler<T>> _handlers;

    public AsyncEvent()
    {
        _handlers = new HashSet<AsyncEventHandler<T>>();
    }

    public void Add(AsyncEventHandler<T> handler)
    {
        _handlers.Add(handler);
    }

    public void Remove(AsyncEventHandler<T> handler)
    {
        _handlers.Remove(handler);
    }

    public async Task InvokeAsync(object sender, T args)
    {
        foreach (var handler in _handlers)
        {
            await handler(sender, args);
        }
    }

    public static AsyncEvent<T> operator+(AsyncEvent<T> left, AsyncEventHandler<T> right)
    {
        var result = left ?? new AsyncEvent<T>();
        result.Add(right);
        return result;
    }

    public static AsyncEvent<T> operator-(AsyncEvent<T> left, AsyncEventHandler<T> right)
    {
        left.Remove(right);
        return left;
    }
}

Para declarar un evento en la clase que genera eventos:

public AsyncEvent MyNormalEvent;
public AsyncEvent<ProgressEventArgs> MyCustomEvent;

Para plantear los eventos:

if (MyNormalEvent != null) await MyNormalEvent.InvokeAsync(this, new EventArgs());
if (MyCustomEvent != null) await MyCustomEvent.InvokeAsync(this, new ProgressEventArgs());

Para suscribirse a los eventos:

MyControl.Click += async (sender, args) => {
    // await...
}

MyControl.Click += (sender, args) => {
    // synchronous code
    return Task.CompletedTask;
}
cat_in_hat
fuente
1
Has inventado completamente un nuevo mecanismo de controlador de eventos. Tal vez esto es a lo que eventualmente se traducen los delegados en .NET, pero no se puede esperar que las personas lo adopten. Tener un tipo de retorno para el delegado (del evento) en sí mismo puede desanimar a las personas para empezar. Pero buen esfuerzo, realmente me gusta lo bien que está hecho.
nawfal
@nawfal Gracias! Lo he modificado desde entonces para evitar devolver un delegado. La fuente está disponible aquí como parte de Lara Web Engine, una alternativa a Blazor.
cat_in_hat