有没有办法解决WPF除了反射之外调用GC.Collect(2)?

Kir*_*oll 46 c# reflection wpf garbage-collection

我最近不得不将这个怪物检入生产代码来操作WPF类中的私有字段:( tl; dr我如何避免这样做?)

private static class MemoryPressurePatcher
{
    private static Timer gcResetTimer;
    private static Stopwatch collectionTimer;
    private static Stopwatch allocationTimer;
    private static object lockObject;

    public static void Patch()
    {
        Type memoryPressureType = typeof(Duration).Assembly.GetType("MS.Internal.MemoryPressure");
        if (memoryPressureType != null)
        {
            collectionTimer = memoryPressureType.GetField("_collectionTimer", BindingFlags.Static | BindingFlags.NonPublic)?.GetValue(null) as Stopwatch;
            allocationTimer = memoryPressureType.GetField("_allocationTimer", BindingFlags.Static | BindingFlags.NonPublic)?.GetValue(null) as Stopwatch;
            lockObject = memoryPressureType.GetField("lockObj", BindingFlags.Static | BindingFlags.NonPublic)?.GetValue(null);

            if (collectionTimer != null && allocationTimer != null && lockObject != null)
            {
                gcResetTimer = new Timer(ResetTimer);
                gcResetTimer.Change(TimeSpan.Zero, TimeSpan.FromMilliseconds(500));
            }
        }                
    }       

