Czy można oczekiwać na zdarzenie zamiast innej metody asynchronicznej?


156

W mojej aplikacji Metro C # / XAML jest przycisk, który uruchamia długotrwały proces. Tak więc, zgodnie z zaleceniami, używam async / await, aby upewnić się, że wątek interfejsu użytkownika nie zostanie zablokowany:

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

Czasami rzeczy dziejące się w GetResults wymagały dodatkowego wkładu użytkownika, zanim będzie można je kontynuować. Dla uproszczenia powiedzmy, że użytkownik musi po prostu kliknąć przycisk „kontynuuj”.

Moje pytanie brzmi: jak mogę zawiesić wykonanie GetResults tak, aby czekało na zdarzenie, takie jak kliknięcie innego przycisku?

Oto brzydki sposób na osiągnięcie tego, czego szukam: moduł obsługi zdarzenia dla przycisku kontynuuj ”ustawia flagę ...

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

... a GetResults okresowo sonduje to:

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

Ankieta jest wyraźnie okropna (zajęte oczekiwanie / marnowanie cykli) i szukam czegoś opartego na wydarzeniach.

Jakieś pomysły?

Tak przy okazji, w tym uproszczonym przykładzie jednym rozwiązaniem byłoby oczywiście podzielenie GetResults () na dwie części, wywołanie pierwszej części z przycisku Start i drugiej części z przycisku Kontynuuj. W rzeczywistości rzeczy dziejące się w GetResults są bardziej złożone i różne typy danych wejściowych użytkownika mogą być wymagane w różnych punktach wykonywania. Tak więc rozbicie logiki na wiele metod byłoby nietrywialne.

Odpowiedzi:


225

Możesz użyć wystąpienia klasy SemaphoreSlim jako sygnału:

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

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

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

Alternatywnie można użyć wystąpienia klasy TaskCompletionSource <T>, aby utworzyć zadanie <T>, które reprezentuje wynik kliknięcia przycisku:

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)nie wydaje się wspierać WaitAsync().
svick

3
@DanielHilgarth Nie, nie mogłeś. asyncnie oznacza „działa w innym wątku” czy coś w tym rodzaju. Oznacza po prostu „możesz użyć awaitw tej metodzie”. W tym przypadku blokowanie wewnątrz GetResults()faktycznie zablokowałoby wątek interfejsu użytkownika.
svick

2
@ Gabe awaitsamo w sobie nie gwarantuje, że zostanie utworzony kolejny wątek, ale powoduje, że wszystko inne po instrukcji działa jako kontynuacja Tasklub oczekiwanie, które wywołujesz await. Częściej niż nie, to jest jakiś rodzaj operacji asynchronicznych, które mogłyby być zakończenie IO, lub coś, co jest w innym wątku.
casper 1

16
+1. Musiałem to sprawdzić, więc na wypadek, gdyby inni byli zainteresowani: SemaphoreSlim.WaitAsyncnie tylko wypycha Waitwątek na wątek puli wątków. SemaphoreSlimma odpowiednią kolejkę Tasks, które są używane do implementacji WaitAsync.
Stephen Cleary

14
TaskCompletionSource <T> + await .Task + .SetResult () okazuje się idealnym rozwiązaniem dla mojego scenariusza - dzięki! :-)
Max

75

Kiedy masz niezwykłą rzecz, którą musisz awaitwłączyć, często najłatwiejszą odpowiedzią jest TaskCompletionSource(lub asyncna podstawie jakiegoś prymitywu z włączoną funkcją TaskCompletionSource).

W takim przypadku twoja potrzeba jest dość prosta, więc możesz po prostu użyć TaskCompletionSourcebezpośrednio:

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

Logicznie rzecz biorąc, TaskCompletionSourcejest jak an async ManualResetEvent, z tą różnicą, że możesz „ustawić” zdarzenie tylko raz, a zdarzenie może mieć „wynik” (w tym przypadku go nie używamy, więc ustawiamy wynik na null).


5
Ponieważ analizuję „czekaj na zdarzenie” jako zasadniczo tę samą sytuację, co „zawijaj EAP w zadanie”, zdecydowanie wolałbym to podejście. IMHO, to zdecydowanie prostszy / łatwiejszy do zrozumienia kod.
James Manning

8

Oto klasa użyteczności, której używam:

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

A oto jak z tego korzystam:

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

// ... make it change ...

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

1
Nie wiem, jak to działa. W jaki sposób metoda Listen asynchronicznie wykonuje mój niestandardowy program obsługi? Nie new Task(() => { });zostałby natychmiast ukończony?
nawfal

5

Prosta klasa pomocnika:

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

Stosowanie:

var valueChangedEventAwaiter = new EventAwaiter<YourEventArgs>(
                            h => example.YourEvent += h,
                            h => example.YourEvent -= h);
await valueChangedEventAwaiter.Task;

1
Jak wyczyścisz subskrypcję example.YourEvent?
Denis P

@DenisP może przekazać zdarzenie do konstruktora dla EventAwaiter?
CJBrew

@DenisP Poprawiłem wersję i uruchomiłem krótki test.
Felix Keil

