0

I provoked a memory-leak with a like this:

public class TestClass
{
    
}

public class ExecutionQueue
{
    private IList<Action> actions = new List<Action>();

    public void AddToQueue(TestClass testclass)
    {
        actions.Add(() =>
        {
            Console.WriteLine(testclass); //here we keep a reference to the TestClass instance
        });
    }
}

You can see that with creating the anonymous method we also keep a reference of the TestClass instance.

Now you can build a unit-test looking like this:

[MethodImpl(MethodImplOptions.NoInlining)]
private WeakReference DoCreateMemoryLeak(out ExecutionQueue executionQueue)
{
    TestClass test = new TestClass();
    executionQueue = new ExecutionQueue();
    executionQueue.Foo(test);
    return new WeakReference(test);
}


[Fact]
public void CanFindMemoryLeakCausedByCapturingInAnonymousMethod()
{
    WeakReference weakReference = DoCreateMemoryLeak(out ExecutionQueue test);
    test.Should().NotBeNull();

    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.WaitForFullGCComplete();
    GC.Collect();

    weakReference.IsAlive.Should().BeTrue("We wanted to create a memory leak");
}

Which works as expected. Now I wanted to inspect the CLR using ClrMD and find out why the instance cannot be collected by the GC. To do so I followed the ways described in the article Exploring .NET managed heap with ClrMD. In a first iteration staying close to this code (but adopt it for ClrMD 2.0). It would look like this:

public static bool TryFindHeapReferences(IntPtr instanceAddress, out string feedback, bool suspend = true)
{
    object instance = AddressTool.GetInstance<object>(instanceAddress);
    string instanceTypeName = instance.GetType().FullName;
    ClrObject clrInstance = default;
    bool foundInstance = false;
    HashSet<ulong> reportedRetentions = new HashSet<ulong>();
    StringBuilder sb = new StringBuilder();
    StringBuilder sbInner = new StringBuilder();
    lock (inspectionLock)
    {
        try
        {
            using (DataTarget target = DataTarget.AttachToProcess(
                Process.GetCurrentProcess().Id, suspend))
            {
                ClrRuntime runtime = target.ClrVersions.First().CreateRuntime();
                var heap = runtime.Heap;
                if (heap.CanWalkHeap)
                {
                    foreach (var clrObject in heap.EnumerateObjects())
                    {
                        var typeName = clrObject.Type.Name;
                        if (typeName == instanceTypeName)
                        {
                            if (AddressTool.GetAddress(instance) == (IntPtr)clrObject.Address)
                            {
                                //we found the memory address of the instance to be inspected
                                clrInstance = clrObject;
                                foundInstance = true;
                                break;
                            }
                        }
                    }
                    if (foundInstance)
                    {
                        // Enumerate roots and try to find the current object
                        var stack = new Stack<ulong>();
                        var roots = heap.EnumerateRoots();
                        foreach (var root in roots)
                        {
                            stack.Clear();
                            stack.Push(root.Object.Address);
                            if (GetPathToObject(heap, clrInstance.Address, stack, new HashSet<ulong>()))
                            {
                                // Print retention path
                                var depth = 0;
                                object previousInstance = null;
                                foreach (var address in stack)
                                {
                                    var t = heap.GetObjectType(address);
                                    if (t == null)
                                    {
                                        continue;
                                    }
                                    sbInner.Append($"{new string('+', depth++)} {address} - {t.Name}");                                      
                                    sbInner.Append(Environment.NewLine);
                                }
                                sbInner.AppendLine();
                                string innerString = sbInner.ToString();
                                sbInner.Clear();
                                ulong hash = CalculateHash(innerString);
                                if (!reportedRetentions.Contains(hash))
                                {
                                    sb.Append(innerString);
                                    reportedRetentions.Add(hash);
                                }
                            }
                        }
                    }
                }
            }
        }
        catch (Exception e)
        {
            feedback = "Exception during inspection: " + e;
            return false;
        }
    }
    feedback = sb.ToString();
    return foundInstance;
}