    private static void ResetTimer(object o)
    {
        lock (lockObject)
        {
            collectionTimer.Reset();
            allocationTimer.Reset();
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

要理解为什么我会这么疯狂,你需要看一下MS.Internal.MemoryPressure.ProcessAdd():

/// <summary>
/// Check the timers and decide if enough time has elapsed to
/// force a collection
/// </summary>
private static void ProcessAdd()
{
    bool shouldCollect = false;

    if (_totalMemory >= INITIAL_THRESHOLD)
    {
        // need to synchronize access to the timers, both for the integrity
        // of the elapsed time and to ensure they are reset and started
        // properly
        lock (lockObj)
        {
            // if it's been long enough since the last allocation
            // or too long since the last forced collection, collect
            if (_allocationTimer.ElapsedMilliseconds >= INTER_ALLOCATION_THRESHOLD
                || (_collectionTimer.ElapsedMilliseconds > MAX_TIME_BETWEEN_COLLECTIONS))
            {
                _collectionTimer.Reset();
                _collectionTimer.Start();

                shouldCollect = true;
            }
            _allocationTimer.Reset();
            _allocationTimer.Start();
        }

        // now that we're out of the lock do the collection
        if (shouldCollect)
        {
            Collect();
        }
    }

    return;
}
Run Code Online (Sandbox Code Playgroud)

重要的一点接近结尾,它调用方法Collect():

private static void Collect()
{
    // for now only force Gen 2 GCs to ensure we clean up memory
    // These will be forced infrequently and the memory we're tracking
    // is very long lived so it's ok
    GC.Collect(2);
}
Run Code Online (Sandbox Code Playgroud)

是的,这是WPF实际上强制第二代垃圾收集,这会强制完全阻止GC. 一个自然发生的GC在第2代堆上没有阻塞地发生.这在实践中意味着无论何时调用此方法,我们的整个应用程序都会锁定.您的应用程序使用的内存越多,您的第2代堆的碎片越多,所需的时间就越长.我们的应用程序目前缓存相当多的数据并且可以轻松占用大量内存,强制GC可以在慢速设备上锁定我们的应用程序几秒钟 - 每850 MS.

尽管作者的抗议恰恰相反,但很容易得出这种方法被频繁调用的情况.BitmapSource从文件加载时会发生WPF的内存代码.我们使用数千个项目虚拟化列表视图,其中每个项目由存储在磁盘上的缩略图表示.当我们向下滚动时,我们将动态加载这些缩略图,并且GC以最大频率发生.因此,随着应用程序不断锁定,滚动变得令人难以置信地缓慢而且波涛汹涌.

有了可怕的反射黑客,我提到了最高层,我们强制永远不会遇到计时器,因此WPF从不强制GC.此外,似乎没有不良后果 - 内存随着滚动而增长,最终GC自然触发而不会锁定主线程.

是否还有其他选项可以阻止这些调用,GC.Collect(2)这不像我的解决方案那么可怕?很想得到一个解释,说明通过这个黑客可能会产生的具体问题.我的意思是避免呼叫的问题GC.Collect(2).(在我看来GC自然应该是足够的)

Luc*_*ski 11

注意:只有当它导致你的应用程序出现瓶颈时才这样做,并确保你了解后果 - 请参阅Hans的答案,以便首先解释为什么他们将这个问题放在WPF中.

你有一些讨厌的代码试图在框架中修复一个令人讨厌的黑客...因为它是静态的并且从WPF中的多个地方调用,你实际上不能比使用反射来打破它更好(其他解决方案会更糟糕).

所以不要指望有一个干净的解决方案.除非他们更改WPF代码,否则不存在这样的事情.

但我认为你的黑客可能更简单,并避免使用计时器:只是破解_totalMemory价值,你就完成了.这是一个long,这意味着它可以转向负值.那个非常大的负面价值.

private static class MemoryPressurePatcher
{
    public static void Patch()
    {
        var memoryPressureType = typeof(Duration).Assembly.GetType("MS.Internal.MemoryPressure");
        var totalMemoryField = memoryPressureType?.GetField("_totalMemory", BindingFlags.Static | BindingFlags.NonPublic);

        if (totalMemoryField?.FieldType != typeof(long))
            return;

        var currentValue = (long) totalMemoryField.GetValue(null);

        if (currentValue >= 0)
            totalMemoryField.SetValue(null, currentValue + long.MinValue);
    }
}
Run Code Online (Sandbox Code Playgroud)

在这里,现在你的应用程序必须在调用之前分配大约8艾字节GC.Collect.不用说,如果发生这种情况,你将面临更大的问题需要解决.:)

如果您担心下溢的可能性,请使用long.MinValue / 2偏移量.这仍然留下4艾字节.

请注意,它AddToTotal实际上执行边界检查_totalMemory,但它在Debug.Assert 这里执行:

Debug.Assert(newValue >= 0);
Run Code Online (Sandbox Code Playgroud)

由于您将使用.NET Framework的发行版本,这些断言将被禁用(使用a ConditionalAttribute),因此无需担心这一点.


您已经问过这种方法可能会出现什么问题.让我们来看看.

  • 最明显的一个:MS改变你试图破解的WPF代码.

    那么,在这种情况下,它几乎取决于变化的性质.

    • 他们更改了类型名称/字段名称/字段类型:在这种情况下,将不会执行黑客攻击,并且您将回到库存行为.反射代码非常具有防御性,它不会抛出异常,它只是不会做任何事情.

    • 它们将Debug.Assert调用更改为在发布版本中启用的运行时检查.在这种情况下,您的应用程序注定失败.任何从磁盘加载图像的尝试都会抛出.哎呀.

      他们自己的代码几乎是一个黑客的事实可以减轻这种风险.他们不打算扔,它应该被忽视.他们希望它安静地坐着,默默地失败.让图像加载是一个非常重要的功能,不应该受到某些内存管理代码的影响,其唯一目的是将内存使用量降至最低.

    • 对于OP中的原始补丁,如果他们更改常量值,您的黑客可能会停止工作.

    • 他们在保持类和字段完整的同时更改算法.嗯......任何事都可能发生,取决于变化.

  • 现在,让我们假设黑客工作并GC.Collect成功禁用了呼叫.

    在这种情况下,明显的风险是增加内存使用量.由于收集频率较低,因此将在给定时间分配更多内存.这应该不是一个大问题,因为当gen 0填满时,收集仍然会自然发生.

    你也会有更多的内存碎片,这是收集更少的直接后果.这可能会可能不会成为你的问题-所以配置您的应用程序.

    较少的集合也意味着更少的对象被提升到更高的一代.这是一个很好的事情.理想情况下,你应该在gen 0中拥有短暂的对象,在gen 2中拥有长寿命的对象.频繁的集合实际上会导致短暂的对象被提升为gen 1然后再提升到gen 2,你最终会得到gen 2中的许多无法访问的对象.这些只会使用gen 2集合进行清理,会导致堆碎,并且实际上会增加GC时间,因为它必须花费更多时间来压缩堆.这实际上是为什么称GC.Collect自己被认为是一种不好的做法的主要原因- 你正在积极地击败GC策略,这会影响整个应用程序.

在任何情况下,正确的方法是加载图像,缩小图像并在UI中显示这些缩略图.所有这些处理都应该在后台线程中完成.在JPEG图像的情况下,加载嵌入的缩略图 - 它们可能足够好.并且使用对象池,因此您不需要每次都实例化新的位图,这完全绕过了MemoryPressure类问题.是的,这正是其他答案所暗示的;)


Han*_*ant 10

我觉得你有什么就好了.干得好,很好的黑客,反射是一个很棒的工具来修复不稳定的框架代码.我自己多次使用过它.只是将其使用限制在显示ListView的视图中,使其始终处于活动状态是非常危险的.

关于潜在问题的一点点,可怕的ProcessAdd()hack当然非常粗糙.这是BitmapSource没有实现IDisposable的结果.一个可疑的设计决策,SO充满了关于它的问题.然而,关于所有这些都是相反的问题,这个计时器不够快,无法跟上.它只是不能很好地工作.

您无法更改此代码的工作方式.它解决的值是const声明.基于15年前可能适用的值,此代码的可能年龄.它从1兆字节开始,称"10s of MB"是一个问题,生活变得简单了:)他们忘记写它以便它正确扩展,GC.AddMemoryPressure()今天可能会很好.太晚了,他们无法在不显着改变程序行为的情况下解决这个问题.

