Any way to workaround WPF's calling of GC.Coll

2019-01-30 23:06发布

问题:

I recently had to check in this monstrosity into production code to manipulate private fields in a WPF class: (tl;dr how do I avoid having to do this?)

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

To understand why I would do something so crazy, you need to look at 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;
}

The important bit is near the end, where it calls the method 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);
}

Yes, that's WPF actually forcing a gen 2 garbage collection, which forces a full blocking GC. A naturally occurring GC happens without blocking on the gen 2 heap. What this means in practice is that whenever this method is called, our entire app locks up. The more memory your app is using, and the more fragmented your gen 2 heap is, the longer it will take. Our app presently caches quite a bit of data and can easily take up a gig of memory and the forced GC can lock up our app on a slow device for several seconds -- every 850 MS.

For despite the author's protestations to the contrary, it is easy to arrive at a scenario where this method is called with great frequency. This memory code of WPF's occurs when loading a BitmapSource from a file. We virtualize a listview with thousands of items where each item is represented by a thumbnail stored on disk. As we scroll down, we are dynamically loading in those thumbnails, and that GC is happening at maximum frequency. So scrolling becomes unbelievably slow and choppy with the app locking up constantly.

With that horrific reflection hack I mentioned up top, we force the timers to never be met, and thus WPF never forces the GC. Furthermore, there appear to be no adverse consequences -- memory grows as one scrolls and eventually a GC is triggered naturally without locking up the main thread.

Is there any other option to prevent those calls to GC.Collect(2) that is not so flagrantly hideous as my solution? Would love to get an explanation for what the concrete problems are that might arise from following through with this hack. By that I mean problems with avoiding the call to GC.Collect(2). (seems to me the GC occurring naturally ought to be sufficient)

回答1:

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.

You have some nasty code there trying to fix a nasty hack in the framework... As it's all static and called from multiple places in WPF, you can't really do better than use reflection to break it (other solutions would be much worse).

So don't expect a clean solution there. No such thing exists unless they change the WPF code.

But I think your hack could be simpler and avoid using a timer: just hack the _totalMemory value and you're done. It's a long, which means it can go to negative values. And very big negative values at that.

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

Here, now your app would have to allocate about 8 exabytes before calling GC.Collect. Needless to say, if this happens you'll have bigger problems to solve. :)

If you're worried about the possibility of an underflow, just use long.MinValue / 2 as the offset. This still leaves you with 4 exabytes.

Note that AddToTotal actually performs bounds checking of _totalMemory, but it does this with a Debug.Assert here:

Debug.Assert(newValue >= 0);

As you'll be using a release version of the .NET Framework, these asserts will be disabled (with a ConditionalAttribute), so there's no need to worry about that.


You've asked what problems could arise with this approach. Let's take a look.

  • The most obvious one: MS changes the WPF code you're trying to hack.

    Well, in that case, it pretty much depends on the nature of the change.

    • They change the type name/field name/field type: in that case, the hack will not be performed, and you'll be back to stock behavior. The reflection code is pretty defensive, it won't throw an exception, it just won't do anything.

    • They change the Debug.Assert call to a runtime check which is enabled in the release version. In that case, your app is doomed. Any attempt to load an image from disk will throw. Oops.

      This risk is mitigated by the fact that their own code is pretty much a hack. They don't intend it to throw, it should go unnoticed. They want it to sit quiet and fail silently. Letting images load is a much more important feature which should not be impaired by some memory management code whose only purpose is to keep memory usage to a minimum.

    • In the case of your original patch in the OP, if they change the constant values, your hack may stop working.

    • They change the algorithm while keeping the class and field intact. Well... anything could happen, depending on the change.

  • Now, let's suppose the hack works and disables the GC.Collect call successfully.

    The obvious risk in this case is increased memory usage. Since collections will be less frequent, more memory will be allocated at a given time. This should not be a big issue, since collections would still occur naturally when gen 0 fills up.

    You'd also have more memory fragmentation, this is a direct consequence of fewer collections. This may or may not be a problem for you - so profile your app.

    Fewer collections also means fewer objects are promoted to a higher generation. This is a good thing. Ideally, you should have short-lived objects in gen 0, and long-lived objects in gen 2. Frequent collections will actually cause short-lived objects to be promoted to gen 1 and then to gen 2, and you'll end up with many unreachable objects in gen 2. These will only be cleaned up with a gen 2 collection, will cause heap fragmentation, and will actually increase the GC time, since it'll have to spend more time compacting the heap. This is actually the primary reason why calling GC.Collect yourself is considered a bad practice - you're actively defeating the GC strategy, and this affects the whole application.

In any case, the correct approach would be to load the images, scale them down and display these thumbnails in your UI. All of this processing should be done in a background thread. In the case of JPEG images, load the embedded thumbnails - they may be good enough. And use an object pool so you don't need to instantiate new bitmaps each time, this completely bypasses the MemoryPressure class problem. And yes, that's exactly what the other answers suggest ;)



回答2:

I think what you have is just fine. Well done, nice hack, Reflection is an awesome tool to fix wonky framework code. I've used it myself many times. Just limit its usage to the view that displays the ListView, it is too dangerous to have it active all the time.

Noodling a bit about the underlying problem, the horrid ProcessAdd() hack is of course very crude. It is a consequence of BitmapSource not implementing IDisposable. A questionable design decision, SO is filled with questions about it. However, about all of them are about the opposite problem, this timer not being quick enough to keep up. It just doesn't work very well.

