.NET Debugging Demos Lab 7: Memory Leak - Walkthrough
We have reached the end of the .NET Debugging Labs series. And we are going to end it with a walkthrough of the last memory leak lab.
I am interested to get feedback, either in comments or through email in the contact me section, on what you liked, what you didn’t like. If you felt that it was worth your while etc. so I know if there is an interest in possibly doing similar lab series in the future. It will probably be a while before I would post a new series but if there is interest at least I can start thinking about it. If that is the case, let me know what you thought was missing from this one so that I can put some good scenarios in the next one.
Previous labs and setup instructions
If you are new to the debugging labs, here you can find information on how to set up the labs as well as links to the previous labs in the series.
- Information and setup instructions
- Lab 1: Hang
- Lab 2: Crash
- Lab 3: Memory
- Lab 4: High CPU hang
- Lab 5: Crash
- Lab 6: Memory
Review the performance counters to figure out what we are leaking
-
Check
Working Set
,GC Heap Size
andNumber of Assemblies Loaded
- Can you tell if the issue we are facing is a virtual bytes leak, a native leak or a .NET leak?
The numbers seem to follow each other so it looks like a .net memory issue
- Can you tell if the issue we are facing is a virtual bytes leak, a native leak or a .NET leak?
Debug the memory dump
-
Open the memory dump in WinDbg, load up the symbols and load sos.dll (see information and setup instructions for more info)
- What is the size of the memory dump (on disk)
~770 MB
- What is the size of the memory dump (on disk)
-
Run
!eeheap -gc
and!dumpheap -stat
0:000> !eeheap -gc Number of GC Heaps: 8 ------------------------------ Heap 0 (000001EF5C8E8EE0) generation 0 starts at 0x000001EF5CD66400 generation 1 starts at 0x000001EF5CCA1018 generation 2 starts at 0x000001EF5CCA1000 ephemeral segment allocation context: none segment begin allocated size 000001EF5CCA0000 000001EF5CCA1000 000001EF5CD7A418 0xd9418(889880) Large object heap starts at 0x000001F35CCA1000 segment begin allocated size 000001F35CCA0000 000001F35CCA1000 000001F35FB9AEC0 0x2ef9ec0(49258176) Heap Size: Size: 0x2fd32d8 (50148056) bytes. ------------------------------ Heap 1 (000001EF5C9100E0) generation 0 starts at 0x000001EFDCD02A08 generation 1 starts at 0x000001EFDCCA1018 generation 2 starts at 0x000001EFDCCA1000 ephemeral segment allocation context: none segment begin allocated size 000001EFDCCA0000 000001EFDCCA1000 000001EFDCEF4A20 0x253a20(2439712) Large object heap starts at 0x000001F36CCA1000 segment begin allocated size 000001F36CCA0000 000001F36CCA1000 000001F36FB2CBC8 0x2e8bbc8(48806856) Heap Size: Size: 0x30df5e8 (51246568) bytes. ------------------------------ ... ------------------------------ Heap 7 (000001F3E86D84F0) generation 0 starts at 0x000001F2DCD80CA8 generation 1 starts at 0x000001F2DCCA1018 generation 2 starts at 0x000001F2DCCA1000 ephemeral segment allocation context: none segment begin allocated size 000001F2DCCA0000 000001F2DCCA1000 000001F2DCD8ECC0 0xedcc0(974016) Large object heap starts at 0x000001F3CCCA1000 segment begin allocated size 000001F3CCCA0000 000001F3CCCA1000 000001F3CFB8EAB8 0x2eedab8(49207992) Heap Size: Size: 0x2fdb778 (50182008) bytes. ------------------------------ GC Heap Size: Size: 0x18c53f18 (415579928) bytes.
- What is the size of the .NET heap according to !eeheap -gc?
Around 415 MB - so quite a bit of the memory goes here
- Is most of the memory stored on the regular heaps or on the large object heap?
Looks like the large object heap is very heavily used
We saw from the performance counters that we appeared to be leaking .net memory, so the next step is to determine what the memory is used for.
- What is the size of the .NET heap according to !eeheap -gc?
-
Run
!dumpheap -stat
0:000> !dumpheap -stat Statistics: MT Count TotalSize Class Name ... 00007ffcfcadbe00 1002 104208 BuggyBits.Controllers.NewsController 00007ffcfc3997a8 1472 105984 System.Collections.Generic.Dictionary`2[[System.String, System.Private.CoreLib],[System.Object, System.Private.CoreLib]] 00007ffcfcc509f0 1698 108672 System.Collections.Generic.HashSet`1[[System.Object, System.Private.CoreLib]] 00007ffcfcd62400 1849 118336 Microsoft.AspNetCore.Mvc.Routing.UrlActionContext 00007ffcfcd48fd0 3007 120280 Microsoft.Extensions.Internal.CopyOnWriteDictionary`2[[System.Object, System.Private.CoreLib],[System.Object, System.Private.CoreLib]] 00007ffcfcc687c0 2556 122688 System.Collections.Concurrent.ConcurrentDictionary`2+Node[[System.Object, System.Private.CoreLib],[Microsoft.Extensions.Caching.Memory.CacheEntry, Microsoft.Extensions.Caching.Memory]] 00007ffcfce031a0 1421 125048 System.Diagnostics.Tracing.IncrementingCounterPayload 00007ffcfccad320 3938 126016 Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers.ViewBufferPage 00007ffcfcba0298 1002 128256 Microsoft.AspNetCore.Http.DefaultHttpContext 00007ffcfcd60768 1853 129800 Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers.ViewBufferPage[] 00007ffcfcd49dd8 3488 139520 Microsoft.AspNetCore.Razor.TagHelpers.TagHelperAttribute 00007ffcfcb6fd10 232 142912 Microsoft.AspNetCore.Server.IIS.Core.IISHttpContextOfT`1[[Microsoft.AspNetCore.Hosting.HostingApplication+Context, Microsoft.AspNetCore.Hosting]] 000001ef5c3a6b70 4825 145952 Free 00007ffcfcd46dc0 2325 148800 System.Func`1[[System.Threading.Tasks.Task, System.Private.CoreLib]] 00007ffcfcbbafc0 3468 155344 Microsoft.AspNetCore.Routing.RouteEndpoint[] 00007ffcfc4c8f58 1852 162800 System.Collections.Generic.KeyValuePair`2[[System.String, System.Private.CoreLib],[System.String, System.Private.CoreLib]][] 00007ffcfcd68c80 1851 162824 Microsoft.AspNetCore.Routing.Tree.OutboundMatchResult[] 00007ffcfcd601d0 4168 166720 Microsoft.AspNetCore.Razor.TagHelpers.DefaultTagHelperContent 00007ffcfc3f0db0 1747 181688 System.Reflection.RuntimeMethodInfo 00007ffcfcc67a68 1007 193344 Microsoft.Extensions.Caching.Memory.CacheEntry 00007ffcfcaed2e0 3936 220416 Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers.ViewBuffer 00007ffcfcadde08 1856 222720 Microsoft.AspNetCore.Mvc.TagHelpers.AnchorTagHelper 00007ffcfc2d6618 560 224136 System.Object[] 00007ffcfc392aa8 3873 233464 System.SByte[] 00007ffcfcdfb628 2436 253344 System.Diagnostics.Tracing.CounterPayload 00007ffcfcc88000 3934 314720 Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers.ViewBufferTextWriter 00007ffcfc3d3058 995 358016 System.Char[] 00007ffcfc9e4fa8 9122 364880 Microsoft.AspNetCore.Routing.RouteValueDictionary 00007ffcfcb53bf8 8922 691920 System.Collections.Generic.KeyValuePair`2[[System.String, System.Private.CoreLib],[System.Object, System.Private.CoreLib]][] 00007ffcfc392360 457 731633 System.Byte[] 00007ffcfc391e18 16450 1108462 System.String 00007ffcfc3af090 2352 400900028 System.Int32[]
- What types of objects seem to use up most of the memory?
out of the approximately 417 MB, 400 MB are locked up in Int32 arrays, so if we could just figure out what is causing these (or the largest of these arrays) to stick around we would probably resolve the issue.
- Looking at the 10-20 bottommost object types in
!dumpheap -stat
, can you see any patterns among the objects? I.e. can they be grouped in any way?Most of the 10-20 bottommost object types are UI or request related, i.e. objects that you would typically see hooked up to an asp.net page or an asp.net request.
Normally you will be able to see patterns such as many data related objects, many ui related objects, many xml related objects etc. like in this memory investigation
- Looking at the 10-20 bottom most object types in
!dumpheap -stat
, do the quantities of each of them seem normal or does anything seem out of the ordinary?The most obvious one is the Int32 arrays, but the fact that we have 1002
NewsController
objects is also very curious. We ran the test with 1000 requests, and I had browsed to the News part once or twice before running the test to start up the iisexpress.exe process so it looks like all the requests are still sticking around. Normally you should expect to see only the requests that are currently executing stick around or perhaps a few more, but definitely not this many. If I run~* e !clrstack
to see all .net stacks I have no requests currently executing.
- What types of objects seem to use up most of the memory?
-
Dump the large objects using
!dumpheap -min 0n85000
0:000> !dumpheap -min 0n85000 Address MT Size ... 000001f3cfa69ab0 00007ffcfc3af090 400024 000001f3cfacb568 00007ffcfc3af090 400024 000001f3cfb2d020 00007ffcfc3af090 400024 Statistics: MT Count TotalSize Class Name 00007ffcfc392360 1 131096 System.Byte[] 00007ffcfc3af090 1002 400824048 System.Int32[] Total 1003 objects
-
What type of objects are stored on the Large Object Heap (LOH)?
Most (or all in this case) LOH objects are Int32 arrays and they all seem to have approximately the same size. What is even more interesting is that there are 1002 of them (same number as our aspx pages) so they could be connected somehow.
-
-
Take any of the objects on the LOH and run
!gcroot <object address>
to find out where they are rooted0:000> !gcroot 000001f3cfb2d020 Thread 5560: 0000007B1C5FF050 00007FFD5B7A3542 System.Threading.Tasks.Task.SpinThenBlockingWait(Int32, System.Threading.CancellationToken) [/_/src/System.Private.CoreLib/shared/System/Threading/Tasks/Task.cs @ 2922] rbp+10: 0000007b1c5ff0c0 ... -> 000001F15CD231D8 System.Collections.Concurrent.ConcurrentDictionary`2+Tables[[System.Object, System.Private.CoreLib],[Microsoft.Extensions.Caching.Memory.CacheEntry, Microsoft.Extensions.Caching.Memory]] -> 000001F15CD103F8 System.Collections.Concurrent.ConcurrentDictionary`2+Node[[System.Object, System.Private.CoreLib],[Microsoft.Extensions.Caching.Memory.CacheEntry, Microsoft.Extensions.Caching.Memory]][] -> 000001F0DCE337A0 System.Collections.Concurrent.ConcurrentDictionary`2+Node[[System.Object, System.Private.CoreLib],[Microsoft.Extensions.Caching.Memory.CacheEntry, Microsoft.Extensions.Caching.Memory]] -> 000001F0DCE33450 Microsoft.Extensions.Caching.Memory.CacheEntry -> 000001F0DCE33690 System.Collections.Generic.List`1[[Microsoft.Extensions.Caching.Memory.PostEvictionCallbackRegistration, Microsoft.Extensions.Caching.Abstractions]] -> 000001F0DCE336B0 Microsoft.Extensions.Caching.Memory.PostEvictionCallbackRegistration[] -> 000001F0DCE33670 Microsoft.Extensions.Caching.Memory.PostEvictionCallbackRegistration -> 000001F0DCE33630 Microsoft.Extensions.Caching.Memory.PostEvictionDelegate -> 000001F0DCE33140 BuggyBits.Controllers.NewsController -> 000001F3CFB2D020 System.Int32[] Found 1 unique roots (run '!gcroot -all' to see all roots).
-
What types of roots would you normally see when running !gcroot and what do they mean?
GCRoot will check all “roots” in the process and if the object you are looking for is in any of the “root chains” it will list this root chain.
…
Look at the root chain of the object that you did
!gcroot
on.-
Why is it sticking around? How could the root chain be broken?
The Int32[] is a member variable of a news_aspx page, and this in turn is a member variable of a CacheItemRemovedCallback which is referenced by a CacheEntry so as long as the cache entry exists and the link is not severed the object will stay around. The question is really what is a CacheItemRemovedCallback and why is the page referenced there…
Let’s look at the objects involved in a bit more detail…
0:000> !do 000001F0DCE33450 Name: Microsoft.Extensions.Caching.Memory.CacheEntry MethodTable: 00007ffcfcc67a68 EEClass: 00007ffcfcc735b0 Size: 192(0xc0) bytes File: C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App\3.1.11\Microsoft.Extensions.Caching.Memory.dll Fields: MT Field Offset Type VT Attr Value Name ... 00007ffcfc2d0af0 4000010 38 System.Object 0 instance 000001f0dce336e8 _value ... 00007ffcfc2d0af0 4000014 48 System.Object 0 instance 000001f0dce333b0 <Key>k__BackingField .... 00007ffcfc49b420 4000004 10 ...Private.CoreLib]] 0 static 000001f25cd15500 ExpirationCallback 0:000> !DumpObj /d 000001f0dce336e8 Name: System.String MethodTable: 00007ffcfc391e18 EEClass: 00007ffcfc382150 Size: 78(0x4e) bytes File: C:\Program Files\dotnet\shared\Microsoft.NETCore.App\3.1.11\System.Private.CoreLib.dll String: New site launched 2008-02-02 Fields: MT Field Offset Type VT Attr Value Name 00007ffcfc2db1f0 4000242 8 System.Int32 1 instance 28 _stringLength 00007ffcfc2d8008 4000243 c System.Char 1 instance 4e _firstChar 00007ffcfc391e18 4000244 110 System.String 0 static 000001f2dcca1360 Empty 0:000> !DumpObj /d 000001f0dce333b0 Name: System.String MethodTable: 00007ffcfc391e18 EEClass: 00007ffcfc382150 Size: 94(0x5e) bytes File: C:\Program Files\dotnet\shared\Microsoft.NETCore.App\3.1.11\System.Private.CoreLib.dll String: 3f98f161-6b7a-4144-8ba6-6f2f11892501 Fields: MT Field Offset Type VT Attr Value Name 00007ffcfc2db1f0 4000242 8 System.Int32 1 instance 36 _stringLength 00007ffcfc2d8008 4000243 c System.Char 1 instance 33 _firstChar 00007ffcfc391e18 4000244 110 System.String 0 static 000001f2dcca1360 Empty
The CacheEntry has the cache key
3f98f161-6b7a-4144-8ba6-6f2f11892501
and the valueNew site launched 2008-02-02
.Looking at the NewsController we find
public IActionResult Index() { string key = Guid.NewGuid().ToString(); var cachedResult = cache.GetOrCreate(key, cacheEntry => { cacheEntry.SlidingExpiration = TimeSpan.FromMinutes(5); cacheEntry.RegisterPostEvictionCallback(CacheRemovedCallback); cacheEntry.Priority = CacheItemPriority.NeverRemove; return new string("New site launched 2008-02-02"); }); var news = new List<News> { new News() { Title = cachedResult } }; return View(news); }
The next item in the root chain was the PostEvictionDelegate and that is linked to the CacheEntry as the _target, looking at the code above we see that we do in fact register a PostEvictionCallback in the Cache.Add method, so that explains why it is linked like this.
0:000> !do 000001F0DCE33630 Name: Microsoft.Extensions.Caching.Memory.PostEvictionDelegate MethodTable: 00007ffcfcdd9450 EEClass: 00007ffcfcdce548 Size: 64(0x40) bytes File: C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App\3.1.11\Microsoft.Extensions.Caching.Abstractions.dll Fields: MT Field Offset Type VT Attr Value Name 00007ffcfc2d0af0 400012a 8 System.Object 0 instance 000001f0dce33140 _target 00007ffcfc2d0af0 400012b 10 System.Object 0 instance 0000000000000000 _methodBase 00007ffcfc2decc8 400012c 18 System.IntPtr 1 instance 00007FFCFCA0A1E8 _methodPtr 00007ffcfc2decc8 400012d 20 System.IntPtr 1 instance 0000000000000000 _methodPtrAux 00007ffcfc2d0af0 40001bc 28 System.Object 0 instance 0000000000000000 _invocationList 00007ffcfc2decc8 40001bd 30 System.IntPtr 1 instance 0000000000000000 _invocationCount
The target member variable points to a NewsController. The _target member variable for a delegate/event handler is the object instance that should be used to call the event handler.
Going back to the code above, the method that handles the PostEvictionCallback (i.e. the function to be called when the cached object is removed from cache) is this.CacheRemovedCallback. In other words, our target is
_this
(which is the NewsController itself, and the _methodPtr should point toCacheRemovedCallback
. So the Controller will be linked to the cacheEntry as long as it is still in cache in order to be able to call the CacheRemovedCallback when it goes out of scope.Finally - the Int32[] is just a member variable of the NewsController
-
Putting it all together and determining the cause of the memory leak
-
Look at the code for the News controller and use the knowledge you gathered from the debugging session to figure out how the leak was generated.
- How long will the objects stick around?
Until the object is dropped from cache, in this case 5 minutes
- What can you do to avoid this type of event handler leak?
Any time you hook up an event handler to a static object or a long lived object it is better to hook up a static method as the event handler rather than an instance method. That way there is no object that needs to stick around for the event handler to still be valid.
In this particular case, every time we hit the News page and add the item to cache we will “store” an instance of the NewsController in cache and everything it references. The easy solution here would be to make the CacheRemovedCallback static and call NewsController.CacheRemovedCallback rather than this.CacheRemovedCallback.
I have described this particular issue and another similar one in the following articles:
- .NET Memory Leak Case Study: The Event Handlers That Made The Memory Balloon
- ASP.NET Quiz Answers: Does Page.Cache leak memory?
You should also be careful to not store web controls or any other objects in cache that may have references that you are not aware of. Storing a web control in cache for example will also store a page in cache since the web control usually has a
_parent
property that points to the aspx page.
- How long will the objects stick around?
Have fun, Tess