Why is calling await completing the parent Task prematurely?

依然范特西╮ 提交于 2020-01-28 11:20:13

问题


I'm trying to create a control that exposes a DoLoading event that consumers can subscribe to in order to perform loading operations. For convenience, event handlers should be called from the UI thread allowing consumers to update the UI at will, but they will also be able to use async/await to perform long-running tasks without blocking the UI thread.

For this, I have declared the following delegate:

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

That allows consumers to subscribe to the event:

public event AsyncEventHandler<bool> DoLoading;

The idea is that consumers will subscribe to the event as so (this line is executed in the UI thread):

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

At an appropriate point in time, I'm getting a TaskScheduler for the UI thread and storing it in _uiScheduler.

The event is triggered when appropriate by the loader with the following line (this happens in a random thread):

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

Notice that this line is not called from the UI thread but needs to update the UI when loading is completed, so I'm using ContinueWith to execute code on the UI task scheduler when the loading task completes.

I've tried several variations of the following methods, none of which have worked, so here's where I'm at:

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

As you can see, I'm starting two tasks and expect my ContinueWith from before to execute one all of them complete.

The commandTask completes immediately, so it can be ignored for the moment. The eventHandlerTask, as I see it, should only complete one the event handler completes, given that I'm awaiting the call to the method that calls the event handler and I'm awaiting the event handler itself.

However, what's actually happening, is that the tasks are being completed as soon as the line await Task.Delay(1000) in my event handler is executed.

Why is this and how can I get the behaviour I expect?


回答1:


You correctly realized that StartNew() returns Task<Task> in this case, and you care about the inner Task (though I'm not sure why are you waiting for the outer Task before starting commandTask).

But then you return Task<Task> and ignore the inner Task. What you should do is to use await instead of return and change the return type of PerformLoadingActionAsync() to just Task:

await Task.WhenAll(evenHandlerTask, commandTask);

Few more notes:

  1. Using event handlers this way is quite dangerous, because you care about the Task returned from the handler, but if there are more handlers, only the last Task will be returned if you raise the event normally. If you really want to do this, you should call GetInvocationList(), which lets you invoke and await each handler separately:

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

    If you know that you'll never have more than one handler, you could use a delegate property that can be directly set instead of an event.

  2. If you have an async method or lambda that has the only await just before its return (and no finallys), then you don't need to make it async, just return the Task directly:

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



回答2:


First off, I recommend you reconsider the design of your "asynchronous event".

It is true that you can use a return value of Task, but it's more natural for C# event handlers to return void. In particular, if you have multiple subscriptions, the Task returned from handler(this, ...) is only the return value of one of the event handlers. To properly wait for all async events to complete, you'd need to use Delegate.GetInvocationList with Task.WhenAll when you raise the event.

Since you're already on the WinRT platform, I recommend you use "deferrals". This is the solution chosen by the WinRT team for asynchronous events, so it should be familiar to consumers of your class.

Unfortunately, the WinRT team did not include the deferral infrastructure in the .NET framework for WinRT. So I wrote a blog post about async event handlers and how to build a deferral manager.

Using a deferral, your event-raising code would look like this:

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

So that's my recommendation for a solution. The benefits of a deferral are that it enables both synchronous and asynchronous handlers, it's a technique already familiar to WinRT developers, and it correctly handles multiple subscribers without additional code.

Now, as to why the original code doesn't work, you can think this through by paying careful attention to all the types in your code and identifying what each task represents. Keep in mind the following important points:

  • Task<T> derives from Task. This means Task<Task> will convert down to Task without any warnings.
  • StartNew is not async-aware so it behaves differently than Task.Run. See Stephen Toub's excellent blog post on the subject.

Your OnDoLoading method will return a Task representing the completion of the last event handler. Any Tasks from other event handlers are ignored (as I mention above, you should use Delegate.GetInvocationList or deferrals to properly support multiple asynchronous handlers).

Now let's look at PerformLoadingActionAsync:

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

There's a lot going on in this statement. It's semantically equivalent to this (slightly simpler) line of code:

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

OK, so we are queueing up OnDoLoading to the UI thread. The return type of OnDoLoading is Task, so the return type of StartNew is Task<Task>. Stephen Toub's blog goes into the details of this kind of wrapping, but you can think of it like this: the "outer" task represents the start of the asynchronous OnDoLoading method (up until it has to yield at an await), and the "inner" task represents the completion of the asynchronous OnDoLoading method.

Next, we await the result of StartNew. This unwraps the "outer" task and we get a Task that represents the completion of OnDoLoading stored in evenHandlerTask.

return Task.WhenAll(evenHandlerTask, commandTask);

Now you're returning a Task that represents when both commandTask and evenHandlerTask have completed. However, you're in an async method, so your actual return type is Task<Task> - and it's the inner task that represents what you want. I think what you meant to do was:

await Task.WhenAll(evenHandlerTask, commandTask);

Which would give you a return type of Task, representing the full completion.

If you look at how it's called:

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

ContinueWith is acting on the outer Task in the original code, when you really wanted it to act on the inner Task.



来源:https://stackoverflow.com/questions/16416744/why-is-calling-await-completing-the-parent-task-prematurely

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!