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 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 ThreadPoolWatcher class that will encapsulate this logic:
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();
}
There are a few things going on in this code, so let’s dissect it a bit.
First, we’re creating a new Thread (which we’re providing a name so we can identify it while debugging) that, when run, will continually invoke the Watcher method. The watcher uses 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, dbghelp.dll, and have Windows generate the dump for us.
[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}.");
}
}
}
This is a dump class, and because it only works on Windows, we’re annotating it with the 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 MiniDumpWriteDump function from 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 createdump utility that ships with the .NET runtime.
[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;
}
}
This class does a couple of extra things. It uses prctl from libc to allow the child createdump process to attach to its parent under Yama’s restricted ptrace policy, and it locates the 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.
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);
}
}
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 ThreadPoolWatcher instance, starting it, and running the workload while the dedicated watcher thread waits for the next probe.
var tpw = new ThreadPoolWatcher();
tpw.Start();
ApplicationRunner.DoLotsOfWork();
// The application keeps running until the process exits or the watcher is stopped.
After a while, our application will start to become unresponsive and generate the dump file.
Analyzing the Memory Dump
The .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.

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.
DllImport < LibraryImport.
A bit of a shame to not use that in a blog about modern .NET.
Yeah, that’s fair – I’ve been using DllImport for as long as DllImport has existed and I always forget that it has a modern sibling in LibraryImport (old habits die hard!).
Is it not bad advice to perform minidump on current process, as it will suspend all threads independent of their locks, so it can actually cause a deadlock if a thread is suspended while holding the lock for memory allocation (or other critical application resource)
Even your own documentation says “MiniDumpWriteDump should be called from a separate process if at all possible”:
– http://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/nf-minidumpapiset-minidumpwritedump
It’s not clear to me why the ptrace changes are necessary in the linix version. Per the comments the default with that LSM is that the descendants of the process can trace it, and createdump is being spawned by the process, so it is a descendant..
Certainly that can be useful code to run to allow other non-children process that run as the same user to dump this one. (Like a parent or sibling process).
Hmm, maybe I have it overengineered. I’ll admit I was running this within a Docker container, not Linux bare metal, and I was hitting a problem where it didn’t have the permission to get to the memory in the container. After some digging and prompting, this was the code that was working and I was about to get the dump that could then be opened in Visual Studio to debug the dump (which was my end goal).
I am not sure on the goal of this blog post, every other way to create a memory dump seems more appealing for me. There are the dotnet tools for dumps, there are the environment variables to capture dumps on a crash and there the ClrMD nuget pkg to create in-process dumps. All requires less ceremony.
The post should point out that (depending on the dump type) a PROD dump may contain sensitive information or secrets. It should be handled with care when sharing or moving it.
I don't think it is a good idea to have code for creating self...
Like any good problem, there's many ways to solve it, and you're right that there are tools like `dotnet-dump` that can be used to capture a dump of a running process. A challenge of tools like that is that it can be reactive - you have to go and replicate a problem and be dumping the process while it's happening, whereas this code is proactive - it's "always running" and can catch a problem when it happens before the user knows to report an issue.
The code here is also highly simplistic and part of the intent was to show the...
The dotnet tools are a great way to externally grab a dump, the environment variables method is useful for actual crash dumps. For packages i'd thing you want Microsoft.Diagnostics.NETCore.Client, rather than ClrMD (Microsoft.Diagnostics.Runtime), which is more analysis and . This is the approach used by dotnet-dump on non-windows platforms, but it does work by establishing IPC with the process, and asking it to dump itself.
Nevertheless this code can still be useful when adding that package reference requires more overhead (e.g. some dependency introduction team meeting is needed or whatever).
You are not wrong that trying to take memory dumps from...