【问题标题】:Efficient alternative for (unsubscribe from) events(取消订阅)事件的有效替代方案
【发布时间】:2020-12-16 00:53:06
【问题描述】:

在我的应用程序中,某些值可以随时更改。当这个值发生变化时,很多组件需要做一些事情。在应用程序使用过程中组件的数量会发生变化(取决于用户打开的内容)。目前这是通过事件实现的:当值发生变化时,会引发一个事件,所有感兴趣的组件都会在该事件上被订阅。

当然,为了避免内存泄漏,这些组件在 dispose 时取消订阅。

当我们使用大量组件(几百万个)对应用程序进行压力测试时,最大的瓶颈之一(在高负载期间 >90% 的 cpu 时间)是这些订阅:MyApp.ValueChanged -= TheValueChanged; 需要很长时间(几秒钟) .

我假设是这样,因为有很多订阅者,并且此取消订阅需要在订阅者列表中找到正确的订阅者(最坏的情况:在一百万个项目的列表中搜索一百万次)。

现在,我的问题是:有什么好的方法可以改善这一点?

我在考虑弱事件处理程序,所以取消订阅不是必需的,但可能有更好的解决方案。

【问题讨论】:

  • 创建一个静态布尔变量,您可以在事件中进行测试,并在变量为假时从事件中返回。
  • 您的所有订阅者是否同时取消订阅,或者这些订阅者在您的应用生命周期中的不同时刻取消订阅?
  • 您可以尝试自己实现事件的addremove 方法,以更符合您的使用模式。
  • @l33t GC 只在分配对象时运行,而不是在释放引用时运行。 GC 无法知道对象的所有引用是否在运行之前都已释放。如果它使用引用计数,您的评论将是正确的,但事实并非如此。而且 GC 执行时间通常会随着活动对象的数量而不是死对象的数量而变化。
  • @Servy 为什么要花时间来证明一个无效点?取消订阅事件肯定是一个会导致异常收集时间的用例。事实上,我想不出 .NET 中的内置模式会导致比这个操作更多的分配。 证明 here.

标签: c# performance events


【解决方案1】:

GC 是罪魁祸首。期间。

首先,强事件处理程序的默认实现并未针对许多侦听器进行优化。要获得良好的性能,请考虑显式实现事件处理程序。使用哈希集,我们可以改进内部使用平面列表的默认实现。

修复

对于您的活动,使用HashSet 实现addremove。请注意,此实现不是线程安全的。如果多个线程将使用该事件,您可能需要添加锁定机制。

class Publisher : INotifyPropertyChanged
{
    private HashSet<PropertyChangedEventHandler> propertyChangedHandlers =
        new HashSet<PropertyChangedEventHandler>();

    public event PropertyChangedEventHandler PropertyChanged
    {
        add => propertyChangedHandlers.Add(value);
        remove => propertyChangedHandlers.Remove(value);
    }

    public void Signal()
    {
        var args = new PropertyChangedEventArgs(null);
        foreach (var handler in propertyChangedHandlers)
        {
            handler(this, new PropertyChangedEventArgs(null));
        }
    }

    public void RemoveSubscribers(IEnumerable<Listener> listeners)
    {
        foreach (var listener in listeners)
        {
            listener.Subscribe(this);
        }
    }
}

这会表现得更好。但是为什么?当然,从哈希集中删除项目比遍历平面列表要快得多,但它并没有那么慢。极端的 CPU 时间实际上来自GC。取消订阅事件时,将重新分配处理程序列表。旧的列表很快就会被垃圾收集器收集起来。

分析

在您的情况下,您有 1,000,000 事件处理程序。因此,每次取消订阅事件处理程序时,都会取消引用 N 项目列表(其中 N 接近 1000000)并分配新的 N-1 处理程序列表。这样做 100 次,GC 将有大量数据要收集到内存中:

100 * 1000000 * 8 字节 = ~800 MB

我们可以使用GC.TryStartNoGCRegion API 轻松证明这一点。下面的示例尝试取消订阅 20 个事件处理程序,但由于此操作确实会分配超过请求的 128 MB,因此它将失败:

未处理的异常:System.InvalidOperationException:已分配 内存超出 NoGCRegion 模式的指定内存 System.GC.EndNoGCRegionWorker()

class Publisher : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    protected virtual void OnPropertyChanged(string propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}

class Listener
{
    public void Subscribe(Publisher publisher)
    {
        publisher.PropertyChanged += OnPropertyChanged;
    }

    public void Unsubscribe(Publisher publisher)
    {
        publisher.PropertyChanged -= OnPropertyChanged;
    }

    private static void OnPropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        Console.WriteLine($"OnPropertyChanged called for {nameof(Listener)} {sender}");
    }
}

class Program
{
    static void Main(string[] args)
    {
        var publisher = new Publisher();

        var listeners = Enumerable.Range(0, 1000000)
            .Select(p => new Listener())
            .ToList();

        foreach (var listener in listeners)
        {
            listener.Subscribe(publisher);
        }

        var watch = new System.Diagnostics.Stopwatch();
        watch.Start();

        const int toRemove = 20;

        if (GC.TryStartNoGCRegion(128L * 1024L * 1024L, true))
        {
            try
            {
                for (int i = 0; i < toRemove; i++)
                {
                    listeners[i].Unsubscribe(publisher);
                }
            }
            finally
            {
                watch.Stop();
                var time = watch.ElapsedMilliseconds;
                Console.WriteLine($"Removing {toRemove} handlers: {time} ms");
                GC.EndNoGCRegion();
            }
        }
    }
}

如果我们运行内存分析器,我们可以看到 删除 事件处理程序确实会导致分配和GC

更多详情请见MulticastDelegate.cs(360)

【讨论】:

    【解决方案2】:

    我找到了这个答案,这表明了另一个可能的改进:https://stackoverflow.com/a/24239037/1851717

    但是,一些实验使我决定采用我的第一个想法:使用弱事件管理器,完全跳过退订。我编写了一个自定义的弱事件管理器,因为我可以做一些额外的优化,利用一些应用程序特定的特性。

    因此,总的来说,总结一下 cmets 和解决方案,当您的事件退订成为瓶颈时,请检查以下事项:

    【讨论】:

    • 弱事件使用大约 10 倍的 RAM(每个侦听器 1 kB,我上次检查时)。你也失去了确定性行为;例如悬空事件处理程序(在某个时候由系统回收)。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2018-01-05
    • 1970-01-01
    • 2017-04-25
    • 1970-01-01
    • 2020-11-11
    相关资源
    最近更新 更多