问题
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:
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 lastTask
will be returned if you raise the event normally. If you really want to do this, you should call GetInvocationList(), which lets you invoke andawait
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.
If you have an
async
method or lambda that has the onlyawait
just before itsreturn
(and nofinally
s), then you don't need to make itasync
, just return theTask
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 fromTask
. This meansTask<Task>
will convert down toTask
without any warnings.StartNew
is notasync
-aware so it behaves differently thanTask.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 Task
s 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