为什么调用await过早地完成父任务?

本文关键字:任务 调用 await 为什么 | 更新日期: 2023-09-27 18:12:39

我正在尝试创建一个控件,该控件公开消费者可以订阅的DoLoading事件,以便执行加载操作。为了方便,事件处理程序应该从UI线程调用,允许消费者随意更新UI,但他们也可以使用async/await来执行长时间运行的任务,而不会阻塞UI线程。

为此,我声明了以下委托:

public delegate Task AsyncEventHandler<TEventArgs>(object sender, TEventArgs e);

允许消费者订阅事件:

public event AsyncEventHandler<bool> DoLoading;

这个想法是消费者将订阅事件(这一行在UI线程中执行):

loader.DoLoading += async (s, e) =>
            {
                for (var i = 5; i > 0; i--)
                {
                    loader.Text = i.ToString(); // UI update
                    await Task.Delay(1000); // long-running task doesn't block UI
                }
            };

在适当的时间点,我为UI线程获得TaskScheduler并将其存储在_uiScheduler中。

该事件在适当的时候由loader用以下行触发(这发生在一个随机线程中):

this.PerformLoadingActionAsync().ContinueWith(
            _ =>
            {
                // Other operations that must happen on UI thread
            },
            _uiScheduler);

请注意,这一行不是从UI线程调用的,而是需要在加载完成时更新UI,所以我使用ContinueWith在加载任务完成时执行UI任务调度器上的代码。

我尝试了以下几种方法,但没有一种有效,所以我在这里:

private async Task<Task> PerformLoadingActionAsync()
{
    TaskFactory uiFactory = new TaskFactory(_uiScheduler);
    // Trigger event on the UI thread and await its execution
    Task evenHandlerTask = await uiFactory.StartNew(async () => await this.OnDoLoading(_mustLoadPreviousRunningState));
    // This can be ignored for now as it completes immediately
    Task commandTask = Task.Run(() => this.ExecuteCommand());
    return Task.WhenAll(evenHandlerTask, commandTask);
}
private async Task OnDoLoading(bool mustLoadPreviousRunningState)
{
    var handler = this.DoLoading;
    if (handler != null)
    {
        await handler(this, mustLoadPreviousRunningState);
    }
}

如您所见,我正在启动两个任务,并期望之前的ContinueWith执行一个所有完成。

commandTask立即完成,因此可以暂时忽略它。eventHandlerTask,正如我所看到的,应该只完成一个事件处理程序完成,因为我正在等待调用事件处理程序的方法的调用,我正在等待事件处理程序本身。

然而,实际发生的情况是,只要我的事件处理程序中的await Task.Delay(1000)行被执行,任务就完成了。

为什么会这样,我怎样才能得到我所期望的行为?

为什么调用await过早地完成父任务?

您正确地意识到StartNew()在这种情况下返回Task<Task>,并且您关心内部Task(尽管我不确定为什么在开始commandTask之前等待外部Task)。

然后返回Task<Task>而忽略内部的Task。您应该做的是使用await而不是return,并将PerformLoadingActionAsync()的返回类型更改为Task:

await Task.WhenAll(evenHandlerTask, commandTask);

注释:

  1. 以这种方式使用事件处理程序是相当危险的,因为您关心从处理程序返回的Task,但是如果有更多处理程序,如果您正常引发事件,则只会返回最后一个Task。如果您真的想这样做,您应该调用GetInvocationList(),它允许您分别调用和await每个处理程序:

    private async Task OnDoLoading(bool mustLoadPreviousRunningState)
    {
        var handler = this.DoLoading;
        if (handler != null)
        {
            var handlers = handler.GetInvocationList();
            foreach (AsyncEventHandler<bool> innerHandler in handlers)
            {
                await innerHandler(this, mustLoadPreviousRunningState);
            }
        }
    }
    

    如果你知道你永远不会有多个处理程序,你可以使用一个可以直接设置的委托属性来代替事件。

  2. 如果你有一个async方法或lambda,只有await就在它的return之前(没有finally s),那么你不需要使它成为async,只需直接返回Task:

    Task.Factory.StartNew(() => this.OnDoLoading(true))
    