你当然可以打败计时器,避免你的黑客入侵.当然,你现在遇到的问题是它的Interval与用户在没有读取任何东西但只是试图找到感兴趣的记录时滚动ListView的速度大致相同.这是一个UI设计问题,这个问题在包含数千行的列表视图中非常常见,这是您可能不想解决的问题.您需要做的是缓存缩略图,收集您知道接下来可能需要的缩略图.最好的方法是在线程池线程中执行此操作.在执行此操作时测量时间,您可以花费850毫秒.然而,该代码不会比现在的代码小,也不会更漂亮.


Alo*_*aus 9

.NET 4.6.2将通过一起杀死MemoryPressure类来修复它.我刚检查了预览,我的UI挂起完全消失了.

.NET 4.6实现它

internal SafeMILHandleMemoryPressure(long gcPressure)
{
    this._gcPressure = gcPressure;
    this._refCount = 0;
    GC.AddMemoryPressure(this._gcPressure);
}
Run Code Online (Sandbox Code Playgroud)

而在.NET 4.6.2之前你有这个粗糙的MemoryPressure类,它会强制GC.Collect每隔850ms(如果在没有分配WPF位图之间)或每30秒强制你分配多少WPF位图.

作为参考,旧手柄的实现就像

internal SafeMILHandleMemoryPressure(long gcPressure)
{
    this._gcPressure = gcPressure;
    this._refCount = 0;
    if (this._gcPressure > 8192L)
    {
        MemoryPressure.Add(this._gcPressure);   // Kills UI interactivity !!!!!
        return;
    }
    GC.AddMemoryPressure(this._gcPressure);
}
Run Code Online (Sandbox Code Playgroud)

这会产生巨大的差异,因为您可以看到GC挂起时间在我编写的一个简单的测试应用程序中显着下降以重现问题. 在此输入图像描述

在这里,您可以看到GC悬浮时间从2,71秒下降到0.86秒.即使对于多GB管理堆,这仍然几乎保持不变.这也提高了整体应用程序性能,因为现在后台GC可以在应有的位置完成工作:在后台.这可以防止所有托管线程的突然停止,尽管GC正在清理,但这些线程可以继续快乐地工作.没有多少人知道GC为他们提供了什么样的背景,但是这会产生真正的世界差异.普通应用程序工作负载的10-15%.如果您有一个多GB托管应用程序,其中完整的GC可能需要几秒钟,您会发现一个显着的改进.在一些测试中,应用程序有内存泄漏(5GB托管堆,完整GC暂停时间7s)我确实看到由于这些强制GC导致35s UI延迟!


cok*_*n19 6

