任何方法来解决WPF的调用GC.Collect(2)除了reflection吗?

我最近不得不把这个怪物检入到生产代码中来操纵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。 自然发生的GC发生在第2代堆没有阻塞。 这意味着在实践中,无论何时调用此方法,我们的整个应用程序都会被locking。 您的应用程序使用的内存越多,第2代堆越碎,时间越长。 我们的应用程序目前caching了相当多的数据,可以轻松地占用内存,强制GC可以在慢速设备上locking我们的应用几秒钟 – 每850个MS。

尽pipe作者提出了相反的抗议,很容易出现这种方法被频繁调用的情况。 从文件加载BitmapSource时,WPF的此内存代码发生。 我们用一个列表视图虚拟化了数以千计的项目,其中每个项目都由存储在磁盘上的缩略图表示。 当我们向下滚动时,我们在这些缩略图中dynamic加载,并且GC正在以最大频率发生。 所以滚动变得令人难以置信的缓慢和不断变化的应用程序locking。

有了这个可怕的reflection黑客,我们强迫定时器永远不会被满足,因此WPF从不强制GC。 此外,似乎没有任何不良后果 – 内存随着一个卷轴的增长而增长,最终GC自然触发而不locking主线程。

有没有其他的select,以防止这些GC.Collect(2)调用是不是像我的解决scheme那么可怕? 希望能够解释这个黑客攻击可能带来的具体问题。 我的意思是避免调用GC.Collect(2) 。 (在我看来,自然发生的GC应该是足够的)

注意:只有在引起应用程序瓶颈的情况下才能做到这一点,并确保您了解后果 – 请参阅Hans的回答,以便他们为什么首先将这个问题放在WPF中。

你有一些讨厌的代码,试图修复框架中的讨厌的黑客…因为它是静态的,并从WPF中的多个地方调用,你不能真正做得比使用reflection打破它(其他解决scheme会更糟糕 )。

所以不要指望有一个干净的解决scheme。 除非他们改变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艾字节 。 不用说,如果发生这种情况,你将会遇到更大的问题需要解决。 🙂

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

请注意, AddToTotal实际上会执行AddToTotal边界检查,但是在这里使用Debug.Assert 此操作 :

 Debug.Assert(newValue >= 0); 

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


你已经问过这种方法会出现什么问题。 让我们来看看。

  • 最明显的一个:MS更改您试图破解的WPF代码。

    那么在这种情况下,这很大程度上取决于变化的性质。

    • 他们更改types名称/字段名称/字段types:在这种情况下,黑客将不会执行,您将回到股票行为。 reflection代码非常防守,它不会抛出exception,它不会做任何事情。

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

      这个风险是由于他们自己的代码几乎是黑客的事实而减轻的。 他们不打算抛出,它应该被忽视。 他们希望它安静地静静地失败。 加载图像是一个非常重要的特性,不应该被某些内存pipe理代码所损害,这些内存pipe理代码的唯一目的是将内存使用量降到最低。

    • 如果你在OP中的原始补丁,如果他们改变常量值,你的黑客可能会停止工作。

    • 他们改变algorithm,同时保持类和领域完好无损。 那么…任何事情都可能发生,取决于变化。

  • 现在,让我们假设hack工作并成功禁用GC.Collect调用。

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

    你也会有更多的内存碎片,这是收集更less的直接后果。 这可能会可能不会成为你的问题 – 所以configuration你的应用程序。

    更less的收集也意味着更less的对象被提升到更高的一代。 这是一件好事。 理想情况下,你应该在第0代有短暂的对象,在第2代应该有长寿命的对象。频繁的集合实际上会导致短暂的对象被提升到第1代,然后是第2代,最后你会第2代中有许多不可达的对象。这些只能用gen 2集合清理,会导致堆碎片,并且实际上会增加GC时间,因为它需要花费更多的时间来压缩堆。 这实际上是调用GC的主要原因。 GC.Collect自己被认为是不好的做法 – 您正在积极地击败GC策略,这会影响整个应用程序。

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

我想你有什么好的。 做得好,好的黑客,reflection是一个很好的工具来解决wonky框架代码。 我自己多次使用它。 只是将其用法限制在显示ListView的视图中,它始终处于活动状态是非常危险的。

