Memory Related Metrics in C#

Recently we use Grafana to monitor ASP.NET Core apps. We have an interesting observation that sometimes the "allocated memory" is larger than "working set". After investigation, we found the root cause is that the app uses native dlls which operates on unmanaged memory which is not managed by GC. Therefore, how to correctly collect memory related metrics in C#?

We will explain these related concepts in this post.

Basic Concepts

Managed vs Unmanaged Code

# What is "managed code"?

To put it very simply, managed code is just that: code whose execution is managed by a runtime.

# Interoperating with unmanaged code

Code that executes under the control of the runtime is called managed code. Conversely, code that runs outside the runtime is called unmanaged code. COM components, ActiveX interfaces, and Windows API functions are examples of unmanaged code.

Unsafe Code

# Unsafe code and pointers

In the common language runtime (CLR), unsafe code is referred to as unverifiable code.

Please note that unmanaged != unsafe. According to their definitions, unsafe code still runs on top of CLR, while unmanaged code is not managed by CLR. In short, unmanaged code must be unsafe, but unsafe code is still "under manage".

GC.GetTotalMemory

# What is private bytes, virtual bytes, working set?

# GC.GetTotalMemory(Boolean) Method

Retrieves the number of bytes currently thought to be allocated. A parameter indicates whether this method can wait a short interval before returning, to allow the system to collect garbage and finalize objects.

WorkingSet

# Process.MaxWorkingSet Property

The working set of a process is the set of memory pages currently visible to the process in physical RAM memory. These pages are resident and available for an application to use without triggering a page fault.

Private Bytes

# Process.PrivateMemorySize64 Property

The value returned by this property represents the current size of memory used by the process, in bytes, that cannot be shared with other processes.

Create Unmanaged Objects in C

If we want to test whether a memory API collects unmanaged memory as well, we need to create unmanaged object first. Basically, you can write a native c/c++ dll to directly work with memory. Fortunately, we have an easier way to create unmanaged objects: Marshal Class.

# Marshal Class

Provides a collection of methods for allocating unmanaged memory, copying unmanaged memory blocks, and converting managed to unmanaged types, as well as other miscellaneous methods used when interacting with unmanaged code.

We will use Marshal.AllocHGlobal Method to request unmanaged memory.

# Marshal.AllocHGlobal Method

// Claim 10MB memory from unmanaged memory of the process
IntPtr unmanagedMem = Marshal.AllocHGlobal(10 * 1024 * 1024);

Demo App

using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Initial:");
            GetMetrics();
            var managedMem = new string('x', 10 * 1024 * 1024);
            Thread.Sleep(2000);

            Console.WriteLine("Request 10MB memory in managed heap");
            GetMetrics();
            Thread.Sleep(2000);

            IntPtr unmanagedMem = Marshal.AllocHGlobal(10 * 1024 * 1024);
            // Intended not to call Marshal.FreeHGlobal(unmanagedMem);
            Console.WriteLine("Request 10MB unmanaged memory");
            GetMetrics();
        }

        static void GetMetrics()
        {
            var process = Process.GetCurrentProcess();

            var workingSet = process.WorkingSet64 / 1024 / 1024;
            var gcTotalMemory = GC.GetTotalMemory(true) / 1024 / 1024;
            var privateBytes = process.PrivateMemorySize64 / 1024 / 1024;
            Console.WriteLine($"WorkingSet: {workingSet}MB GCTotalMemory: {gcTotalMemory}MB PrivateBytes: {privateBytes}MB");
            Console.WriteLine();
        }
    }
}

Results:

Initial:
WorkingSet: 14MB GCTotalMemory: 0MB PrivateBytes: 8MB

Request 10MB memory in managed heap
WorkingSet: 34MB GCTotalMemory: 20MB PrivateBytes: 28MB

Request 10MB unmanaged memory
WorkingSet: 34MB GCTotalMemory: 20MB PrivateBytes: 38MB

VS Diagnostic Tools

Easy to find that the statistics is align with VS Diagnostic Tools.

Observations

  • PrivateBytes includes unmanaged memory
  • WorkingSet doesn't include unmanaged memory
  • WorkingSet != GCTotalMemory + PrivateBytes, which means they are not directly related