Skip to content

Memory Leaks

Jonathan Peppers edited this page Jul 14, 2023 · 15 revisions

Diagnosing Memory Leaks

The purpose of this doc, is to be a guide on:

  • Tooling to identify & diagnose leaks
  • Understanding C# code patterns that cause memory leaks
  • Techniques to fix leaks

We've tried to tag various PRs and Issues related to memory issues at:

Tooling to find leaks

Collecting *.gcdump files

The best way to understand memory usage is to take a "snapshot" of all C# objects in memory at a given time. The dotnet-gcdump tool is one way you can do this for programs running on the CoreCLR runtime.

You can also use Visual Studio while debugging via Debug > Windows > Diagnostic Tools. On the Memory Usage tab, you can take snapshots

Screenshot of Memory Usage Tab

After taking a snapshot, you can open it to view every managed (C#) object in memory and inspect what objects hold references in a tree view:

Screenshot of Managed Memory

This same view is available in Visual Studio when opening *.gcdump files created by dotnet-gcdump or other tooling. *.gcdump files can also be opened in PerfView, but as of writing there is no way to open these files on non-Windows platforms.

Note Although taking memory snaphots while debugging can be quite convenient, you will need to disable XAML hot reload for them to be accurate. In recent versions of Visual Studio, the Managed Memory window will display a warning if you forget this step.

Note You might also consider taking memory snapshots of Release builds, as code paths can be significantly different in configurations where XAML compilation, AOT compilation, and trimming are enabled.

Unfortunately, .NET MAUI apps running on Android, iOS, and macOS are running on the Mono runtime, and so the same support isn't there quite yet. dotnet-gcdump support for the Mono runtime is coming in a future preview of .NET 8:

We are hopeful this will become much easier in the future. For now, we still have a way to record *.gcdump files from the Mono runtime -- just in a much less convenient manner.

To illustrate the process, let's look at how you can record *.gcdump files from a .NET MAUI application running on macOS via Catalyst.

  1. Launch the app with $DOTNET_DiagnosticPorts set:
$ DOTNET_DiagnosticPorts=~/my-dev-port,suspend ./bin/Debug/net7.0-maccatalyst/maccatalyst-x64/MyApp.app/Contents/MacOS/MyApp

In the output directory, macOS applications can be found in the *.app folder (bundle). We can run the main binary directly with the $DOTNET_DiagnosticPorts environment variable set in our terminal. The app will pause at this point, waiting for an instance of dotnet-trace to connect to it.

  1. Launch dotnet-trace with a special "provider" in a different terminal window:
$ dotnet-trace collect --diagnostic-port ~/my-dev-port --providers Microsoft-DotNETRuntimeMonoProfiler:0xC900001:4

This allows the app to fully launch, while dotnet-trace saves a *.nettrace file containing object allocation information from the Mono runtime.

When trying to record a precise snapshot, I normally Ctrl+C dotnet-trace to cancel the first recording. Then I navigate to the appropriate place in the app where the problem occurs and start a fresh recording. Each time dotnet-trace connects to the running app, it will create a fresh *.nettrace file with current snapshot information.

  1. Convert the *.nettrace file to a *.gcdump file we can open in Visual Studio.

There is a tool from Filip Navara we can use for this: mono-gcdump. Clone the source code for this tool locally and build & run it:

dotnet run -- convert my-dev-port.nettrace

This should output a my-dev-port.gcdump file in the same directory the *.nettrace file was located.

To attach dotnet-trace to a running app on iOS & Android, the dotnet-dsrouter tool is used to connect to mobile devices. See further information about using dotnet-trace and dotnet-dsrouter together for specific platforms at:

Patterns that cause leaks

C# events in cross-platform code

Take for example, the cross-platform Grid.ColumnDefinitions property:

public class Grid : Layout, IGridLayout
{
    public static readonly BindableProperty ColumnDefinitionsProperty = BindableProperty.Create("ColumnDefinitions",
        typeof(ColumnDefinitionCollection), typeof(Grid), null, validateValue: (bindable, value) => value != null,
        propertyChanged: UpdateSizeChangedHandlers, defaultValueCreator: bindable =>
        {
            var colDef = new ColumnDefinitionCollection();
            colDef.ItemSizeChanged += ((Grid)bindable).DefinitionsChanged;
            return colDef;
        });

    public ColumnDefinitionCollection ColumnDefinitions
    {
        get { return (ColumnDefinitionCollection)GetValue(ColumnDefinitionsProperty); }
        set { SetValue(ColumnDefinitionsProperty, value); }
    }
  • Grid has a strong reference to its ColumnDefinitionCollection via the BindableProperty.

  • ColumnDefinitionCollection has a strong reference to Grid

If you put a breakpoint on the line with ItemSizeChanged +=, you can see the event has an EventHandler object where the Target is a strong reference back to the Grid.

In some cases, circular references like this are completely OK. The .NET runtime(s)' garbage collectors know how to collect cycles of objects that point each other. When there is no "root" object holding them both, they can both go away.

The problem comes in with object lifetimes: what happens if the ColumnDefinitionCollection lives for the life of the entire application?

Consider the following Style in Application.Resources or Resources/Styles/Styles.xaml:

<Style TargetType="Grid" x:Key="GridStyleWithColumnDefinitions">
    <Setter Property="ColumnDefinitions" Value="18,*"/>
</Style>

If you applied this Style to a Grid on a random Page:

  • Application's main ResourceDictionary holds the Style.
  • The Style holds a ColumnDefinitionCollection.
  • The ColumnDefinitionCollection holds the Grid.
  • Grid unfortunately holds the Page via .Parent.Parent.Parent, etc.

This situation could cause entire Page's to live forever!

Note The issue with Grid is fixed in dotnet/maui#16145, but is an excellent example of illustrating how C# events can go wrong.

Techniques to fix leaks

When to use WeakEventManager

Considering the Grid.ColumnDefinition situation above:

var colDef = new ColumnDefinitionCollection();
colDef.ItemSizeChanged += ((Grid)bindable).DefinitionsChanged;
return colDef;

Since the ItemSizeChanged event is part of .NET MAUI, and the event is non-virtual, we can use WeakEventManager:

readonly WeakEventManager _weakEventManager = new WeakEventManager();

public event EventHandler ItemSizeChanged
{
    add => _weakEventManager.AddEventHandler(value);
    remove => _weakEventManager.RemoveEventHandler(value);
}

To raise the event, we can call in the appropriate place:

_weakEventManager.HandleEvent(this, e, nameof(ItemSizeChanged));

WeakEventManager holds each subscriber as a WeakReference. A WeakReference is a special type that allows the subscriber to be collected by the GC. You can access WeakReference.IsAlive or WeakReference.Target will return null if the object is gone.

Clone this wiki locally