Any way to workaround WPF's calling of GC.Collect(2) aside from reflection?
我最近不得不将这个怪物检入生产代码来操作WPF类中的私有字段:( tl; dr我如何避免这样做?)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | 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(); } } } |
要理解为什么我会这么疯狂,你需要看看
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | /// <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; } |
重要的位接近结尾,它调用方法
1 2 3 4 5 6 7 | 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代堆上没有阻塞地发生。这在实践中意味着无论何时调用此方法,我们的整个应用程序都会锁定。您的应用程序使用的内存越多,您的第2代堆的碎片越多,所需的时间就越长。我们的应用程序目前缓存相当多的数据并且可以轻松占用大量内存,强制GC可以在慢速设备上锁定我们的应用程序几秒钟 - 每850 MS。
尽管作者的抗议恰恰相反,但很容易得出这种方法被频繁调用的情况。从文件加载
有了可怕的反射黑客,我提到了最高层,我们强制永远不会遇到计时器,因此WPF从不强制GC。此外,似乎没有不良后果 - 内存随着滚动而增长,最终GC自然触发而不会锁定主线程。
是否有任何其他选项可以阻止那些对我的解决方案不那么可怕的
Notice: Do this only if it causes a bottleneck in your app, and make sure you understand the consequences - See Hans's answer for a good explanation on why they put this in WPF in the first place.
Ok.
你有一些讨厌的代码试图在框架中修复一个令人讨厌的黑客...因为它是静态的并且从WPF中的多个地方调用,你实际上不能比使用反射来打破它更好(其他解决方案会更糟糕)。
所以不要指望有一个干净的解决方案。除非他们更改WPF代码,否则不存在这样的事情。
但我认为你的黑客可能更简单并且避免使用计时器:只需破解
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | 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); } } |
在这里,现在你的应用程序必须在调用
如果您担心下溢的可能性,只需使用
请注意,
1 | Debug.Assert(newValue >= 0); |
由于您将使用.NET Framework的发行版,这些断言将被禁用(使用
您已经问过这种方法可能会出现什么问题。让我们来看看。
最明显的一个:MS改变你试图破解的WPF代码。
那么,在这种情况下,它几乎取决于变化的性质。
他们更改了类型名称/字段名称/字段类型:在这种情况下,将不会执行黑客攻击,并且您将回到库存行为。反射代码非常具有防御性,它不会抛出异常,它只是不会做任何事情。
他们将
他们自己的代码几乎是一个黑客的事实可以减轻这种风险。他们不打算扔,它应该被忽视。他们希望它安静地坐着,默默地失败。让图像加载是一个非常重要的功能,不应该受到某些内存管理代码的影响,这些代码的唯一目的是将内存使用量降至最低。
对于OP中的原始补丁,如果他们更改常量值,您的黑客可能会停止工作。
他们在保持类和字段完整的同时更改算法。嗯......任何事都可能发生,取决于变化。
现在,让我们假设黑客工作并成功禁用
在这种情况下,明显的风险是增加内存使用量。由于收集频率较低,因此将在给定时间分配更多内存。这应该不是一个大问题,因为当gen 0填满时,收集仍然会自然发生。
你也会有更多的内存碎片,这是收集较少的直接后果。这对您来说可能是也可能不是问题 - 因此请对您的应用进行分析。
较少的集合也意味着更少的对象被提升到更高的一代。这是一件好事。理想情况下,你应该在gen 0中拥有短暂的对象,在gen 2中拥有长寿命的对象。频繁的集合实际上会导致短暂的对象被提升为gen 1然后再提升到gen 2,你最终会得到gen 2中的许多无法访问的对象。这些只会使用gen 2集合进行清理,会导致堆碎,并且实际上会增加GC时间,因为它必须花费更多时间来压缩堆。这实际上是为什么自己调用
在任何情况下,正确的方法是加载图像,缩小图像并在UI中显示这些缩略图。所有这些处理都应该在后台线程中完成。在JPEG图像的情况下,加载嵌入的缩略图 - 它们可能足够好。并且使用对象池,因此您不需要每次都实例化新的位图,这完全绕过了
好。
我觉得你有什么就好了。干得好,很好的黑客,反射是一个很棒的工具来修复不稳定的框架代码。我自己多次使用过它。只是将其使用限制在显示ListView的视图中,使其始终处于活动状态是非常危险的。
关于潜在问题的一点点,可怕的ProcessAdd()hack当然非常粗糙。这是BitmapSource没有实现IDisposable的结果。一个可疑的设计决策,SO充满了关于它的问题。然而,关于所有这些都是相反的问题,这个计时器不够快,无法跟上。它只是不能很好地工作。
您无法更改此代码的工作方式。它解决的值是const声明。基于15年前可能适用的值,此代码的可能年龄。它从1兆字节开始,称"10s of MB"是一个问题,生活变得简单了:)他们忘记写它以便它正确扩展,GC.AddMemoryPressure()今天可能会很好。太晚了,他们无法在不显着改变程序行为的情况下解决这个问题。
你当然可以打败计时器,避免你的黑客入侵。当然,你现在遇到的问题是它的Interval与用户在没有读取任何东西但只是试图找到感兴趣的记录时滚动ListView的速度大致相同。这是一个UI设计问题,这个问题在包含数千行的列表视图中非常常见,这是您可能不想解决的问题。您需要做的是缓存缩略图,收集您知道接下来可能需要的缩略图。最好的方法是在线程池线程中执行此操作。在执行此操作时测量时间,您可以花费850毫秒。然而,该代码不会比现在的代码小,也不会更漂亮。
.NET 4.6.2将通过一起杀死MemoryPressure类来修复它。我刚检查了预览,我的UI挂起完全消失了。
.NET 4.6实现它
1 2 3 4 5 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位图。
作为参考,旧手柄的实现就像
1 2 3 4 5 6 7 8 9 10 11 | 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挂起时间在我编写的一个简单的测试应用程序中显着下降以重现问题。
在这里,您可以看到GC悬浮时间从2,71秒下降到0.86秒。即使对于多GB管理堆,这仍然几乎保持不变。这也提高了整体应用程序性能,因为现在后台GC可以在应有的位置完成工作:在后台。这可以防止所有托管线程的突然停止,尽管GC正在清理,但这些线程可以继续快乐地工作。没有多少人知道GC为他们提供了什么背景,但是这会产生真正的世界差异。普通应用程序工作负载的10-15%。如果您有一个多GB托管应用程序,其中完整的GC可能需要几秒钟,您会发现一个显着的改进。在一些测试中,应用程序有内存泄漏(5GB托管堆,完全GC挂起时间为7s)我确实看到由于这些强制GC导致35s UI延迟!
关于使用反思方法可能遇到的具体问题的更新问题,我认为@HansPassant对您的具体方法的评估是彻底的。但更一般地说,使用当前方法运行的风险与使用任何反映您不拥有的代码的风险相同;它可以在下一次更新中改变你的下方。只要你对此感到满意,你所拥有的代码应该具有可忽略的风险。
为了有希望回答原始问题,可能有办法通过最小化
虽然可能有其他人,但主要关注点是如何构建缩略图图像。该应用程序预先创建
值得注意的是,
MainWindow.xaml:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | <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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 | 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; } } } |
作为一个基准,(对于我自己,如果没有其他人,我想)我已经在使用迅驰处理器的10年前笔记本电脑上测试了这种方法,并且在UI中几乎没有流动性问题。
我希望我能相提并论,但我相信已经有了更好的答案:在xaml窗口上调用ShowDialog时,如何防止调用垃圾收集?
即使从ProcessAdd方法的代码中,如果_totalMemory足够小,也可以看到没有任何内容被执行。所以我认为这个代码更容易使用,副作用更少:
1 2 3 4 | typeof(BitmapImage).Assembly .GetType("MS.Internal.MemoryPressure") .GetField("_totalMemory", BindingFlags.NonPublic | BindingFlags.Static) .SetValue(null, Int64.MinValue / 2); |
但是,我们需要了解该方法应该做什么,.NET源代码的注释非常清楚:
1 2 3 4 5 | /// 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.html为
听起来对你来说,你不仅可以将_totalMemory设置为一个非常低的值,而且还可以劫持该机制。您偶尔可以读取该值,将最初减去的大值添加到其中,并获取绘制控件使用的内存的实际值,并决定是否要GC.Collect。