能否等待事件而不是另一个异步方法?

208

在我的C#/XAML Metro应用程序中,有一个按钮启动了一个长时间运行的进程。所以,按照建议,我正在使用async/await确保UI线程不会被阻塞:

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

有时,在GetResults内发生的事情需要额外的用户输入才能继续。为简单起见,假设用户只需点击“继续”按钮。

我的问题是:我如何暂停GetResults的执行以等待另一个按钮的点击等事件发生?

这里有一种丑陋的方法可以实现我所期望的:继续”按钮的事件处理程序设置了一个标志...

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

...并且GetResults会定期轮询它:

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

这种轮询方式明显很糟糕(忙等/浪费CPU周期),我正在寻找一种基于事件的替代方案。

有任何想法吗?

顺便说一下,在这个简化的例子中,当然有一种解决方法就是将GetResults()拆分成两部分,从开始按钮调用第一部分,从继续按钮调用第二部分。在实际情况中,GetResults中发生的事情更加复杂,并且不同类型的用户输入可能需要在执行的不同阶段进行。因此,将逻辑拆分为多个方法并不容易。

11个回答

305
你可以使用 SemaphoreSlim 类 的实例作为一个信号:
private SemaphoreSlim signal = new SemaphoreSlim(0, 1);

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

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

或者,您可以使用 TaskCompletionSource<T> 实例创建一个 Task<T> 来表示按钮点击的结果:

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

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

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

9
ManualResetEvent(Slim) 似乎不支持 WaitAsync() - svick
3
不,你不能。async并不意味着“在不同的线程上运行”或类似的东西。它只是意味着“你可以在这个方法中使用await”。在这种情况下,在GetResults()内部阻塞实际上会阻塞UI线程。 - svick
2
我的观点是,这个问题中没有明确创建“Task”的代码,因此所有的代码都将在UI线程上运行。 - svick
18
+1. 我不得不查找这个,以防其他人感兴趣:SemaphoreSlim.WaitAsync 不仅仅是将 Wait 推送到线程池线程上。SemaphoreSlim 有一个适当的 Task 队列,用于实现 WaitAsync - Stephen Cleary
20
TaskCompletionSource<T> + await .Task + .SetResult() 对我的场景来说是完美的解决方案 - 谢谢! :-) - Max
显示剩余14条评论

94
当你需要等待一些不寻常的事情时,最简单的方法通常是使用TaskCompletionSource(或基于TaskCompletionSource的某些异步启用原语)。
在这种情况下,您的需求非常简单,因此可以直接使用TaskCompletionSource:
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);
}

逻辑上来说,TaskCompletionSource 就像是一个 async 版本的 ManualResetEvent,不同之处在于你只能“设置”一次事件,并且事件可以有一个“结果”(在这种情况下,我们没有使用它,所以只需将结果设置为 null)。


5
我将"await an event"解释为与"将EAP封装在任务中"基本相同的情况,因此我肯定更喜欢这种方法。在我看来,这绝对是代码更简单/更易理解的方法。 - James Manning
1
如果你对处理对象有疑问,可以参考同一作者的这里的答案。 - Christian Gollhardt

7

这是我使用的一个实用类:

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

以下是我如何使用它:

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

// ... make it change ...

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

1
我不知道这是如何工作的。Listen方法是如何异步执行我的自定义处理程序的?new Task(() => { });不会立即完成吗? - nawfal

5
理想情况下,您最好不要这样做。虽然您可以阻止异步线程,但这是一种资源浪费,也不理想。
考虑一个经典的例子:当用户去吃午饭时,按钮正在等待被点击。
如果您在等待用户输入时停止了异步代码,那么该线程在暂停期间只是浪费资源。
话虽如此,在异步操作中,最好设置需要维护到按钮启用并且您正在“等待”点击的状态。此时,您的GetResults方法将停止。
然后,当按钮被点击时,根据您存储的状态,您会启动另一个异步任务来继续工作。
由于在调用GetResults的事件处理程序中捕获了SynchronizationContext(编译器将执行此操作,因为使用了await关键字,并且SynchronizationContext.Current应该是非空的,因为您在UI应用程序中). 您可以这样使用async/await
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是一个方法,用于在按钮被按下时继续获取结果。如果您的按钮没有被按下,则您的事件处理程序不会执行任何操作。