There isn't anything you can do to change the way this code works. The values it works off are const declarations. Based on values that might have been appropriate 15 years ago, the probable age of this code. It starts at one megabyte and calls "10s of MB" a problem, life was simpler back then :) They forgot to write it so it scales properly, GC.AddMemoryPressure() would probably be fine today. Too little too late, they can't fix this anymore without dramatically altering program behavior.

You can certainly defeat the timer and avoid your hack. Surely the problem you have right now is that its Interval is about the same as the rate at which a user scrolls through the ListView when he doesn't read anything but just tries to find the record of interest. That's a UI design problem that's so common with list views with thousands of rows, an issue you probably don't want to address. What you need to do is cache the thumbnails, gathering the ones you know you'll likely need next. Best way to do that is to do so in a threadpool thread. Measure time while you do this, you can spend up to 850 msec. That code is however not going to be smaller than what you have now, not much prettier either.



回答3:

.NET 4.6.2 will fix it by killing the MemoryPressure class alltogether. I just have checked the preview and my UI hangs are entirely gone.

.NET 4.6 implemements it

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

whereas pre .NET 4.6.2 you had this crude MemoryPressure class which would force a GC.Collect every 850ms (if in between no WPF Bitmaps were allocated) or every 30s regardless how much WPF bitmaps you did allocate.

For reference the old handle was implemented like

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

This makes a huge difference as you can see the GC suspension times drop dramatically in a simple test application I did write to repro the issue.

Here you see that the GC suspension times did drop from 2,71s down to 0,86s. This remains nearly constant even for multi GB managed heaps. This also increases overall application performance because now the background GC can do its work where it should: In the background. This prevents sudden halts of all managed threads which can continue to do work happily although the GC is cleaning things up. Not many people are aware what background GC gives them but this makes a real world difference of ca. 10-15% for common application workloads. If you have a multi GB managed application where a full GC can take seconds you will notice a dramatic improvement. In some tests were an application had a memory leak (5GB managed heap, full GC suspend time 7s) I did see 35s UI delays due to these forced GCs!



回答4:

To the updated question regarding what the concrete problems are you may encounter by using the reflection approach, I think @HansPassant was thorough in his assessment of your specific approach. But more generally speaking, the risk you run with your current approach is the same risk you run with using any reflection against code you don't own; it can change underneath of you in the next update. So long as you're comfortable with that, the code you have should have negligible risk.

To hopefully answer the original question, there may be a way to workaround the GC.Collect(2) problem by minimizing the number of BitmapSource operations. Below is a sample app which illustrates my thought. Similar to what you described, it uses a virtualized ItemsControl to display thumbnails from disk.

While there may be others, the main point of interest is how the thumbnail images are constructed. The app creates a cache of WriteableBitmap objects upfront. As list items are requested by the UI, it reads the image from disk, using a BitmapFrame to retrieve the image information, primarily pixel data. A WriteableBitmap object is pulled from the cache, it's pixel data is overwritten, then it is assigned to the view-model. As existing list items fall out of view and are recycled, the WriteableBitmap object is returned to the cache for later reuse. The only BitmapSource-related activity incurred during that entire process is the actual loading of the image from disk.

It is worth noting that, that the image returned by the GetBitmapImageBytes() method must be exactly the same size as those in the WriteableBitmap cache for this pixel-overwrite approach to work; currently 256 x 256. For simplicity's sake, the bitmap images I used in my testing were already at this size, but it should be trivial to implement scaling, as needed.

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

As a benchmark, (for myself if no one else, I suppose) I've tested this approach on a 10-year old laptop with a Centrino processor and had almost no issue with fluidity in the UI.



回答5:

I wish I could take credit for this, but I believe a better answer is already there: How can I prevent garbage collection from being called when calling ShowDialog on a xaml window?

Even from the code of the ProcessAdd method one can see that nothing gets executed if _totalMemory is small enough. So I think this code is much easier to use and with less side effects:

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

We need to understand, though, what the method is supposed to do, and the comment from the .NET source is pretty clear:

/// 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

So my conclusion is that by disabling their code, you risk filling up your memory because of the way images are managed. However, since you know that the application you use is large and that it might need GC.Collect to be called, a very simple and safe fix would be for you to call it yourself, when you feel you can.

The code there tries to execute it every time the total memory used goes over a threshold, with a timer so it doesn't happen too often. That would be 30 seconds for them. So why don't you call GC.Collect(2) when you are closing forms or doing other things that would release the use of many images? Or when the computer is idle or the app is not in focus, etc?

I took the time to check where the _totalMemory value comes from and it seems that every time they create a WritableBitmap, they add the memory for it to _totalMemory, which is calculated here: http://referencesource.microsoft.com/PresentationCore/R/dca5f18570fed771.html as pixelWidth * pixelHeight * pixelFormat.InternalBitsPerPixel / 8 * 2; and further on in methods that work with Freezables. It is an internal mechanism to keep track of the memory allocated by the graphical representation of almost any WPF control.

It sounds to me as you could not only set _totalMemory to a very low value, but also hijack the mechanism. You could occasionally read the value, add to it the large value you subtracted initially, and get the actual value of memory used by drawn controls and decide whether you want to GC.Collect or not.