关于使用反思方法可能遇到的具体问题的更新问题,我认为@HansPassant对您的具体方法的评估是彻底的.但更一般地说,使用当前方法运行的风险与使用任何反映您不拥有的代码的风险相同; 它可以在下一次更新中改变你的下方.只要你对此感到满意,你所拥有的代码应该具有可忽略的风险.

为了回答原始问题,可能有办法GC.Collect(2)通过最小化BitmapSource操作数来解决问题.下面是一个示例应用程序,说明了我的想法.与您描述的类似,它使用虚拟化ItemsControl来显示磁盘的缩略图.

虽然可能有其他人,但主要关注点是如何构建缩略图图像.该应用程序WriteableBitmap预先创建对象缓存.当UI请求列表项时,它从磁盘读取图像,使用a BitmapFrame来检索图像信息,主要是像素数据.甲WriteableBitmap对象是从缓存拉动时,它的像素数据被覆盖,则它被分配到该视图的模型.由于现有列表项不在视图范围内并被回收,因此将WriteableBitmap对象返回到高速缓存以供以后重用.BitmapSource在整个过程中发生的唯一相关活动是从磁盘实际加载图像.

值得注意的是,该GetBitmapImageBytes()方法返回的图像必须WriteableBitmap缓存中的图像完全相同才能使用此像素覆盖方法; 目前为256 x 256.为简单起见,我在测试中使用的位图图像已经达到这个尺寸,但根据需要实现缩放应该是微不足道的.

MainWindow.xaml:

<Window x:Class="VirtualizedListView.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="500" Width="500">
    <Grid>
        <ItemsControl VirtualizingStackPanel.IsVirtualizing="True"
                      VirtualizingStackPanel.VirtualizationMode="Recycling"
                      VirtualizingStackPanel.CleanUpVirtualizedItem="VirtualizingStackPanel_CleanUpVirtualizedItem"
                      ScrollViewer.CanContentScroll="True"
                      ItemsSource="{Binding Path=Thumbnails}">
            <ItemsControl.ItemTemplate>
                <DataTemplate>
                    <Border BorderBrush="White" BorderThickness="1">
                        <Image Source="{Binding Image, Mode=OneTime}" Height="128" Width="128" />
                    </Border>
                </DataTemplate>
            </ItemsControl.ItemTemplate>
            <ItemsControl.ItemsPanel>
                <ItemsPanelTemplate>
                    <VirtualizingStackPanel />
                </ItemsPanelTemplate>
            </ItemsControl.ItemsPanel>
            <ItemsControl.Template>
                <ControlTemplate>
                    <Border BorderThickness="{TemplateBinding Border.BorderThickness}"
                            Padding="{TemplateBinding Control.Padding}"
                            BorderBrush="{TemplateBinding Border.BorderBrush}"
                            Background="{TemplateBinding Panel.Background}"
                            SnapsToDevicePixels="True">
                        <ScrollViewer Padding="{TemplateBinding Control.Padding}" Focusable="False">
                            <ItemsPresenter SnapsToDevicePixels="{TemplateBinding UIElement.SnapsToDevicePixels}" />
                        </ScrollViewer>
                    </Border>
                </ControlTemplate>
            </ItemsControl.Template>
        </ItemsControl>
    </Grid>
</Window>
Run Code Online (Sandbox Code Playgroud)

MainWindow.xaml.cs:

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;

namespace VirtualizedListView
{
    public partial class MainWindow : Window
    {
        private const string ThumbnailDirectory = @"D:\temp\thumbnails";

        private ConcurrentQueue<WriteableBitmap> _writeableBitmapCache = new ConcurrentQueue<WriteableBitmap>();

        public MainWindow()
        {
            InitializeComponent();
            DataContext = this;

            // Load thumbnail file names
            List<string> fileList = new List<string>(System.IO.Directory.GetFiles(ThumbnailDirectory));

            // Load view-model
            Thumbnails = new ObservableCollection<Thumbnail>();
            foreach (string file in fileList)
                Thumbnails.Add(new Thumbnail(GetImageForThumbnail) { FilePath = file });

            // Create cache of pre-built WriteableBitmap objects; note that this assumes that all thumbnails
            // will be the exact same size.  This will need to be tuned for your needs
            for (int i = 0; i <= 99; ++i)
                _writeableBitmapCache.Enqueue(new WriteableBitmap(256, 256, 96d, 96d, PixelFormats.Bgr32, null));
        }

