如何通过工作线程更新ObservableCollection?

95

我有一个ObservableCollection<A> a_collection; 集合包含'n'个元素。每个元素A长这样:

public class A : INotifyPropertyChanged
{

    public ObservableCollection<B> b_subcollection;
    Thread m_worker;
}

基本上,它全部连接到一个WPF列表视图和显示所选项目的b_subcollection的详细信息视图控件(双向绑定、属性更改时更新等)。

当我开始实现线程时,问题出现了。整个想法是让整个a_collection使用它们的工作线程"做事",然后更新各自的b_subcollections,并使gui实时显示结果。

当我尝试时,我得到了一个异常,说只有Dispatcher线程才能修改ObservableCollection,并且工作停滞了。

有人能解释一下问题,以及如何解决它吗?


请尝试使用以下链接,该链接提供一个线程安全的解决方案,适用于任何线程,并可以通过多个UI线程绑定: http://www.codeproject.com/Articles/64936/Multithreaded-ObservableImmutableCollection - Anthony
7个回答

149

.NET 4.5的新选项

从.NET 4.5开始,有一种内置机制可以自动同步对集合的访问并将CollectionChanged事件分派到UI线程。要启用此功能,您需要从UI线程中调用BindingOperations.EnableCollectionSynchronization

EnableCollectionSynchronization会执行两个操作:

  1. 记住从中调用它的线程,并使数据绑定管道在该线程上调度CollectionChanged事件。
  2. 在处理了已调度的事件之前,锁定集合,以便在后台线程修改集合时运行UI线程的事件处理程序不会尝试读取集合。

非常重要的是,这并不能解决所有问题:为确保对本质上不线程安全的集合的线程安全访问,您必须与框架协作,即在集合将要被修改时,在后台线程中获取相同的锁。

因此,正确操作所需的步骤如下:

1. 决定要使用哪种锁定方式

这将确定必须使用哪个EnableCollectionSynchronization重载。大多数情况下,简单的lock语句就足够了,因此这个重载是标准选择,但如果您使用了一些花哨的同步机制,则还有支持自定义锁定

2. 创建集合并启用同步

根据所选锁定机制,在UI线程上调用相应的重载。如果使用标准的lock语句,您需要提供锁定对象作为参数。如果使用自定义同步,则需要提供CollectionSynchronizationCallback委托和上下文对象(可以是null)。当调用时,此委托必须获取您的自定义锁定,调用传递给它的Action并在返回之前释放锁定。

3. 在修改集合之前通过锁定协作

在您自己将要修改它时,还必须使用相同的机制对集合进行锁定; 在简单情况下,使用传递给EnableCollectionSynchronization的相同锁定对象进行锁定,或者在自定义情况下使用相同的自定义同步机制进行锁定。


