È possibile attendere un evento anziché un altro metodo asincrono?


156

Nella mia app metro C # / XAML, c'è un pulsante che avvia un processo di lunga durata. Quindi, come raccomandato, sto usando async / await per assicurarmi che il thread dell'interfaccia utente non venga bloccato:

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)
  ...
}

Di tanto in tanto, le cose che accadono all'interno di GetResults richiedono un input aggiuntivo da parte dell'utente prima di poter continuare. Per semplicità, supponiamo che l'utente debba semplicemente fare clic su un pulsante "continua".

La mia domanda è: come posso sospendere l'esecuzione di GetResults in modo tale da attendere un evento come il clic di un altro pulsante?

Ecco un brutto modo per ottenere ciò che sto cercando: il gestore di eventi per il pulsante continua "imposta una bandiera ...

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

... e GetResults lo interroga periodicamente:

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

Il polling è chiaramente terribile (attesa impegnata / spreco di cicli) e sto cercando qualcosa basato sugli eventi.

Qualche idea?

In questo esempio semplificato, una soluzione sarebbe ovviamente quella di dividere GetResults () in due parti, invocare la prima parte dal pulsante di avvio e la seconda parte dal pulsante continua. In realtà, le cose che accadono in GetResults sono più complesse e diversi tipi di input dell'utente possono essere richiesti in diversi punti dell'esecuzione. Quindi suddividere la logica in più metodi non sarebbe banale.

Risposte:


225

È possibile utilizzare un'istanza della classe SemaphoreSlim come segnale:

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

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

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

In alternativa, è possibile utilizzare un'istanza della classe TaskCompletionSource <T> per creare un'attività <T> che rappresenta il risultato del clic sul pulsante:

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

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

// wait for task somewhere else
await tcs.Task;

7
@DanielHilgarth ManualResetEvent(Slim)non sembra supportare WaitAsync().
svick,

3
@DanielHilgarth No, non puoi. asyncnon significa "gira su un thread diverso", o qualcosa del genere. Significa solo "è possibile utilizzare awaitin questo metodo". E in questo caso, il blocco all'interno GetResults()bloccherebbe effettivamente il thread dell'interfaccia utente.
svick,

2
@Gabe di awaitper sé non garantisce che venga creato un altro thread, ma fa sì che tutto il resto dopo l'istruzione venga eseguito come una continuazione sull'attendibile Tasko in attesa await. Più spesso, è una sorta di operazione asincrona, che potrebbe essere il completamento di I / O o qualcosa che si trova su un altro thread.
casper:

16
+1. Ho dovuto cercare questo, quindi nel caso in cui altri siano interessati: SemaphoreSlim.WaitAsyncnon si limita a spingere il Waitthread su un thread pool. SemaphoreSlimha una coda corretta di messaggi Taskche sono utilizzati per implementare WaitAsync.
Stephen Cleary,

14
TaskCompletionSource <T> + await .Task + .SetResult () risulta essere la soluzione perfetta per il mio scenario - grazie! :-)
Max

75

Quando hai qualcosa di insolito che devi fare await, la risposta più semplice è spesso TaskCompletionSource(o qualche asyncprimitiva abilitata basata suTaskCompletionSource ).

In questo caso, il tuo bisogno è abbastanza semplice, quindi puoi semplicemente usare TaskCompletionSourcedirettamente:

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);
}

Logicamente, TaskCompletionSourceè come un async ManualResetEvent, tranne per il fatto che è possibile "impostare" l'evento una sola volta e l'evento può avere un "risultato" (in questo caso, non lo stiamo usando, quindi abbiamo semplicemente impostato il risultato su null).


5
Dato che analizzo "attendo un evento" sostanzialmente come la stessa situazione di "avvolgere EAP in un compito", preferirei sicuramente questo approccio. IMHO, è decisamente più semplice / facile da ragionare sul codice.
James Manning,

8

Ecco una classe di utilità che utilizzo:

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; }
}

Ed ecco come lo uso:

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

// ... make it change ...

await itChanged.Successfully;
someObject.PropertyChanged -= itChanged.Listen;

1
Non so come funzioni. In che modo il metodo Listen esegue in modo asincrono il mio gestore personalizzato? Non new Task(() => { });verrebbe completato all'istante?
nawfal,

5

Classe di aiuto semplice:

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;

1
Come ripuliresti l'abbonamento example.YourEvent?
Denis P

@DenisP forse passa l'evento nel costruttore per EventAwaiter?
CJBrew

@DenisP Ho migliorato la versione ed eseguito un breve test.
Felix Keil,