        public ObservableCollection<Thumbnail> Thumbnails
        {
            get { return (ObservableCollection<Thumbnail>)GetValue(ThumbnailsProperty); }
            set { SetValue(ThumbnailsProperty, value); }
        }
        public static readonly DependencyProperty ThumbnailsProperty =
            DependencyProperty.Register("Thumbnails", typeof(ObservableCollection<Thumbnail>), typeof(MainWindow));

        private BitmapSource GetImageForThumbnail(Thumbnail thumbnail)
        {
            // Get the thumbnail data via the proxy in the other app domain
            ImageLoaderProxyPixelData pixelData = GetBitmapImageBytes(thumbnail.FilePath);
            WriteableBitmap writeableBitmap;

            // Get a pre-built WriteableBitmap out of the cache then overwrite its pixels with the current thumbnail information.
            // This avoids the memory pressure being set in this app domain, keeping that in the app domain of the proxy.
            while (!_writeableBitmapCache.TryDequeue(out writeableBitmap)) { Thread.Sleep(1); }
            writeableBitmap.WritePixels(pixelData.Rect, pixelData.Pixels, pixelData.Stride, 0);

            return writeableBitmap;
        }

        private ImageLoaderProxyPixelData GetBitmapImageBytes(string fileName)
        {
            // All of the BitmapSource creation occurs in this method, keeping the calls to 
            // MemoryPressure.ProcessAdd() localized to this app domain

            // Load the image from file
            BitmapFrame bmpFrame = BitmapFrame.Create(new Uri(fileName));
            int stride = bmpFrame.PixelWidth * bmpFrame.Format.BitsPerPixel;
            byte[] pixels = new byte[bmpFrame.PixelHeight * stride];

            // Construct and return the image information
            bmpFrame.CopyPixels(pixels, stride, 0);
            return new ImageLoaderProxyPixelData()
            {
                Pixels = pixels,
                Stride = stride,
                Rect = new Int32Rect(0, 0, bmpFrame.PixelWidth, bmpFrame.PixelHeight)
            };
        }

        public void VirtualizingStackPanel_CleanUpVirtualizedItem(object sender, CleanUpVirtualizedItemEventArgs e)
        {
            // Get a reference to the WriteableBitmap before nullifying the property to release the reference
            Thumbnail thumbnail = (Thumbnail)e.Value;
            WriteableBitmap thumbnailImage = (WriteableBitmap)thumbnail.Image;
            thumbnail.Image = null;

            // Asynchronously add the WriteableBitmap back to the cache
            Dispatcher.BeginInvoke((Action)(() =>
            {
                _writeableBitmapCache.Enqueue(thumbnailImage);
            }), System.Windows.Threading.DispatcherPriority.Loaded);
        }
    }

    // View-Model
    public class Thumbnail : DependencyObject
    {
        private Func<Thumbnail, BitmapSource> _imageGetter;
        private BitmapSource _image;

        public Thumbnail(Func<Thumbnail, BitmapSource> imageGetter)
        {
            _imageGetter = imageGetter;
        }

        public string FilePath
        {
            get { return (string)GetValue(FilePathProperty); }
            set { SetValue(FilePathProperty, value); }
        }
        public static readonly DependencyProperty FilePathProperty =
            DependencyProperty.Register("FilePath", typeof(string), typeof(Thumbnail));

        public BitmapSource Image
        {
            get
            {
                if (_image== null)
                    _image = _imageGetter(this);
                return _image;
            }
            set { _image = value; }
        }
    }

    public class ImageLoaderProxyPixelData
    {
        public byte[] Pixels { get; set; }
        public Int32Rect Rect { get; set; }
        public int Stride { get; set; }
    }
}
Run Code Online (Sandbox Code Playgroud)

作为一个基准,(对于我自己,如果没有其他人,我想)我已经在使用迅驰处理器的10年前笔记本电脑上测试了这种方法,并且在UI中几乎没有流动性问题.