什么是异步线程?在原问题和您的回答中,没有任何代码不会在UI线程上运行。 - svick
1
@svick 不是真的。GetResults 返回一个 Taskawait 只是说“运行任务,当任务完成后,继续执行此代码之后的内容”。鉴于存在同步上下文,调用被捕获在 await 上,因此会返回到 UI 线程。awaitTask.Wait() 完全不同。 - casperOne
@svick 基于什么准确地说?它的某些部分将运行,但在某个时刻,必须有一些等待的东西是基于Task的,然后那段代码将异步运行,而其他所有内容将继续在UI线程上进行,但在一个连续调用中。在异步任务之前和之后的这些点将在UI线程上,但会有一个后台操作(除非您做了像同步运行或其他边缘情况的事情),这就是阻塞被打破的地方。 - casperOne
@svick,你不能没有await而使用async,那么你到底在等待什么,它不是在其他线程上运行,也不是等待IO完成吗? - casperOne
1
对于想要了解更多的人,请参见此处:http://chat.stackoverflow.com/rooms/17937 - @svick和我基本上是互相误解,但我们说的是同一件事。 - casperOne
显示剩余3条评论

5

简单的帮助类:

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

使用方法:

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

1
你会如何清理对 example.YourEvent 的订阅? - Denis P
@DenisP 或许可以将事件传递到 EventAwaiter 的构造函数中? - CJBrew
@DenisP 我改进了版本并进行了简短的测试。 - Felix Keil
根据情况,我认为可以添加IDisposable。另外,为了避免重复输入事件,我们还可以使用反射传递事件名称,这样使用起来就更简单了。除此之外,我喜欢这种模式,谢谢。 - Denis P

3

Stephen Toub 在他的博客上发布了这个 AsyncManualResetEvent

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

博客的链接对我来说显示为403禁止访问,请检查一下。 - tsul
1
@tsul 已修复,谢谢。 - Drew Noakes

3

我正在使用自己的AsyncEvent类来实现可等待事件。

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

在声明一个引发事件的类中,需要使用以下语法:
public AsyncEvent MyNormalEvent;
public AsyncEvent<ProgressEventArgs> MyCustomEvent;

触发事件的方法:

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

要订阅事件:

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

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

