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

时间:2016-03-16 19:04:22

标签: c# wpf reflection garbage-collection

我最近不得不将这个 monstrosity 签入生产代码来操作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();
        }
    }
}

要理解为什么我会这么疯狂,你需要看看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;
}

重要的一点接近结尾,它调用方法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);
}

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

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

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

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

5 个答案:

答案 0 :(得分:11)

  

注意:只有在应用程序出现瓶颈的情况下才能执行此操作,并确保了解其后果 - 请参阅Hans's answer以获取有关将其置于WPF中的原因的详细说明第一名。

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

所以不要期望那里有 clean 解决方案。除非他们更改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);
    }
}

此处,现在您的应用必须在调用GC.Collect之前分配大约 8 exabytes 。毋庸置疑,如果发生这种情况,您将面临更大的问题需要解决。 :)

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

请注意,AddToTotal实际上对_totalMemory执行边界检查,但它使用Debug.Assert here执行此操作:

Debug.Assert(newValue >= 0);

当您正在使用.NET Framework的发行版时,这些断言将被禁用(使用ConditionalAttribute),因此无需担心。

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

  • 最明显的一个:MS会更改您尝试入侵的WPF代码。

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

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

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

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

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

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

  • 现在,让我们假设黑客工作正常并成功禁用GC.Collect来电。

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

    您还会有更多内存碎片,这是收集次数减少的直接后果。 可能可能不会成为您的问题 - 因此请对您的应用进行分析。

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

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

答案 1 :(得分:10)

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

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

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

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

答案 2 :(得分:9)

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

.NET 4.6实现它

internal SafeMILHandleMemoryPressure(long gcPressure)
{
    this._gcPressure = gcPressure;
    this._refCount = 0;
    GC.AddMemoryPressure(this._gcPressure);
}

而在.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);
}

这会产生巨大的差异,因为您可以看到GC挂起时间在一个简单的测试应用程序中显着下降,我写了这个应用程序来重现问题。 enter image description here

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

答案 3 :(得分:6)

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

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

虽然可能有其他人,但主要关注点是如何构建缩略图图像。该应用程序会预先创建WriteableBitmap个对象的缓存。当UI请求列表项时,它使用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>

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; }
    }
}

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

答案 4 :(得分:1)

我希望我能相信这一点,但我相信已有更好的答案:How can I prevent garbage collection from being called when calling ShowDialog on a xaml window?

即使从ProcessAdd方法的代码中,如果_totalMemory足够小,也可以看到没有任何内容被执行。所以我认为这个代码更容易使用,副作用更少:

typeof(BitmapImage).Assembly
  .GetType("MS.Internal.MemoryPressure")
  .GetField("_totalMemory", BindingFlags.NonPublic | BindingFlags.Static)
  .SetValue(null, Int64.MinValue / 2); 

我们需要了解该方法应该做什么,.NET源代码的注释非常清楚:

/// Avalon currently only tracks unmanaged memory pressure related to Images.  
/// The implementation of this class exploits this by using a timer-based
/// tracking scheme. It assumes that the unmanaged memory it is tracking
/// is allocated in batches, held onto for a long time, and released all at once
/// We have profiled a variety of scenarios and found images do work this way

所以我的结论是,通过禁用代码,您可能会因为图像的管理方式而填满您的内存。但是,既然您知道您使用的应用程序很大并且可能需要调用GC.Collect,那么当您认为可以时,您可以自行调用它来进行非常简单和安全的修复。

每次使用的总内存超过阈值时,代码会尝试执行它,并使用计时器,因此不会经常发生。对他们来说这将是30秒。那么,为什么不在关闭表单或做其他会释放许多图像的事情时调用GC.Collect(2)?或者,当计算机处于空闲状态或应用程序未对焦时,等等?

我花时间检查_totalMemory值的来源,似乎每次创建WritableBitmap时,他们都会将内存添加到_totalMemory,在此计算:http://referencesource.microsoft.com/PresentationCore/R/dca5f18570fed771.htmlpixelWidth * pixelHeight * pixelFormat.InternalBitsPerPixel / 8 * 2;以及与Freezables一起使用的方法。它是一种内部机制,用于跟踪几乎所有WPF控件的图形表示所分配的内存。

听起来对你来说,你不仅可以将_totalMemory设置为一个非常低的值,而且还可以劫持该机制。您偶尔可以读取该值,将最初减去的大值添加到其中,并获取绘制控件使用的内存的实际值,并决定是否要GC.Collect。