Ho potuto vedere l'aggiunta anche di IDisposable, a seconda delle circostanze. Inoltre, per evitare di dover digitare l'evento due volte, potremmo anche usare Reflection per passare il nome dell'evento, quindi l'utilizzo è ancora più semplice. Altrimenti, mi piace lo schema, grazie.
Denis P

4

Idealmente, non lo fai . Anche se puoi sicuramente bloccare il thread asincrono, è uno spreco di risorse e non l'ideale.

Considera l'esempio canonico in cui l'utente va a pranzo mentre il pulsante è in attesa di essere cliccato.

Se hai fermato il tuo codice asincrono mentre aspettavi l'input dell'utente, allora sta semplicemente sprecando risorse mentre quel thread è in pausa.

Detto questo, è meglio se nella tua operazione asincrona imposti lo stato che devi mantenere al punto in cui il pulsante è abilitato e stai "aspettando" con un clic. A quel punto, il tuo GetResultsmetodo si interrompe .

Quindi, quando si fa clic sul pulsante , in base allo stato memorizzato, si avvia un'altra attività asincrona per continuare il lavoro.

Poiché SynchronizationContextverrà catturato nel gestore di eventi che chiama GetResults(il compilatore lo farà come risultato dell'utilizzo della awaitparola chiave utilizzata e del fatto che SynchronizationContext.Current dovrebbe essere non null, dato che ci si trova in un'applicazione UI), l'utente può usare async/await così:

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)
  ...
}

ContinueToGetResultsAsyncè il metodo che continua a ottenere i risultati nel caso in cui venga premuto il pulsante. Se il pulsante non viene premuto, il gestore dell'evento non fa nulla.


Quale thread asincrono? Non esiste alcun codice che non verrà eseguito sul thread dell'interfaccia utente, sia nella domanda originale che nella risposta.
svick,

@svick Non è vero. GetResultsrestituisce a Task. awaitdice semplicemente "esegui l'attività e, al termine dell'attività, continua il codice dopo questo". Dato che esiste un contesto di sincronizzazione, la chiamata viene trasferita al thread dell'interfaccia utente, poiché viene acquisita sul await. nonawait è lo stesso di , non per niente. Task.Wait()
casper:

Non ho detto nulla Wait(). Ma il codice in GetResults()verrà eseguito sul thread dell'interfaccia utente qui, non c'è nessun altro thread. In altre parole, sì, awaitfondamentalmente esegue l'attività, come dici tu, ma qui l'attività viene eseguita anche sul thread dell'interfaccia utente.
svick,

@svick Non c'è motivo di supporre che l'attività venga eseguita sul thread dell'interfaccia utente, perché lo fai? È possibile , ma improbabile. E la chiamata è composta da due chiamate UI separate, tecnicamente, una fino alla awaite quindi il codice dopo await, non c'è alcun blocco. Il resto del codice viene riportato in marshall in una continuazione e pianificato attraverso SynchronizationContext.
casper:

1
Per gli altri che vogliono vedere di più, vedi qui: chat.stackoverflow.com/rooms/17937 - @svick e sostanzialmente ci siamo fraintesi, ma dicevamo la stessa cosa.
casper:

3

Stephen Toub ha pubblicato questa AsyncManualResetEventlezione sul suo 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; 
        } 
    } 
}

0

Con estensioni reattive (Rx.Net)

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

var res = await eventObservable.FirstAsync();

È possibile aggiungere Rx con Nuget Package System.Reactive

Campione testato:

    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");
    }

0

Sto usando la mia classe AsyncEvent per eventi attendibili.

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;
    }
}

Per dichiarare un evento nella classe che genera eventi:

public AsyncEvent MyNormalEvent;
public AsyncEvent<ProgressEventArgs> MyCustomEvent;

Per aumentare gli eventi:

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

Per iscriversi agli eventi:

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

MyControl.Click += (sender, args) => {
    // synchronous code
    return Task.CompletedTask;
}

1
Hai completamente inventato un nuovo meccanismo di gestione degli eventi. Forse è questo che alla fine vengono tradotti i delegati in .NET, ma non possono aspettarsi che le persone lo adottino. Avere un tipo di ritorno per il delegato (dell'evento) stesso può scoraggiare le persone. Ma un buon sforzo, mi piace molto quanto è fatto bene.
nawfal,

@nawfal Grazie! L'ho modificato da allora per evitare di restituire un delegato. La fonte è disponibile qui come parte di Lara Web Engine, un'alternativa a Blazor.
cat_in_hat il
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.