首先,我建议您重新考虑"异步事件"的设计。

确实可以使用返回值Task,但是c#事件处理程序返回void更自然。特别是,如果您有多个订阅,那么从handler(this, ...)返回的Task只是事件处理程序中一个的返回值。为了正确地等待所有异步事件完成,在引发事件时需要使用Delegate.GetInvocationListTask.WhenAll

既然你已经使用了WinRT平台,我建议你使用"延迟"。这是WinRT团队为异步事件选择的解决方案,因此您的类的消费者应该熟悉它。

不幸的是,WinRT团队并没有在WinRT的。net框架中包含延迟基础设施。所以我写了一篇关于异步事件处理程序和如何构建延迟管理器的博文。

使用延迟,您的事件引发代码看起来像这样:

private Task OnDoLoading(bool mustLoadPreviousRunningState)
{
  var handler = this.DoLoading;
  if (handler == null)
    return;
  var args = new DoLoadingEventArgs(this, mustLoadPreviousRunningState);
  handler(args);
  return args.WaitForDeferralsAsync();
}
private Task PerformLoadingActionAsync()
{
  TaskFactory uiFactory = new TaskFactory(_uiScheduler);
  // Trigger event on the UI thread.
  var eventHandlerTask = uiFactory.StartNew(() => OnDoLoading(_mustLoadPreviousRunningState)).Unwrap();
  Task commandTask = Task.Run(() => this.ExecuteCommand());
  return Task.WhenAll(eventHandlerTask, commandTask);
}

这就是我建议的解决方案。延迟的好处是它支持同步和异步处理程序,这是WinRT开发人员已经熟悉的一种技术,并且它可以正确地处理多个订阅者,而无需额外的代码。

现在,至于为什么原始代码不起作用,您可以通过仔细关注代码中的所有类型并确定每个任务代表什么来思考这个问题。请记住以下要点:

  • Task<T>源自Task。这意味着Task<Task>将在没有任何警告的情况下转换为Task
  • StartNew不是async感知的,所以它的行为与Task.Run不同。参见Stephen Toub关于这个主题的优秀博客文章。

你的OnDoLoading方法将返回一个表示最后一个事件处理程序完成的Task。任何来自其他事件处理程序的Task都会被忽略(正如我在上面提到的,您应该使用Delegate.GetInvocationList或延迟来正确支持多个异步处理程序)。

现在让我们看看PerformLoadingActionAsync:

Task evenHandlerTask = await uiFactory.StartNew(async () => await this.OnDoLoading(_mustLoadPreviousRunningState));

这个语句中有很多内容。它在语义上等同于下面这行(稍微简单一点)代码:

Task evenHandlerTask = await uiFactory.StartNew(() => OnDoLoading(_mustLoadPreviousRunningState));

好的,所以我们将OnDoLoading排队到UI线程。因为OnDoLoading的返回类型是Task,所以StartNew的返回类型是Task<Task>。Stephen Toub的博客详细介绍了这种包装,但您可以这样考虑:"外部"任务表示异步OnDoLoading方法的开始(直到它不得不在await处屈服),而"内部"任务表示异步OnDoLoading方法的完成

下一步,我们await StartNew的结果。这将打开"外部"任务,我们得到一个Task,它表示存储在evenHandlerTask中的OnDoLoading的完成。

return Task.WhenAll(evenHandlerTask, commandTask);

现在你返回一个Task,它表示commandTaskevenHandlerTask都完成了。然而,你是在一个async方法中,所以你的实际返回类型是Task<Task>——它是代表你想要的内部任务。我想你的意思是:

await Task.WhenAll(evenHandlerTask, commandTask);

返回类型为Task,表示完全完成。

如果你看看它是怎么命名的:

this.PerformLoadingActionAsync().ContinueWith(...)

ContinueWith在原始代码中作用于外部 Task,当您真正希望它作用于内部 Task时。