线程安全的 ObservableImmutable Collection






4.86/5 (65投票s)
这些类应该可以一劳永逸地解决困扰集合类的多线程问题。
引言
我在网上偶然看到了一个网页,人们在上面投票对下一个.NET Framework进行增强。在该页面上,我注意到很多人都在投票希望有一种安全的多线程方式来分派 INotifyCollectionChanged
事件,特别是关于 ObservableCollection
。这让我想起我最初在此问题上经历的痛苦以及许多致力于解决它的文章,其中最早(如果不是第一个)的解决方案来自Bea Stollnitz(我喜欢她的博客)。我早已将这个问题抛诸脑后,因为我几年前就已经自己解决了,但完全忘记了它仍然像当时一样令人头疼,所以我决定将相关的类从我的库中提取出来与大家分享。
我的解决方案的好处是它很简单,而且没有我见过的许多其他解决方案的限制,例如在某些线程中无法修改集合、无法从其他UI线程绑定到集合、需要在内部维护两个集合并保持同步、或者需要传递Dispatcher等...我的解决方案允许您通过 *任何* 线程来修改集合,无论是拥有它的UI线程、不是拥有它的其他UI线程,还是后台线程。
请花时间阅读本文,以便您了解我打算解决的一些问题,并告诉我这个解决方案是否对您有帮助,以及您是否遇到了任何错误。另外,别忘了投票!
重大更新:在本文的先前版本中,我解决了导致臭名昭著的“This type of CollectionView ...”异常的问题,但我的工作并未真正完成。您看,还有其他原因导致您无法从另一个线程更新集合,这些原因会引发异常,而我尚未解决。一个主要问题是UI线程可能正在尝试显示您的集合,当它在屏幕上显示集合时,一个后台线程可能会删除一个项目而UI线程却不知道,当它尝试显示该项目时发现它不存在并抛出异常。另一个主要问题是两个或多个线程可能同时尝试添加或删除项目,最终导致集合损坏并引发异常。我现在也解决了这些其他问题。本文中我将主要处理的类是 ObservableImmutableList 类,因为这个类将取代您通常使用的 ObservableCollection 类。然而,我的库现在包含其他集合类(如 ObservableImmutableDictionary),它们具有相同的线程安全属性,并且现在也实现了 INotifyCollectionChanged。我会随着时间的推移继续扩展这个库。
背景
网上已经有大量文章深入介绍了这个问题,所以这里我只会简要概括一下。总而言之,问题在于,当 INotifyCollectionChanged
事件从非原始线程触发时,会引发臭名昭著的“This type of CollectionView does not support changes to its SourceCollection from a thread different from the Dispatcher thread.” 您可以通过创建一个 ObservableCollection
并将其绑定到一个 ItemsControl
/ListBox
/ListView
来自己测试这一点;请注意,这样做是在UI线程上完成的。然后,通过工作线程(例如通过 Timer
事件或 ThreadPool.QueueUserWorkItem
)向其中添加一个项目,您将收到一个异常。问题在于 NotifyCollectionChanged
事件(您的 ItemsControl
/ListBox
/ListView
会自动订阅该事件以确定是否向集合中添加或删除了项目)是从工作线程触发的,因为工作线程试图修改集合,而工作线程不是UI线程,因此会抛出异常。这个解释有点简化,但您能理解意思。
Using the Code
以简单方式解决“This type of CollectionView does not support changes to its SourceCollection from a thread different from the Dispatcher thread.” 问题的诀窍在于认识到问题只发生在通知端。如果我想收到事件通知,我该怎么做?我当然会订阅它。这就是诀窍所在;它发生在订阅过程中。
我所做的是覆盖 CollectionChanged
的订阅机制,这样每当集合发生任何更改时,它都会从相关线程触发。
protected virtual void OnCollectionChanged(NotifyCollectionChangedEventArgs args)
{
var notifyCollectionChangedEventHandler = CollectionChanged;
if (notifyCollectionChangedEventHandler == null)
return;
foreach (NotifyCollectionChangedEventHandler handler in notifyCollectionChangedEventHandler.GetInvocationList())
{
var dispatcherObject = handler.Target as DispatcherObject;
if (dispatcherObject != null && !dispatcherObject.CheckAccess())
{
dispatcherObject.Dispatcher.Invoke(DispatcherPriority.DataBind, handler, this, args);
}
else
handler(this, args); // note : this does not execute handler in target thread's context
}
}
正如您所见,我正在为每个事件处理程序获取与 DispatcherObject 关联的 DispatcherObject,如果它是UI线程,那么我将使用它来将调用 marshall 到UI线程;如果不是,那么它是一个后台线程,我将立即执行它。这就是修复这个臭名昭著的异常所需的一切。现在,为了解决其他线程安全问题,我需要确保1)对集合的更改是原子性的(也就是说,它们一次性发生,不会让您措手不及)和2)在NotifyCollectionChanged事件在每个线程上执行时,不允许发生任何进一步的更改。
解决问题#1的方法是将底层数据结构从 ObservableCollection 更改为 ImmutableList,并向其添加 Observable 功能。原因在于 ObservableCollection 包含许多在您不知情的情况下会改变的状态,而 ImmutableList 是不可变的,不会在您不知情的情况下改变;如果您需要向 ImmutableList 添加或删除项目,它会为您创建一个新的 ImmutableList(字符串的工作方式就是如此)。现在您可能会认为这会带来巨大的性能损失,但实际上它在后台做了一些非常巧妙的事情,所以速度相当快。
解决问题#2的方法是将集合的修改和 NotifyCollectionChanged 包装在一个锁中,以便对于发生的每一次更改,每个线程都会在任何进一步的更改发生之前立即得到同步通知。现在锁的问题在于它们很慢,但这与锁的主要问题相比算不了什么……如果一个后台线程获得了锁并继续修改集合,它将调用每个线程上的每个 NotifyCollectionChanged 事件。然而,如果它试图调用的UI线程正在等待(即阻塞)锁,那么您就会遇到死锁;也就是说,后台线程正在调用UI线程并等待它执行NotifyCollectionChanged事件,但UI线程正在阻塞后台线程获得的锁,因此无法执行任何操作,最终导致两个线程相互等待并永远卡住。为了解决速度问题,我允许用户通过(LockType 属性)指定他们是想使用 SpinWait 锁还是 Monitor 锁;SpinWait 是一种无锁锁,适用于 NotifyCollectionChanged 逻辑耗时很短的情况,而 Monitor 是一种锁,适用于 NotifyCollectionChanged 逻辑可能需要执行繁重工作的情况。无论哪种情况,您仍然会阻塞UI线程,所以我实现了一个专门用于UI的锁定机制,这样当它试图获取锁时,它仍然在处理消息;这是通过 Dispatcher 和 DispatcherFrame 实现的。
private void PumpWait_PumpUntil(Dispatcher dispatcher, Func<bool> condition)
{
var frame = new DispatcherFrame();
BeginInvokePump(dispatcher, frame, condition);
Dispatcher.PushFrame(frame);
}
private void BeginInvokePump(Dispatcher dispatcher, DispatcherFrame frame, Func<bool> condition)
{
dispatcher.BeginInvoke
(
DispatcherPriority.DataBind,
(Action)
(
() =>
{
frame.Continue = !condition();
if (frame.Continue)
BeginInvokePump(dispatcher, frame, condition);
}
)
);
}
当我在UI线程上执行以下任一调用时(取决于您指定的 LockType)
PumpWait_PumpUntil(dispatcher, () => Interlocked.CompareExchange(ref _lock, 1, 0) == 0);
PumpWait_PumpUntil(dispatcher, () => Monitor.TryEnter(_lockObj));
它将继续尝试获取锁,直到条件满足,在此过程中它将继续处理UI消息。它之所以能够做到这一点,是因为它在原始消息循环中创建了一个消息泵,该消息泵在获取锁的同时仍然可以处理消息。
需要注意的一个重要事项是,您再也不能使用您习惯使用的修改方法,如 Add、Remove 等,因为它们不是线程安全的。我提供了您应该使用的线程安全方法,称为 TryOperation 和 DoOperation。TryOperation 将尝试执行一次操作,如果成功,它将返回true,但如果由于其他线程正在尝试对其进行更改而无法获取锁,则返回false。DoOperation 的工作方式几乎相同,只是它会一直尝试直到成功。这是一个使用 DoOperation 删除项目的示例
items.DoOperation(currentItems => currentItems.Count > 0 ? currentItems.RemoveAt(0) : null);
这里我试图删除索引为 0 的项目,但其他线程可能也在尝试做同样的事情!TryOperation 和 DoOperation 都为我提供了应该执行所有修改的线程安全 ImmutableList,在这个例子中我将其命名为 'currentItems'。由于其他线程可能同时对集合进行操作,我需要检查 'current' 列表的项目数是否大于零,如果是,则可以安全地删除索引为零的项目。如果计数为零,则没有要删除的项目,我简单地返回 'null',这会告诉 DoOperation 取消操作。您实际上可以以线程安全的方式对 ImmutableList 进行任意多次更改
items.DoOperation(currentItems => currentItems.Clear().Add("Hello").Add("Cruel").Add("World").Remove("Cruel"));
有几个需要注意的地方:1)不要在 NotifyCollectionChanged 事件中修改集合!您可以正常读取它,只是不要尝试修改它,否则您将陷入地狱;相信我。2)VirtualizingStackPanel 与此类存在一些不兼容的问题,因此您无法使用它,抱歉;请用 StackPanel 替换它,就像我在 uxViewWindow 类中的 XAML 中所做的那样。如果有人能弄清楚 VirtualizingStackPanel 到底有什么问题导致我的计划泡汤,我将非常乐意听取。
附加的 Visual Studio 2012 解决方案包含一个测试项目,您可以在其中看到该类的实际运行情况。当您运行测试解决方案时,将出现主窗口。
主窗口由一个 ObservableImmutableList
、一个绑定到该集合的 ItemsControl
和一个用于生成同一数据的独立视图的按钮组成。如果您查看窗口标题,您将看到与窗口关联的线程 ID(即 UI 线程 ID)。主窗口有一个内部计时器,每 100 毫秒触发一次;每次触发时,它将从 UI 线程或后台线程向列表中添加、删除或更改一个字符串。在上方的快照中,您可以看到,尽管 ItemsControl
绑定到 Thread1
上的集合,但工作线程 5 和 6 都能毫无问题地向集合中添加项目。
使用 ObservableImmutableList 的另一个好处是,您不仅限于一个 UI 控件;您可以将 *多个* UI 控件绑定到它。您甚至可以使用 CollectionView
类从它获取自定义的 ICollectionView
实例!对于那些不了解视图的人来说,这个概念很简单:每个视图只是查看相同数据的另一种方式。例如,假设您有一个地址簿,其中包含您所有朋友的姓名、电话号码和地址。现在,假设您想通过三个窗口显示您的地址簿:一个窗口按姓氏排序显示,另一个窗口按州分组显示,还有一个窗口用于编辑地址簿。将第一个窗口的地址簿副本按姓氏排序,第二个窗口按州分组,第三个窗口的可编辑地址簿实例传递给它们,岂不是很麻烦?有了视图,就不必那样做,因为每个视图都将表示与源分离。然而,尝试所有您可以使用视图实现的很棒的东西是另一篇文章的主题,而不是我的演示的重点;我只想告诉您,我的 ObservableImmutableList 支持它并提供相同的多线程保护。
如果您点击“在单独的线程上打开视图”按钮,您将打开另一个完全位于不同线程上的窗口。点击此按钮两次,您将获得以下结果
您看到的是三个窗口,每个窗口都在完全不同的 UI 线程上,每个窗口都显示相同数据的不同视图。
正如您所看到的,计时器能够从后台线程或 UI 线程向 ObservableImmutableList 添加/删除/修改项目,而没有任何异常。
请随时随意拆解代码;希望这能为您节省数月痛苦的折磨。如果您有任何问题,请随时提问,别忘了在下方评价这篇文章!
关注点
在我撰写本文时,我偶然发现了 Muljadi Budiman 在 geekswithblogs 上发布的另一篇与我非常相似的解决方案;基本上是相同的方法,只是他从调用列表中获取 dispatcher,而我手动保存了它。我不得不承认,从调用列表中获取 dispatcher 的想法从未在我脑海中闪过,而且我自从更新了我的代码来使用他的方法后,代码行数减少了。您可以在此处找到他的文章 这里(注意:他的解决方案只解决了前面提到的臭名昭著的问题,并没有解决 ObservableCollection 本身的线程安全问题)。
历史
- 2010年3月11日:初次发布
- 2010年3月12日:添加了演示应用程序的相关快照和信息
- 2014年3月14日:重大更新,修复了剩余的线程安全问题。
- 2014年3月15日:添加了特定的 TryXXX 和 DoXXX 方法用于添加/插入/删除/设置功能。
- 2014年3月18日:重构了代码库,增强了方法以在可能的情况下生成特定的 NotifyCollectionChangedAction 以提高性能,并添加了 ObservableImmutableDictionary 类。
- 2014年8月14日:修复了无法通过索引属性绑定到集合元素的问题。例如:{Binding Path=[0]}