private static bool GetPathToObject(ClrHeap heap, ulong objectPointer, Stack<ulong> stack, HashSet<ulong> touchedObjects)
{
    var currentObject = stack.Peek();
    if (!touchedObjects.Add(currentObject))
    {
        return false;
    }
    if (currentObject == objectPointer)
    {
        return true;
    }
    var found = false;
    var type = heap.GetObjectType(currentObject);
    if (type != null)
    {
        ClrObject clrObject = heap.GetObject(currentObject);
        foreach (var innerObject in clrObject.EnumerateReferences( false, false))
        {
            if (innerObject == 0 || touchedObjects.Contains(innerObject))
            {
                continue;
            }
            stack.Push(innerObject);
            if (GetPathToObject(heap, objectPointer, stack, touchedObjects))
            {
                found = true;
                continue;
            }
            stack.Pop();
        };
    }

    return found;
}
    

And the class AddressTool taken from this answer and slightly modified to fix a memory leak would look like this:

public class AddressTool
{
    private static object mutualObject;
    private static ObjectReinterpreter reinterpreter;

    static AddressTool()
    {
        mutualObject = new object();
        reinterpreter = new ObjectReinterpreter();
        reinterpreter.AsObject = new ObjectWrapper();
    }

    public static IntPtr GetAddress(object obj)
    {
        lock (mutualObject)
        {
            reinterpreter.AsObject.Object = obj;
            IntPtr address = reinterpreter.AsIntPtr.Value;
            reinterpreter.AsObject.Object = null;
            return address;
        }
    }

    public static T GetInstance<T>(IntPtr address)
    {
        lock (mutualObject)
        {
            reinterpreter.AsIntPtr.Value = address;
            T instance = (T)reinterpreter.AsObject.Object;
            reinterpreter.AsObject.Object = null;
            return instance;
        }
    }

    [StructLayout(LayoutKind.Explicit)]
    private struct ObjectReinterpreter
    {
        [FieldOffset(0)] public ObjectWrapper AsObject;
        [FieldOffset(0)] public IntPtrWrapper AsIntPtr;
    }

    private class ObjectWrapper
    {
        public object Object;
    }

    private class IntPtrWrapper
    {
        public IntPtr Value;
    }
}

The hasing function is the one from this answer

static ulong CalculateHash(string read)
{
    ulong hashedValue = 3074457345618258791ul;
    for(int i=0; i<read.Length; i++)
    {
        hashedValue += read[i];
        hashedValue *= 3074457345618258799ul;
    }
    return hashedValue;
}

Now the issue I have is that it would actually fail to find the retention path when I start to search on the roots given by heap.EnumerateRoots(). I can actually find the retention path I am looking for if I instead enumerate over all CLR objects using heap.EnumerateObjects() which is quite slow. I would need to change a part of TryFindHeapReferences (given above) to this:

//...
if (foundInstance)
{
    // Enumerate all CLR objects
    foreach (var clrObject in heap.EnumerateObjects())
    {
        stack.Clear();
        stack.Push(clrObject.Address);
        if (GetPathToObject(heap, clrInstance.Address, stack, new HashSet<ulong>()))
            //...

Now the code will correctly find the retention path and the feedback string would contain also this:

 2241974294480 - MemoryInspection.Test.MemoryLeakInspectorTest+TestClass
+ 2241974294584 - MemoryInspection.Test.MemoryLeakInspectorTest+ExecutionQueue+<>c__DisplayClass1_0
++ 2241974294608 - System.Action
+++ 2241974294752 - System.Action[]
++++ 2241974294528 - System.Collections.Generic.List<System.Action>
+++++ 2241974294504 - MemoryInspection.Test.MemoryLeakInspectorTest+ExecutionQueue

So now my questions is: Why don't I find the retention path if I iterate over the roots by using heap.EnumerateRoots()? Either there is a mistake in my code or there is some conceptual misunderstanding and the instance of ExecutionQueue cannot be found when exploring the roots in which case I would like to understand why. While heap.EnumerateObjects() works it will be very slow. I assume an efficient version would work with heap.EnumerateRoots() anyway. Otherwise I will have to optimize for using heap.EnumerateObjects(). But as said I would really like to understand why it is not working using the roots.

EKOlog
  • 432
  • 7
  • 19
Sjoerd222888
  • 2,994
  • 3
  • 28
  • 57

0 Answers0