2
这会导致集合更新阻塞,直到UI线程处理它们吗?在涉及不可变对象的单向数据绑定集合的情况下(这是一个相对常见的场景),似乎可以有一个集合类来保留每个对象的“最后显示版本”以及更改队列,并使用BeginInvoke运行一个方法,在UI线程中执行所有适当的更改[最多只有一个BeginInvoke在任何给定时间挂起。 - supercat
1
我不清楚这种方法如何比必要时调用Dispatcher更好。它们显然是不同的方法,都需要大约相同数量的额外代码 - 因此,解释为什么一个优于另一个将会很有帮助。从我的角度来看,似乎未调用分派程序会导致更可预测的失败(可以捕获和修复),而忘记同步对集合更改的访问可能会被忽略,直到遇到具有不同时间行为的环境。 - Kohanz
4
在UI线程分发程序有许多缺点。最大的一个是,在UI线程实际处理分发之前,您的集合不会被更新,然后你会在UI线程上运行,这可能会导致响应性问题。而另一种锁定方法则可以立即更新集合,并且可以继续在后台线程上进行处理,而不依赖于UI线程执行任何操作,UI线程将在下一个渲染周期中根据需要跟随更改。 - Mike Marynowski
2
我已经研究了4.5中的集合同步约一个月了,我认为这个答案有些不正确。该答案指出启用调用必须发生在UI线程上,并且回调发生在UI线程上。这两者似乎都不是事实。我能够在后台线程上启用集合同步并仍然利用此机制。此外,框架中的深层调用不执行任何调度(参见ViewManager.AccessCollection. https://referencesource.microsoft.com/#PresentationFramework/src/Framework/MS/Internal/Data/ViewManager.cs) 。 - Reginald Blue
3
这个帖子有关EnableCollectionSynchronization的回答里面包含更多深入的见解:https://dev59.com/fnLYa4cB1Zd3GeqPSgVN#16511740 - Matthew S
@supercat - “这会导致集合更新被阻塞,直到UI线程处理它们吗?” 不会。 它只是导致UI线程在其访问中包装 lock(YourLockObject) { ... }。 就像您在代码中所做的那样。 没有什么神秘的。 之所以这样做,而不是采用任何延迟更新集合的技术,是因为这种方式始终使集合保持最新状态。 - ToolmakerSteve

81
从技术上讲,问题并不在于您正在从后台线程更新ObservableCollection。问题在于当您这样做时,集合会在引起更改的同一线程上引发其CollectionChanged事件 - 这意味着控件正在从后台线程更新。
为了在控件绑定到集合时从后台线程填充集合,您可能需要从头开始创建自己的集合类型以解决此问题。但是,还有一个简单的选项可能适合您。
将Add调用发布到UI线程。
public static void AddOnUI<T>(this ICollection<T> collection, T item) {
    Action<T> addMethod = collection.Add;
    Application.Current.Dispatcher.BeginInvoke( addMethod, item );
}

...

b_subcollection.AddOnUI(new B());

这种方法会立即返回(在实际将项目添加到集合之前),然后在UI线程上,该项目将被添加到集合中,每个人都应该感到高兴。

然而,现实情况是,在重负载下,这种解决方案可能会变得缓慢,因为有太多的跨线程活动。更有效的解决方案是,将一堆项目批处理,并定期将它们发布到UI线程,以便您不必为每个项目调用跨线程。

BackgroundWorker类实现了一种模式,允许您通过其ReportProgress方法在后台操作期间报告进度。进度通过ProgressChanged事件在UI线程上报告。这也是您的另一个选择。


BackgroundWorker的runWorkerAsyncCompleted事件绑定到UI线程了吗? - Maciek
1
是的,BackgroundWorker 的设计方式是使用 SynchronizationContext.Current 来引发其完成和进度事件。DoWork 事件将在后台线程上运行。这是一篇关于 WPF 中线程的很好的文章,也讨论了 BackgroundWorker。http://msdn.microsoft.com/en-us/magazine/cc163328.aspx#S4 - Josh
5
感谢你分享这个简单而美妙的答案! - Beaker
在大多数情况下,后台线程不应该阻塞并等待UI更新。使用Dispatcher.Invoke会存在死锁的风险,如果两个线程最终互相等待,最好的情况也会显著地影响代码性能。在您特定的情况下,您可能需要以这种方式执行,但对于绝大多数情况,您的最后一句话是不正确的。 - Josh
这种方法最终会导致难以调试的并发问题和应用程序崩溃。 - Wouter

23

使用.NET 4.0,您可以使用以下一行命令:

.Add

Application.Current.Dispatcher.BeginInvoke(new Action(() => this.MyObservableCollection.Add(myItem)));

.Remove

Application.Current.Dispatcher.BeginInvoke(new Func<bool>(() => this.MyObservableCollection.Remove(myItem)));

假设Application.Current不为null。 - CBFT

15

为了后代而收集同步代码。这使用简单的锁机制来启用集合同步。请注意,您将需要在UI线程上启用集合同步。

public class MainVm
{
    private ObservableCollection<MiniVm> _collectionOfObjects;
    private readonly object _collectionOfObjectsSync = new object();

    public MainVm()
    {

        _collectionOfObjects = new ObservableCollection<MiniVm>();
        // Collection Sync should be enabled from the UI thread. Rest of the collection access can be done on any thread
        Application.Current.Dispatcher.BeginInvoke(new Action(() => 
        { BindingOperations.EnableCollectionSynchronization(_collectionOfObjects, _collectionOfObjectsSync); }));
    }

    /// <summary>
    /// A different thread can access the collection through this method
    /// </summary>
    /// <param name="newMiniVm">The new mini vm to add to observable collection</param>
    private void AddMiniVm(MiniVm newMiniVm)
    {
        lock (_collectionOfObjectsSync)
        {
            _collectionOfObjects.Insert(0, newMiniVm);
        }
    }
}

1

我使用了一个同步上下文:

SynchronizationContext SyncContext { get; set; }

// 在构造函数中:

SyncContext = SynchronizationContext.Current;

// 在后台工作器或事件处理程序中:

SyncContext.Post(o =>
{
    ObservableCollection.AddRange(myData);
}, null);

0

MicrosoftDocs

UI(布局、输入、引发事件等)的平台代码和您应用程序的 UI 代码都在同一 UI 线程上执行

ObservableCollection 在执行以下操作时会引发 CollectionChanged 事件:添加、删除、替换、移动、重置。而且此事件必须在 UI 线程上触发,否则调用线程将会抛出异常。

此类型的 CollectionView 不支持从与 Dispatcher 线程不同的线程更改其 SourceCollection。

这样 UI 将不会更新。

如果想要从后台线程更新 UI,请在应用程序的 dispatcher 中运行该代码。

Application.Current.Dispatcher.Invoke(() => {
    // update UI
});

-2

@Jon的回答很好,但缺少代码示例:

// UI thread
var myCollection = new ObservableCollection<string>();
var lockObject = new object();
BindingOperations.EnableCollectionSynchronization(myCollection, lockObject );

[..]

// Non UI thread
lock (lockObject) 
{
   myCollection.Add("Foo")
}

请注意,CollectionChanged 事件处理程序仍将从非 UI 线程调用。

1
@LadderLogic的回答已经在3年前提供了一个例子。 - Herman

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