在异步任务和UI线程中引发PropertyChanged

14

在许多博客、教程和MSDN上,我可以看到从非UI线程访问UI元素是不可能的 - 好的,我会得到一个未经授权的异常。为了测试它,我编写了一个非常简单的示例:

// simple text to which TextBlock.Text is bound
private string sample = "Starting text";
public string Sample
{
    get { return sample; }
    set { sample = value; RaiseProperty("Sample"); }
}

private async void firstButton_Click(object sender, RoutedEventArgs e)
{
    await Job(); // asynchronous heavy job
    commands.Add("Element"); // back on UI thread so this should be ok?
}

private async Task Job()
{
    // I'm not on UI Thread ?
    await Task.Delay(2000); // some other job
    Sample = "Changed";  // not ok as not UI thread?
    commands.Add("Element from async"); // also not ok?
}

我有一个异步运行的Task,我想在其中更改我的属性(这将引发PropertyChanged事件)并向ObservableCollection添加元素。由于它是以async方式运行的,所以我本不应该能够这样做,但我没有收到任何异常,并且代码可以正常工作。因此我有疑问和误解:

  • 为什么我没有得到异常?
  • async Task中引发PropertyChanged事件是否可以?
  • async Task中修改ObservableCollection是否可以,还是应该返回Task<ICollection>,在获取结果后修改ObservableCollection-清除并填充?
  • 何时在UI线程上运行Task,何时不在?
  • 在上面的代码中,在firstButton_Click中等待Task后管理UI元素是否正确?我总是回到UI线程吗?

为了进行更多测试,我将我的属性更改和集合修改放在了另一个线程中:

System.Threading.Timer newThreadTimer = new System.Threading.Timer((x) =>
   {
       Sample = "Changed";  // not UI thread - exception
       commands.Add("Element from async"); // not UI thread - exception
   }, null, 1000, Timeout.Infinite);

在上面的代码中,我认为我的思路是正确的-就在第一行或第二行后,我遇到了一个异常。但是第一个代码呢?难道我的Task只是幸运地在UI线程上运行吗?

我怀疑这是非常基础的东西,可能是我的误解,但我需要一些澄清,因此提出了这个问题。

2个回答

12
当等待一个Task时,当前线程的SynchronizationContext被捕获(特别是在Task的情况下由TaskAwaiter)。然后将连续体编组回到该SynchronizationContext以执行方法的其余部分(即await关键字后面的部分)。
让我们看一下您的代码示例:
private async Task Job()
{
    // I'm not on UI Thread ?
    await Task.Delay(2000); // some other job
    Sample = "Changed";  // not ok as not UI thread?
    commands.Add("Element from async"); // also not ok?
}

当你使用await Task.Delay(2000)时,编译器会隐式捕获当前的SynchronizationContext,这是你的WindowsFormsSynchronizationContext。当等待返回后,继续执行在同一上下文中,因为你没有显式地告诉它不要这样做,这就是你的UI线程。
如果你将代码更改为await Task.Delay(200).ConfigureAwait(false),那么继续将不会被马歇尔回到当前的SynchronizationContext,而是运行一个ThreadPool线程,导致你的UI元素更新抛出异常。
在你的计时器示例中,Elapsed事件通过ThreadPool线程引发,因此你会得到一个异常,表示你正在尝试更新由不同线程控制的元素。
现在,让我们逐个回答你的问题:
为什么我没有得到异常?
正如所说,await Task.Delay(2000)在UI线程上执行了Continuation,这使得更新你的控件成为可能。
在async Task中引发属性是否可以?
可以。
我不确定您所说的“Raise properties”是什么意思,但如果您的意思是触发INotifyPropertyChanged事件,那么可以在非UI线程上执行它们。
如果您有一个async方法并且想要更新UI绑定元素,请确保将继续操作调度到UI线程。如果该方法是从UI线程调用的并且您使用了await等待其结果,则继续操作将隐式地在UI线程上运行。如果您想通过Task.Run将工作卸载到后台线程,并确保您的继续操作在UI上运行,可以使用 TaskScheduler.FromCurrentSynchronizationContext()捕获您的SynchronizationContext并显式传递它给继续操作。
关于何时在UI线程上进行任务和何时不进行,这取决于您调用异步任务的方式和当前的线程环境。
一个 Task 是一个承诺,即将来会完成的工作。当您从 UI 线程上下文中 await 一个 TaskAwaitable 时,那么您仍在 UI 线程上运行。如果您不是在 UI 线程上,则如下:

  1. 您的异步方法当前是从与 UI 线程不同的线程(线程池线程或新线程)执行的。
  2. 您使用 Task.Run 将工作卸载到后台 ThreadPool 线程上。

在上面的代码中,在 firstButton_Click 中等待 Task 后管理 UI 元素是否可以?我总是回到 UI 线程吗?

只要您不明确告诉代码不要使用 ConfigureAwait(false) 返回其当前上下文,就会返回到 UI 线程。


如果不考虑这个声明:“任务并行库使用TaskScheduler来隐式地(或显式地…)捕获当前SynchronizationContext…”,我会点赞的。但对于'await'继续运行来说并不完全正确:https://dev59.com/I2Ag5IYBdhLWcg3w3uQR - noseratio - open to work
长篇回答(感谢您的回答),但我有一些疑问:1. 看到我的工作是异步运行的 await Job(); 从 Click - 因此捕获的 UI 上下文是在之前和之后而不是内部?2. 我对在同一线程上等待返回感到困惑 - 例如 我无法在 Mutex 中等待,因此在返回后,“捕获的上下文可能是任何线程池线程”。3. 如果从异步任务(在不同的线程上)引发属性更改,那么为什么不能使用计时器? - Romasz
2
@Romasz,你混淆了“异步/同步”和“并发”的相关概念。你编写的所有代码都是单线程的,这意味着你没有问题。初学者在使用“async/await”时常犯的错误是认为,“我们是异步的,那就意味着有线程对吧?线程意味着并发,在过去这是实现异步的唯一方式。” - Aron
1
也许我们应该在聊天室里继续讨论?http://chat.stackoverflow.com/rooms/51870/async-talk - Yuval Itzchakov
3
@Noseratio已做出更改,希望这次描述过程正确:) - Yuval Itzchakov
显示剩余6条评论

4
PropertyChanged事件会被WPF自动派发到UI线程,因此您可以从任何线程修改引发它的属性。在.NET 4.5之前,ObservableCollection.CollectionChanged事件不是这种情况,因此从非UI线程添加或删除元素将导致异常。
从.NET 4.5开始,CollectionChanged也会自动分派到UI线程,因此您不需要担心这个问题。尽管如此,您仍然需要从UI线程访问UI元素(例如按钮)。

2
更正一下,WPF总是通过绑定派遣到UI线程。问题在于它从未支持在UI线程之外更改集合。这通常表现为“CollectionViewSource”类不支持blah blah blah。 - Aron

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