Mogłem również zobaczyć dodanie IDisposable, w zależności od okoliczności. Ponadto, aby uniknąć konieczności dwukrotnego wpisywania zdarzenia, moglibyśmy również użyć odbicia do przekazania nazwy zdarzenia, dzięki czemu użycie jest jeszcze prostsze. W przeciwnym razie podoba mi się wzór, dziękuję.
Denis P

4

Idealnie, nie . Chociaż z pewnością możesz zablokować wątek asynchroniczny, jest to marnotrawstwo zasobów i nie jest idealne.

Rozważmy kanoniczny przykład, w którym użytkownik idzie na obiad, podczas gdy przycisk czeka na kliknięcie.

Jeśli zatrzymałeś kod asynchroniczny podczas oczekiwania na dane wejściowe od użytkownika, po prostu marnujesz zasoby, gdy ten wątek jest wstrzymany.

To powiedziawszy, lepiej jest, jeśli w operacji asynchronicznej ustawisz stan, który musisz utrzymywać, do punktu, w którym przycisk jest włączony i „czekasz” na kliknięcie. W tym momencie Twoja GetResultsmetoda się zatrzymuje .

Wtedy, gdy przycisk zostanie kliknięty, na podstawie stanu, które są przechowywane, zaczynasz kolejny asynchroniczne zadanie kontynuowania prac.

Ponieważ SynchronizationContextzostanie przechwycony w programie obsługi zdarzeń, który wywołuje GetResults(kompilator zrobi to w wyniku użycia awaitużywanego słowa kluczowego i faktu, że SynchronizationContext.Current nie powinien mieć wartości null, biorąc pod uwagę, że jesteś w aplikacji interfejsu użytkownika), można użyć async/await tak:

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

ContinueToGetResultsAsyncto metoda, która nadal daje wyniki w przypadku naciśnięcia przycisku. Jeśli przycisk nie jest wciśnięty, program obsługi zdarzeń nic nie robi.


Jaki wątek asynchroniczny? Nie ma kodu, który nie będzie działał w wątku interfejsu użytkownika, zarówno w pierwotnym pytaniu, jak iw Twojej odpowiedzi.
svick

@svick Nieprawda. GetResultszwraca a Task. awaitpo prostu mówi „uruchom zadanie, a kiedy zadanie zostanie wykonane, kontynuuj kod po tym”. Biorąc pod uwagę, że istnieje kontekst synchronizacji, wywołanie jest kierowane z powrotem do wątku interfejsu użytkownika, ponieważ jest przechwytywane w await. awaitto nie to samo co Task.Wait(), w najmniejszym stopniu.
casperOne

Nic o tym nie mówiłem Wait(). Ale kod w GetResults()będzie działał w wątku interfejsu użytkownika tutaj, nie ma innego wątku. Innymi słowy, tak, awaitzasadniczo uruchamia zadanie, tak jak mówisz, ale tutaj to zadanie działa również w wątku interfejsu użytkownika.
svick

@svick Nie ma powodu, aby zakładać, że zadanie działa w wątku interfejsu użytkownika, dlaczego robisz takie założenie? Jest to możliwe , ale mało prawdopodobne. A wywołanie to dwa oddzielne wywołania interfejsu użytkownika, technicznie rzecz biorąc, jedno do, awaita następnie kod po nim await, nie ma blokowania. Reszta kodu jest przenoszona z powrotem w kontynuacji i planowana przez SynchronizationContext.
casperOne

1
Dla innych, którzy chcą zobaczyć więcej, zobacz tutaj: chat.stackoverflow.com/rooms/17937 - @svick i ja w zasadzie źle się zrozumieliśmy, ale mówiliśmy to samo.
casperOne

3

Stephen Toub opublikował te AsyncManualResetEventzajęcia na swoim blogu .

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

Z rozszerzeniami reaktywnymi (Rx.Net)

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

var res = await eventObservable.FirstAsync();

Możesz dodać Rx za pomocą Nuget Package System.Reactive

Badana próbka:

    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

Używam własnej klasy AsyncEvent dla oczekiwanych wydarzeń.

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

Aby zadeklarować zdarzenie w klasie, które wywołuje zdarzenia:

public AsyncEvent MyNormalEvent;
public AsyncEvent<ProgressEventArgs> MyCustomEvent;

Aby podnieść wydarzenia:

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

Aby zapisać się na wydarzenia:

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

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

1
Całkowicie wymyśliłeś nowy mechanizm obsługi zdarzeń. Może to jest to, co ostatecznie tłumaczą delegaci w .NET, ale nie można oczekiwać, że ludzie to przyjmą. Posiadanie typu zwracanego dla samego delegata (wydarzenia) może na początku zniechęcić ludzi. Ale dobry wysiłek, naprawdę podoba mi się, jak dobrze to jest zrobione.
nawfal

@nawfal Thanks! Od tego czasu zmodyfikowałem go, aby uniknąć powrotu delegata. Źródło jest dostępne tutaj jako część Lara Web Engine, alternatywy dla Blazor.
cat_in_hat,
Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.