Thread Rating:
  • 0 Vote(s) - 0 Average
  • 1
  • 2
  • 3
  • 4
  • 5
[DevBlog MS] Creating a memory dump in C#
1
When applications crash in production, how much do we actually know about what happened? And more importantly, how easy is it to debug what happened so that we can fix the bug? Let’s learn how easy it is to capture a memory dump so that we can debug it.

Why Create a Memory Dump

You know that moment when you’re in a desktop application and suddenly it hangs, the screen greys out, and it’s clear the application has stopped responding. Or when you go to a website and you’re sure you clicked on that link, but the browser is just spinning.

While the developer might have added logging and telemetry to that application and be able to follow the execution pathways from that, actually understanding the state of the application can be a lot harder.

This is where a memory dump can be useful. A memory dump can capture the application state, and depending on whether it’s a full or partial dump, you can get a view of objects in memory that are waiting for the garbage collector to clean up, including out-of-scope state that can still provide insights into the broader application behavior.

For this scenario, we’re going to look at an application that is becoming unresponsive, and a common culprit for this kind of issue is how we are using asynchronous code and tasks.

Monitoring the Thread Pool

The pattern that we’re going to use to monitor the thread pool is that we’ll periodically add our own
Code:
Task
to it, observe how long that task takes to complete, and if it took longer than an allowed threshold, we’ll know that the thread pool is likely saturated and probably something we want to capture a dump of.

We’ll create a
Code:
ThreadPoolWatcher
class that will encapsulate this logic:

