mercredi 16 mars 2016

Any way to workaround WPF's calling of GC.Collect(2) aside from reflection?

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?





Aucun commentaire:

Enregistrer un commentaire