Est-il possible d'attendre un événement au lieu d'une autre méthode asynchrone?


156

Dans mon application de métro C # / XAML, il y a un bouton qui lance un processus de longue durée. Donc, comme recommandé, j'utilise async / await pour m'assurer que le thread de l'interface utilisateur n'est pas bloqué:

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

Parfois, les choses qui se produisent dans GetResults nécessiteraient une entrée utilisateur supplémentaire avant de pouvoir continuer. Pour plus de simplicité, disons que l'utilisateur n'a qu'à cliquer sur un bouton "Continuer".

Ma question est: comment puis-je suspendre l'exécution de GetResults de telle manière qu'elle attend un événement tel que le clic d'un autre bouton?

Voici une manière moche de réaliser ce que je recherche: le gestionnaire d'événements pour le bouton "Continuer" définit un drapeau ...

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

... et GetResults l'interroge périodiquement:

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

Le sondage est clairement terrible (attente chargée / perte de cycles) et je recherche quelque chose d'événementiel.

Des idées?

Btw dans cet exemple simplifié, une solution serait bien sûr de diviser GetResults () en deux parties, d'appeler la première partie à partir du bouton de démarrage et la deuxième partie à partir du bouton Continuer. En réalité, ce qui se passe dans GetResults est plus complexe et différents types d'entrée utilisateur peuvent être requis à différents moments de l'exécution. Donc, diviser la logique en plusieurs méthodes ne serait pas trivial.

Réponses:


225

Vous pouvez utiliser une instance de la classe SemaphoreSlim comme signal:

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

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

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

Vous pouvez également utiliser une instance de la classe TaskCompletionSource <T> pour créer une tâche <T> qui représente le résultat du clic sur le bouton:

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)ne semble pas soutenir WaitAsync().
svick

3
@DanielHilgarth Non, vous ne pouvez pas. asyncne signifie pas «s'exécute sur un fil différent», ou quelque chose comme ça. Cela signifie simplement «vous pouvez utiliser awaitcette méthode». Et dans ce cas, le blocage à l'intérieur bloquerait en GetResults()fait le thread de l'interface utilisateur.
svick

2
@Gabe awaiten lui-même ne garantit pas qu'un autre thread est créé, mais il fait que tout le reste après l'instruction s'exécute comme une continuation sur le Taskou attendu que vous appelez await. Le plus souvent, il s'agit d' une sorte d'opération asynchrone, qui pourrait être l'achèvement d'E / S ou quelque chose qui se trouve sur un autre thread.
casperOne

16
+1. J'ai dû chercher cela, donc juste au cas où d'autres seraient intéressés: SemaphoreSlim.WaitAsyncne le pousse pas simplement Waitsur un thread de pool de threads. SemaphoreSlima une file d'attente appropriée de Tasks qui sont utilisées pour implémenter WaitAsync.
Stephen Cleary

14
TaskCompletionSource <T> + await .Task + .SetResult () s'avère être la solution parfaite pour mon scénario - merci! :-)
Max

75

Lorsque vous avez une chose inhabituelle dont vous avez besoin await, la réponse la plus simple est souvent TaskCompletionSource(ou une asyncprimitive activée en fonction de TaskCompletionSource).

Dans ce cas, votre besoin est assez simple, vous pouvez donc simplement utiliser TaskCompletionSourcedirectement:

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

Logiquement, TaskCompletionSourcec'est comme un async ManualResetEvent, sauf que vous ne pouvez "définir" l'événement qu'une seule fois et que l'événement peut avoir un "résultat" (dans ce cas, nous ne l'utilisons pas, donc nous définissons simplement le résultat sur null).


5
Puisque j'analyse "attendre un événement" comme fondamentalement la même situation que "envelopper EAP dans une tâche", je préférerais définitivement cette approche. À mon humble avis, c'est certainement du code plus simple / plus facile à raisonner.
James Manning

8

Voici une classe utilitaire que j'utilise:

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

Et voici comment je l'utilise:

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

// ... make it change ...

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

1
Je ne sais pas comment ça marche. Comment la méthode Listen exécute-t-elle de manière asynchrone mon gestionnaire personnalisé? Ne serait pas new Task(() => { });terminé instantanément?
nawfal

5

Classe d'assistance 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);
        };
}

Usage:

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

1
Comment nettoyez-vous l'abonnement example.YourEvent?
Denis P

@DenisP peut-être passer l'événement au constructeur pour EventAwaiter?
CJBrew

@DenisP J'ai amélioré la version et exécuté un court test.
Felix Keil