对于这个潜在的问题,有一点点讨论,可怕的ProcessAdd()黑客当然是非常粗糙的。 这是BitmapSource不执行IDisposable的结果。 一个有问题的devise决定,充满了关于它的问题。 但是,他们大概都是相反的问题,这个计时器还不够快。 它只是不工作得很好。

没有什么可以改变这种代码的工作方式。 它的工作原理是const声明。 基于15年前可能适合的值,这个代码的可能年龄。 它从一兆字节开始,称为“10兆字节”的一个问题,当时的生活更简单:)他们忘了写它,所以它适当扩展,今天GC.AddMemoryPressure()可能会很好。 太迟了,他们不能修复这个问题,而不会显着改变程序的行为。

你当然可以打败计时器,避免你的黑客入侵。 当然,你现在面临的问题是,它的间隔与用户滚动浏览ListView的速度大致相同,因为他没有阅读任何内容,只是试图find感兴趣的logging。 这是一个用户界面devise问题,在数千行列表视图中很常见,这个问题你可能不想解决。 你需要做的是caching缩略图,收集你知道你可能会需要下一个。 最好的方法是在一个线程池线程中这样做。 测量时间,而你这样做,你可以花费高达850毫秒。 然而,这个代码不会比现在小,也不会太漂亮。

.NET 4.6.2将通过一起杀死MemoryPressure类来修复它。 我刚刚检查了预览,我的用户界面完全没有了。

.NET 4.6实现它

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

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

为了参考,旧的手柄被实现了

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

这是一个巨大的差异,你可以看到一个简单的testing应用程序,我写了repro这个问题的GC暂停时间急剧下降。 在这里输入图像说明

在这里你可以看到GC的停顿时间确实从2,71下降到0.86。 即使对于多GBpipe理的堆,这也几乎保持不变。 这也提高了整体应用程序的性能,因为现在的背景GC可以在以下情况下完成工作:在后台。 这可以防止所有托pipe线程突然中断,尽pipeGC正在进行清理工作,但仍然可以继续高效工作。 没有多less人知道GC给他们的背景是什么,但是这使得真实世界的差异。 普通应用程序工作量为10-15%。 如果您有一个多GB的托pipe应用程序,完整的GC可能需要几秒钟的时间,您会注意到一个显着的改进。 在一些testing中,一个应用程序有内存泄漏(5GBpipe理堆,完整的GC暂停时间7s),我看到35s UI延迟,由于这些强制GC!

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

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

虽然也可能有其他的,但感兴趣的主要是如何构build缩略图图像。 该应用程序预先创build了一个WriteableBitmap对象caching。 由于UI请求列表项,它从磁盘读取图像,使用BitmapFrame检索图像信息,主要是像素数据。 WriteableBitmap对象从caching中取出,它的像素数据被覆盖,然后被分配给视图模型。 由于现有的列表项不在视图中并且被回收,所以WriteableBitmap对象被返回到高速caching以供以后重新使用。 在整个过程中唯一发生的与BitmapSource相关的活动是从磁盘实际加载图像。

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

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

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

我希望我可以相信这一点,但我相信更好的答案已经存在: 如何防止在xaml窗口中调用ShowDialog时调用垃圾收集?

即使从ProcessAdd方法的代码,你可以看到,如果_totalMemory足够小,什么都得不到执行。 所以我认为这个代码更容易使用,副作用更less:

 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 

所以我的结论是,通过禁用他们的代码,你可能会因为图像pipe理的方式而填满你的内存。 但是,由于您知道您使用的应用程序很大,并且可能需要GC.Collect来调用,所以您可以自己调用一个非常简单和安全的修复程序。

每当使用的总内存超过阈值时,那里的代码都会尝试执行它,并使用计时器,因此不会经常发生。 那将是30秒。 那么你为什么不打电话给GC.Collect(2)当你正在closures窗体或做其他事情,会释放使用许多图像? 或者当电脑闲置或应用程序不在焦点等?

我花了时间来检查_totalMemory值来自哪里,似乎每次他们创build一个WritableBitmap,他们将其内存添加到_totalMemory,这是计算在这里: http ://referencesource.microsoft.com/PresentationCore/R /dca5f18570fed771.html as pixelWidth * pixelHeight * pixelFormat.InternalBitsPerPixel / 8 * 2; 并继续使用Freezables的方法。 这是一种内部机制,可以跟踪由几乎任何WPF控件的graphics表示分配的内存。

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