Code:
[code]internal class ThreadPoolWatcher(string name = "ThreadPool Watcher", int interval = 3_000) { private static readonly object DumpLock = new(); private static int dumpCount; private readonly Thread thread = new(() => Watcher(interval)) { Name = name, IsBackground = true }; private static void Watcher(int interval) { while (true) { Thread.Sleep(interval); Stopwatch stopwatch = Stopwatch.StartNew(); Task task = Task.Run(stopwatch.Stop); if (!task.Wait(interval)) { Console.WriteLine($"Task did not complete within {interval} ms"); } if (stopwatch.ElapsedMilliseconds <= interval) continue; lock (DumpLock) { if (dumpCount++ > 0) { Console.WriteLine("Dump already created for this run; skipping additional dumps."); continue; } } // Took over the interval to complete Console.WriteLine($"Task took too long: {stopwatch.ElapsedMilliseconds} ms"); string path = Path.Combine(AppContext.BaseDirectory, $"fulldump-{Environment.ProcessId}-{DateTime.Now:yyyyMMdd-HHmmss}.dmp"); if (OperatingSystem.IsWindows()) { WindowsDumper.WriteCurrentProcess(path); } else if (OperatingSystem.IsLinux()) { LinuxDumper.WriteCurrentProcess(path); } } } internal void Join() => thread.Join(); internal void Start() => thread.Start(); }[/code]

There are a few things going on in this code, so let’s dissect it a bit.

First, we’re creating a new
Code:
Thread
(which we’re providing a name so we can identify it while debugging) that, when run, will continually invoke the
Code:
Watcher
method. The watcher uses
Code:
Thread.Sleep
to pause for the specified interval between each check.

When the thread wakes up, it adds a new task to the thread pool and measures how long it takes to complete. If the task takes longer than the allowed threshold, it indicates that the thread pool is likely saturated and we may want to capture a memory dump to investigate further. Otherwise, it goes back to sleep. This is a simple way to observe thread-pool behavior in real time by exploiting task timing.

For production use, you should also guard against repeated dump generation. Full dumps can be large and may include credentials, tokens, connection strings, or other sensitive data. Storing them in a restricted directory, adding a cooldown, or limiting the number of files generated is a safer pattern than dumping on every delayed probe.

Then, if the task took longer than the specified interval, we’ll dump the memory of the current process, using either Windows or Linux APIs.

Creating a Windows Memory Dump

On Windows, to create a memory dump of the current process, we’re going to need to call into a native library,
Code:
dbghelp.dll
, and have Windows generate the dump for us.

Code:
[code][SupportedOSPlatform("windows")] internal static class WindowsDumper { [Flags] private enum DumpType : uint { Normal = 0x00000000, WithDataSegs = 0x00000001, WithFullMemory = 0x00000002, WithHandleData = 0x00000004, WithUnloadedModules = 0x00000020, WithFullMemoryInfo = 0x00000800, WithThreadInfo = 0x00001000, WithTokenInformation = 0x00040000, } [DllImport("dbghelp.dll", SetLastError = true, CharSet = CharSet.Unicode)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool MiniDumpWriteDump( IntPtr hProcess, uint processId, SafeHandle hFile, DumpType dumpType, IntPtr exceptionParam, IntPtr userStreamParam, IntPtr callbackParam); /// <summary> /// Writes a full memory dump of the current process. /// </summary> public static void WriteCurrentProcess(string path) { Write(Process.GetCurrentProcess(), path); } /// <summary> /// Writes a full memory dump of <paramref name="process"/> to <paramref name="path"/>. /// </summary> public static void Write(Process process, string path) { ArgumentNullException.ThrowIfNull(process); ArgumentException.ThrowIfNullOrEmpty(path); string? directory = Path.GetDirectoryName(Path.GetFullPath(path)); if (!string.IsNullOrEmpty(directory)) { Directory.CreateDirectory(directory); } using FileStream stream = new(path, FileMode.Create, FileAccess.ReadWrite, FileShare.None); // Full memory dump: entire address space (including the heap), handles, modules and thread state. bool success = MiniDumpWriteDump( process.Handle, (uint)process.Id, stream.SafeFileHandle, DumpType.WithFullMemory | DumpType.WithFullMemoryInfo | DumpType.WithDataSegs | DumpType.WithHandleData | DumpType.WithUnloadedModules | DumpType.WithThreadInfo | DumpType.WithTokenInformation, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero); if (!success) { throw new Win32Exception(Marshal.GetLastWin32Error(), $"MiniDumpWriteDump failed for process {process.Id}."); } } }[/code]

This is a dump class, and because it only works on Windows, we’re annotating it with the
Code:
SupportedOSPlatform("windows")
attribute. Next, there’s an enum that defines the different types of memory dumps that can be created, such as full memory dumps, dumps with handle data, and dumps with thread information. The
Code:
MiniDumpWriteDump
function from
Code:
dbghelp.dll
is then imported to actually perform the dump, and the class provides convenient methods to write a dump of the current process or any specified process.

For this example, we’re adding everything to the memory dump that is generated, which means it will be quite large. In our sample, this produces a dump of approximately 125 MB, although the size depends on the process’s memory usage and selected dump contents.

Creating a Linux Memory Dump

To create an equivalent memory dump on Linux can be a little more difficult as it will depend on the distribution that is used, whether it’s running in a container, and the permissions the process has. Here’s an example of creating a full memory dump using the
Code:
createdump
utility that ships with the .NET runtime.

Code:
[code][SupportedOSPlatform("linux")] internal static class LinuxDumper { // Yama LSM (see /proc/sys/kernel/yama/ptrace_scope). With the default scope of 1 // ("restricted ptrace"), a process may only be ptraced by its own descendants unless // it explicitly designates another process (or PR_SET_PTRACER_ANY) as an allowed // tracer via prctl(PR_SET_PTRACER, ...). "Yama" spelled out in ASCII. private const int PR_SET_PTRACER = 0x59616d61; private static readonly IntPtr PR_SET_PTRACER_ANY = new(-1); [DllImport("libc", SetLastError = true)] private static extern int prctl(int option, IntPtr arg2, IntPtr arg3, IntPtr arg4, IntPtr arg5); public static void WriteCurrentProcess(string path) { AllowAnyProcessToPtraceSelf(); Write(Process.GetCurrentProcess(), path); } /// <summary> /// Best-effort: on distros using the Yama LSM (e.g. Ubuntu/Debian) with the default /// ptrace_scope of 1 ("restricted ptrace"), a process may only be ptraced by its own /// descendants - not the parent that spawned it. createdump attaches to us as our /// child, so we explicitly allow any process to ptrace us. This is a no-op (and /// harmless) on distros where Yama isn't enabled (e.g. many Fedora/RHEL setups), and /// is swallowed entirely if "libc" or prctl can't be resolved at all, which can happen /// on musl-based distros like Alpine that don't ship an unversioned libc.so. /// </summary> private static void AllowAnyProcessToPtraceSelf() { try { _ = prctl(PR_SET_PTRACER, PR_SET_PTRACER_ANY, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero); } catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) { // libc/prctl isn't resolvable this way on this platform (e.g. musl/Alpine) - // fall through and let createdump itself report any real permission failure. } } public static void Write(Process process, string path) { ArgumentNullException.ThrowIfNull(process); ArgumentException.ThrowIfNullOrEmpty(path); string? directory = Path.GetDirectoryName(Path.GetFullPath(path)); if (!string.IsNullOrEmpty(directory)) { Directory.CreateDirectory(directory); } string createDumpPath = FindCreateDump(); using Process createDump = new() { StartInfo = new ProcessStartInfo { FileName = createDumpPath, // --full: entire address space (analogous to MiniDumpWithFullMemory). // -f: explicit output path (createdump would otherwise pick its own name/location). ArgumentList = { "--full", "-f", path, process.Id.ToString(), }, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, }, }; createDump.Start(); string stdout = createDump.StandardOutput.ReadToEnd(); string stderr = createDump.StandardError.ReadToEnd(); createDump.WaitForExit(); if (createDump.ExitCode != 0) { string hint = process.Id != Environment.ProcessId ? " Dumping another process typically requires running as root, the " + "CAP_SYS_PTRACE capability, or /proc/sys/kernel/yama/ptrace_scope set to 0." : " If this is a container, ensure ptrace isn't blocked by seccomp " + "(add --cap-add=SYS_PTRACE) or by an SELinux/AppArmor policy."; throw new InvalidOperationException( $"createdump failed for process {process.Id} with exit code {createDump.ExitCode}.{hint}{Environment.NewLine}{stdout}{stderr}"); } } private static string FindCreateDump() { string runtimeDirectory = RuntimeEnvironment.GetRuntimeDirectory(); string candidate = Path.Combine(runtimeDirectory, "createdump"); if (!File.Exists(candidate)) { throw new FileNotFoundException( $"Could not find the 'createdump' utility next to the runtime directory '{runtimeDirectory}'.", candidate); } return candidate; } }[/code]

This class does a couple of extra things. It uses
Code:
prctl
from
Code:
libc
to allow the child
Code:
createdump
process to attach to its parent under Yama’s restricted ptrace policy, and it locates the
Code:
createdump
utility next to the runtime directory so it can create a full memory dump. In our sample, this produced a dump of approximately 800 MB, although the size depends on the process’s memory usage and the dump configuration.

Simulating a Problem

Now that we can capture memory dumps of our processes, let’s simulate a problem by intentionally causing an issue in our application that we can then analyze using the memory dump.

Code:
[code]internal static class ApplicationRunner { public static void DoLotsOfWork() => Parallel.For(0, 1000, DoSomeWork); private static void DoSomeWork(int i) { Console.WriteLine("Running task {0}", i); Thread.Sleep(10_000); } }[/code]

This code is going to simulate running a lot of parallel tasks, each of them “doing something” that will take a long time to complete, but there’s no restriction on the number of tasks that can be run on the thread-pool, potentially saturating it and causing performance issues or the appearance of the application hanging.

Then we can run our application by creating the
Code:
ThreadPoolWatcher
instance, starting it, and running the workload while the dedicated watcher thread waits for the next probe.

Code:
[code]var tpw = new ThreadPoolWatcher(); tpw.Start(); ApplicationRunner.DoLotsOfWork(); // The application keeps running until the process exits or the watcher is stopped.[/code]

After a while, our application will start to become unresponsive and generate the dump file.

Analyzing the Memory Dump

The
Code:
.dmp
files that are generated can be opened in Visual Studio with managed debugging, allowing us to walk the call stacks, inspect available variables, and view the state of the application at the time the dump was created.

[Image: parallel-stacks-view.webp]

If you want to learn more about analyzing memory dumps and using the parallel stacks view in Visual Studio, you can read the companion article on the Visual Studio blog.

Conclusion

In this article, we’ve seen how easy it can be to have our application create memory dumps when it encounters performance issues or an unresponsive thread pool, allowing us to analyze the state of the application at the time of the problem instead of relying only on logging and reproducing scenarios. Combining this with the Visual Studio tools for analyzing memory dumps, we can gain deep insights into the behavior of our application and more effectively diagnose and resolve complex issues.

By incorporating memory dump generation into our development and monitoring practices, we can proactively address potential performance bottlenecks and hangs, ultimately leading to more robust and reliable applications.

The post Creating a memory dump in C# appeared first on .NET Blog.
Reply


Messages In This Thread
[DevBlog MS] Creating a memory dump in C# - by xSicKxBot - 59 minutes ago


Forum Jump:


Users browsing this thread: 3 Guest(s)