Je pourrais également voir ajouter IDisposable, selon les circonstances. De plus, pour éviter d'avoir à taper deux fois l'événement, nous pourrions également utiliser Reflection pour passer le nom de l'événement, de sorte que l'utilisation est encore plus simple. Sinon, j'aime bien le motif, merci.
Denis P

4

Idéalement, vous ne le faites pas . Bien que vous puissiez certainement bloquer le thread asynchrone, c'est un gaspillage de ressources et ce n'est pas idéal.

Prenons l'exemple canonique où l'utilisateur va déjeuner pendant que le bouton attend d'être cliqué.

Si vous avez arrêté votre code asynchrone en attendant l'entrée de l'utilisateur, cela ne fait que gaspiller des ressources pendant que ce thread est suspendu.

Cela dit, il est préférable que dans votre opération asynchrone, vous définissez l'état que vous devez maintenir au point où le bouton est activé et que vous «attendez» un clic. À ce stade, votre GetResultsméthode s'arrête .

Ensuite, lorsque le bouton est cliqué, basé sur l'état que vous avez enregistré, vous commencez une autre tâche asynchrone pour poursuivre les travaux.

Étant donné que le SynchronizationContextsera capturé dans le gestionnaire d'événements qui appelle GetResults(le compilateur le fera à la suite de l'utilisation du awaitmot - clé utilisé et du fait que SynchronizationContext.Current doit être non nul, étant donné que vous êtes dans une application d'interface utilisateur), vous peut utiliser async/await aimer ainsi:

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

ContinueToGetResultsAsyncest la méthode qui continue d'obtenir les résultats dans le cas où votre bouton est enfoncé. Si votre bouton n'est pas enfoncé, votre gestionnaire d'événements ne fait rien.


Quel thread asynchrone? Il n'y a pas de code qui ne fonctionnera pas sur le fil de discussion de l'interface utilisateur, à la fois dans la question d'origine et dans votre réponse.
svick

@svick Pas vrai. GetResultsrenvoie un Task. awaitdit simplement "exécutez la tâche, et lorsque la tâche est terminée, continuez le code après cela". Étant donné qu'il existe un contexte de synchronisation, l'appel est renvoyé au thread d'interface utilisateur, car il est capturé sur le await. awaitn'est pas le même que Task.Wait(), pas du tout.
casperOne

Je n'ai rien dit à propos de Wait(). Mais le code GetResults()s'exécutera sur le thread d'interface utilisateur ici, il n'y a pas d'autre thread. En d'autres termes, oui, awaitexécute essentiellement la tâche, comme vous le dites, mais ici, cette tâche s'exécute également sur le thread de l'interface utilisateur.
svick

@svick Il n'y a aucune raison de supposer que la tâche s'exécute sur le thread d'interface utilisateur, pourquoi faites-vous cette hypothèse? C'est possible , mais peu probable. Et l'appel se compose de deux appels d'interface utilisateur distincts, techniquement, un jusqu'au await, puis le code après await, il n'y a pas de blocage. Le reste du code est réorganisé dans une continuation et planifié via le SynchronizationContext.
casperOne

1
Pour les autres qui veulent en savoir plus, voir ici: chat.stackoverflow.com/rooms/17937 - @svick et moi nous sommes fondamentalement mal compris, mais je disais la même chose.
casperOne

3

Stephen Toub a publié ce AsyncManualResetEventcours sur son 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

Avec les extensions réactives (Rx.Net)

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

var res = await eventObservable.FirstAsync();

Vous pouvez ajouter Rx avec Nuget Package System.

Échantillon testé:

    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

J'utilise ma propre classe AsyncEvent pour les événements attendus.

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

Pour déclarer un événement dans la classe qui déclenche des événements:

public AsyncEvent MyNormalEvent;
public AsyncEvent<ProgressEventArgs> MyCustomEvent;

Pour élever les événements:

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

Pour vous abonner aux événements:

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

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

1
Vous avez complètement inventé un nouveau mécanisme de gestionnaire d'événements. C'est peut-être ce vers quoi les délégués dans .NET sont finalement traduits, mais on ne peut pas s'attendre à ce que les gens l'adoptent. Avoir un type de retour pour le délégué (de l'événement) lui-même peut décourager les gens au départ. Mais bon effort, j'aime vraiment la façon dont c'est bien fait.
nawfal

@nawfal Merci! Je l'ai modifié depuis pour éviter de renvoyer un délégué. La source est disponible ici dans le cadre de Lara Web Engine, une alternative à Blazor.
cat_in_hat
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.