2
你完全发明了一个新的事件处理机制。也许这就是 .NET 中委托最终被翻译成的东西,但不能指望人们采用这种方式。对于事件本身的委托有一个返回类型可能会让人们一开始就望而却步。但是非常感谢你的努力,真的很喜欢它的完成程度。 - nawfal
@nawfal 谢谢!我已经修改了它,以避免返回委托。源代码在这里可用(https://github.com/integrativesoft/lara/blob/master/src/LaraUI/Tools/AsyncEvent.cs),作为Lara Web Engine的一部分,这是Blazor的替代品。 - cat_in_hat

1
使用响应式扩展 (Rx.Net)
var eventObservable = Observable
            .FromEventPattern<EventArgs>(
                h => example.YourEvent += h,
                h => example.YourEvent -= h);

var res = await eventObservable.FirstAsync();

你可以使用Nuget Package System.Reactive添加Rx。
测试样例:
    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
这是我用于测试的一个类,支持CancellationToken。
此测试方法向我们展示了等待 ClassWithEvent MyEvent 实例被触发。
    public async Task TestEventAwaiter()
    {
        var cls = new ClassWithEvent();

        Task<bool> isRaisedTask = EventAwaiter<ClassWithEvent>.RunAsync(
            cls, 
            nameof(ClassWithEvent.MyMethodEvent), 
            TimeSpan.FromSeconds(3));

        cls.Raise();
        Assert.IsTrue(await isRaisedTask);
        isRaisedTask = EventAwaiter<ClassWithEvent>.RunAsync(
            cls, 
            nameof(ClassWithEvent.MyMethodEvent), 
            TimeSpan.FromSeconds(1));

        System.Threading.Thread.Sleep(2000);

        Assert.IsFalse(await isRaisedTask);
    }

这是事件等待器类。
public class EventAwaiter<TOwner>
{
    private readonly TOwner_owner;
    private readonly string _eventName;
    private readonly TaskCompletionSource<bool> _taskCompletionSource;
    private readonly CancellationTokenSource _elapsedCancellationTokenSource;
    private readonly CancellationTokenSource _linkedCancellationTokenSource;
    private readonly CancellationToken _activeCancellationToken;
    private Delegate _localHookDelegate;
    private EventInfo _eventInfo;

    public static Task<bool> RunAsync(
        TOwner owner,
        string eventName,
        TimeSpan timeout,
        CancellationToken? cancellationToken = null)
    {
        return (new EventAwaiter<TOwner>(owner, eventName, timeout, cancellationToken)).RunAsync(timeout);
    }
    private EventAwaiter(
        TOwner owner,
        string eventName,
        TimeSpan timeout,
        CancellationToken? cancellationToken = null)
    {
        if (owner == null) throw new TypeInitializationException(this.GetType().FullName, new ArgumentNullException(nameof(owner)));
        if (eventName == null) throw new TypeInitializationException(this.GetType().FullName, new ArgumentNullException(nameof(eventName)));

        _owner = owner;
        _eventName = eventName;
        _taskCompletionSource = new TaskCompletionSource<bool>();
        _elapsedCancellationTokenSource = new CancellationTokenSource();
        _linkedCancellationTokenSource =
            cancellationToken == null
                ? null
                : CancellationTokenSource.CreateLinkedTokenSource(_elapsedCancellationTokenSource.Token, cancellationToken.Value);
        _activeCancellationToken = (_linkedCancellationTokenSource ?? _elapsedCancellationTokenSource).Token;

        _eventInfo = typeof(TOwner).GetEvent(_eventName);
        Type eventHandlerType = _eventInfo.EventHandlerType;
        MethodInfo invokeMethodInfo = eventHandlerType.GetMethod("Invoke");
        var parameterTypes = Enumerable.Repeat(this.GetType(),1).Concat(invokeMethodInfo.GetParameters().Select(p => p.ParameterType)).ToArray();
        DynamicMethod eventRedirectorMethod = new DynamicMethod("EventRedirect", typeof(void), parameterTypes);
        ILGenerator generator = eventRedirectorMethod.GetILGenerator();
        generator.Emit(OpCodes.Nop);
        generator.Emit(OpCodes.Ldarg_0);
        generator.EmitCall(OpCodes.Call, this.GetType().GetMethod(nameof(OnEventRaised),BindingFlags.Public | BindingFlags.Instance), null);
        generator.Emit(OpCodes.Ret);
        _localHookDelegate = eventRedirectorMethod.CreateDelegate(eventHandlerType,this);
    }
    private void AddHandler()
    {
        _eventInfo.AddEventHandler(_owner, _localHookDelegate);
    }
    private void RemoveHandler()
    {
        _eventInfo.RemoveEventHandler(_owner, _localHookDelegate);
    }
    private Task<bool> RunAsync(TimeSpan timeout)
    {
        AddHandler();
        Task.Delay(timeout, _activeCancellationToken).
            ContinueWith(TimeOutTaskCompleted);

        return _taskCompletionSource.Task;
    }

    private void TimeOutTaskCompleted(Task tsk)
    {
        RemoveHandler();
        if (_elapsedCancellationTokenSource.IsCancellationRequested) return;

        if (_linkedCancellationTokenSource?.IsCancellationRequested == true)
            SetResult(TaskResult.Cancelled);
        else if (!_taskCompletionSource.Task.IsCompleted)
            SetResult(TaskResult.Failed);

    }

    public void OnEventRaised()
    {
        RemoveHandler();
        if (_taskCompletionSource.Task.IsCompleted)
        {
            if (!_elapsedCancellationTokenSource.IsCancellationRequested)
                _elapsedCancellationTokenSource?.Cancel(false);
        }
        else
        {
            if (!_elapsedCancellationTokenSource.IsCancellationRequested)
                _elapsedCancellationTokenSource?.Cancel(false);
            SetResult(TaskResult.Success);
        }
    }
    enum TaskResult { Failed, Success, Cancelled }
    private void SetResult(TaskResult result)
    {
        if (result == TaskResult.Success)
            _taskCompletionSource.SetResult(true);
        else if (result == TaskResult.Failed)
            _taskCompletionSource.SetResult(false);
        else if (result == TaskResult.Cancelled)
            _taskCompletionSource.SetCanceled();
        Dispose();

    }
    public void Dispose()
    {
        RemoveHandler();
        _elapsedCancellationTokenSource?.Dispose();
        _linkedCancellationTokenSource?.Dispose();
    }
}

它基本上依赖于CancellationTokenSource来报告结果。 它使用一些IL注入来创建与事件签名匹配的委托。 然后,使用一些反射将该委托添加为该事件的处理程序。 生成方法的主体只是调用EventAwaiter类上的另一个函数,然后使用CancellationTokenSource报告成功。

注意,不要直接在产品中使用此代码。这只是一个工作示例。

例如,IL生成是一个昂贵的过程。您应该避免重复生成相同的方法,而是缓存这些方法。


0

AsyncEx提供了AsyncManualResetEvent,您可以使用它来:

var signal = new AsyncManualResetEvent();
await signal.WaitAsync();

并通过以下方式触发:

signal.Set();

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接