ClrMD를 이용해 메모리 덤프 파일로부터 특정 인스턴스를 참조하고 있는 소유자 확인
지난 글에서,
windbg - 닷넷 응용 프로그램의 메모리 누수 분석
; https://www.sysnet.pe.kr/2/0/11808
문자열이 쌓이고 있는 경우를 살펴봤는데, 분석 시 약간 아쉬운 점이 있습니다. 그 덤프의 힙 상태를 다시 보면,
0:000> !dumpheap -stat
Statistics:
MT Count TotalSize Class Name
00007ff90f456748 1 24 Elasticsearch.Net.ElasticsearchDefaultSerializer
00007ff90f192f78 1 24 System.Collections.Generic.ObjectEqualityComparer`1[[System.Runtime.Caching.MemoryCache, System.Runtime.Caching]]
00007ff90f143038 1 24 System.Collections.Generic.Dictionary`2+ValueCollection[[System.String, mscorlib],[OracleInternal.SelfTuning.OracleTuner+OracleTunerInput, Oracle.ManagedDataAccess]]
00007ff90f0120b8 1 24 System.Net.SSPISecureChannelType
00007ff90f011e78 1 24 System.Net.SSPIAuthType
...[생략]...
00007ff90d3869b8 12753 5127112 System.Int32[]
00007ff90d4ba6e0 159 14555944 System.Int64[]
00007ff90d1aba28 148673 16952928 System.Object[]
00007ff90d1fe828 75889 20114243 System.Byte[]
00007ff90ebd41e0 376168 36112128 TestApi.Data.AddrInfo
00007ff90d1f46f0 2726523 208786640 System.String
000000b69ba94870 33952 274313352 Free
Total 4063087 objects
Fragmented blocks larger than 0.5 MB:
Addr Size Followed by
000000b6a40f38b0 11.4MB 000000b6a4c4d630 System.Byte[]
System.String을 소유하고 있는 객체로 (사용자가 만든 타입인) TestApi.Dat.AddrInfo 인스턴스가 많은 걸로 쉽게 짐작을 했는데요. 이런 식으로 찾아낼 수 없는 경우도 더러 있습니다. 예를 들어, 다음의 소스 코드를 덤프 떠서 살펴보면 어떨까요?
using System;
using System.Collections.Generic;
namespace ConsoleApp1
{
class Program
{
static List<string> _list = new List<string>();
static void Main(string[] args)
{
CreateObjects();
GC.Collect(2);
GC.Collect(2);
Console.WriteLine("Dump!");
Console.ReadKey();
}
private static void CreateObjects()
{
for (int i = 0; i < 100000; i ++)
{
_list.Add(Guid.NewGuid().ToString());
}
}
}
}
string에 대한 소유를 1개의 List 객체가 담당하고 있으므로 heap 상태를 봐도 그 소유자를 쉽게 확인할 수 없습니다.
0:000> !dumpheap -stat
Statistics:
MT Count TotalSize Class Name
719025c4 1 12 System.Collections.Generic.GenericEqualityComparer`1[[System.String, mscorlib]]
71900414 1 12 System.Collections.Generic.ObjectEqualityComparer`1[[System.Type, mscorlib]]
718fde9c 1 16 System.Security.Policy.AssemblyEvidenceFactory
7190be18 1 20 System.IO.Stream+NullStream
71901888 1 20 Microsoft.Win32.SafeHandles.SafeFileHandle
718fdde8 1 20 Microsoft.Win32.SafeHandles.SafePEFileHandle
7190c3dc 1 24 System.IO.TextWriter+SyncTextWriter
71902298 1 24 System.Version
71900994 2 24 System.Int32
71461e00 1 24 System.Collections.Generic.List`1[[System.String, mscorlib]]
7190c2e4 1 28 System.IO.__ConsoleStream
7190c29c 1 28 Microsoft.Win32.Win32Native+InputRecord
...[생략]...
71900958 6 452 System.Int32[]
718fff54 22 616 System.RuntimeType
718ff54c 6 626 System.Char[]
7190316c 1 783 System.Byte[]
71901e50 3 924 System.Globalization.CultureData
718fef34 4 17604 System.Object[]
718ff698 19 524868 System.String[]
00f2b548 24 525636 Free
718feb40 100154 8604850 System.String
Total 100299 objects
물론, "!dumpheap -mt 718feb40" 명령어를 이용해 문자열 인스턴스를 나열할 수 있지만 windbg에서 이 명령어를 수행하면 10만 개가 넘는 인스턴스를 출력하느라 한 세월이 걸립니다. 게다가 그중에서 몇 개를 골라 "!gcroot"로 찾아볼 수 있겠지만 무작위로 선정하는 것이므로 운이 좋아야 List를 가리키는 객체를 알아낼 수 있습니다. 또한, 그 객체가 문자열을 보관하는 (Leak이 없는) 또 다른 List 객체일 수도 있다는 점을 감안하면 찾기가 그리 쉽지 않습니다.
이런 상황을 해결하려면, windbg에서 스크립트를 이용해 10만 개의 System.String에 대한 참조를 유지하고 있는 객체 리스트를 작성하는 것입니다. 대표적으로 pykd를 이용하면 될 텐데요,
windbg에서 python 스크립트 실행하는 방법 - pykd
; https://www.sysnet.pe.kr/2/0/11227
아쉽게도 실제로 수행해보면 출력 결과에 대해 일일이 sos.dll의 명령어로 다시 수행하는 방식이어서 전체 과정이 꽤나 긴 시간을 요구하게 됩니다. 그래서 좀 더 빠른 방법을 찾게 되는데, 바로 그 해답이 ClrMD입니다.
Microsoft.Diagnostics.Runtime
Install-Package Microsoft.Diagnostics.Runtime -Version 1.0.2
아울러 이미 제공되고 있는 풍부한 예제 코드에 따라,
Tutorials - 1. Getting Started - A brief introduction to the API and how to create a CLRRuntime instance.
; https://github.com/Microsoft/clrmd/blob/master/Documentation/GettingStarted.md
Tutorials - 3. Walking objects on the GC heap, working with types in CLR MD.
; https://github.com/Microsoft/clrmd/blob/master/Documentation/WalkingTheHeap.md
이거저거 짜깁기하면 우리가 원하는 목적의 코드를 다음과 같이 간단하게 만들 수 있습니다.
using Microsoft.Diagnostics.Runtime;
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace RefOwner
{
class Program
{
static string _platformPostfix = (IntPtr.Size == 4) ? "32" : "64";
static void Main(string[] args)
{
if (args.Length < 2)
{
Console.WriteLine("RefOwner" + _platformPostfix + " [/d] [dumppath] [typename | address]");
Console.WriteLine("[Sample]");
Console.WriteLine("\tRefOwner" + _platformPostfix + " c:\\temp\\test.dmp System.String");
Console.WriteLine("\tRefOwner" + _platformPostfix + " /d c:\\temp\\test.dmp System.String");
return;
}
string filePath = (args.Length == 3) ? args[1] : args[0];
if (File.Exists(filePath) == false)
{
Console.WriteLine("FILE-NOT-FOUND: " + filePath);
return;
}
string basePath = Path.GetDirectoryName(filePath);
string dmpPath = filePath;
string dacPath = Path.Combine(basePath, "mscordacwks.dll");
string typeNameOrAddress = (args.Length == 3) ? args[2] : args[1];
bool detailed = args.Length == 3 && args[0] == "/d";
using (DataTarget dataTarget = DataTarget.LoadCrashDump(dmpPath))
{
foreach (ClrInfo version in dataTarget.ClrVersions)
{
Console.WriteLine("Found CLR Version: " + version.Version);
// This is the data needed to request the dac from the symbol server:
ModuleInfo dacInfo = version.DacInfo;
Console.WriteLine("Filesize: {0:X}", dacInfo.FileSize);
Console.WriteLine("Timestamp: {0:X}", dacInfo.TimeStamp);
Console.WriteLine("Dac File: {0}", dacInfo.FileName);
string dacLocation = version.LocalMatchingDac;
if (!string.IsNullOrEmpty(dacLocation))
Console.WriteLine("Local dac location: " + dacLocation);
Console.WriteLine();
ClrRuntime runtime;
if (File.Exists(dacPath) == false)
{
runtime = version.CreateRuntime();
}
else
{
runtime = version.CreateRuntime(dacPath);
}
// DumpHeapStat(runtime);
// Console.WriteLine();
DumpHeapRefHierachy(runtime, typeNameOrAddress, detailed, 0);
}
}
}
private static void DumpHeapRefHierachy(ClrRuntime runtime, string typeNameOrAddress, bool detailed, int depth)
{
HashSet<ulong> instances = GetObjectListByName(runtime, typeNameOrAddress);
Console.WriteLine(typeNameOrAddress + ", # of instances: " + instances.Count);
Dictionary<string, HeapObjectCounter> owners = GetObjectOwners(runtime, instances);
List<ObjectHistogramItem> histogram = new List<ObjectHistogramItem>();
foreach (var owner in owners)
{
ObjectHistogramItem item = new ObjectHistogramItem { Key = owner.Key, Counter = owner.Value };
histogram.Add(item);
}
histogram.Sort();
int count = 0;
foreach (var item in histogram)
{
Console.WriteLine($"{item.Counter.Total} {item.Key}({item.Counter.OwnerCount})");
if (detailed == true)
{
foreach (var instance in item.Counter)
{
Console.WriteLine($"\t[{instance.Key:x}, {instance.Value}]");
}
}
count += item.Counter.Total;
}
Console.WriteLine("Total: " + count);
}
private static ulong GetFieldAddressValue(ClrRuntime runtime, ClrInstanceField field, ulong addr)
{
ulong fieldAddress = field.GetAddress(addr);
return ReadMemory(runtime, fieldAddress);
}
private static ulong ReadMemory(ClrRuntime runtime, ulong address)
{
bool x86 = IntPtr.Size == 4;
unsafe
{
byte[] refAddr = null;
if (x86 == true)
{
refAddr = new byte[4];
}
else
{
refAddr = new byte[8];
}
runtime.Heap.ReadMemory(address, refAddr, 0, IntPtr.Size);
if (x86 == true)
{
return BitConverter.ToUInt32(refAddr, 0);
}
else
{
return BitConverter.ToUInt64(refAddr, 0);
}
}
}
private static Dictionary<string, HeapObjectCounter> GetObjectOwners(ClrRuntime runtime, HashSet<ulong> instances)
{
Dictionary<string, HeapObjectCounter> dict = new Dictionary<string, HeapObjectCounter>();
if (!runtime.Heap.CanWalkHeap)
{
Console.WriteLine("Cannot walk the heap!");
}
else
{
foreach (ClrSegment seg in runtime.Heap.Segments)
{
for (ulong obj = seg.FirstObject; obj != 0; obj = seg.NextObject(obj))
{
ClrType type = runtime.Heap.GetObjectType(obj);
if (type == null || type.IsFree == true)
{
continue;
}
string typeName = type.ToString();
int gen = runtime.Heap.GetGeneration(obj);
if (gen != 2)
{
continue;
}
if (type.IsArray == true)
{
int arrayLength = type.GetArrayLength(obj);
for (int i = 0; i < arrayLength; i++)
{
ulong elemAddress = type.GetArrayElementAddress(obj, i);
ClrType elemType = type.ComponentType;
if (elemType.IsValueClass == false)
{
ulong elemRef = ReadMemory(runtime, elemAddress);
if (instances.Contains(elemRef) == true)
{
if (dict.ContainsKey(typeName) == true)
{
dict[typeName].AddCount(obj);
}
else
{
dict.Add(typeName, new HeapObjectCounter(obj));
}
}
}
else
{
AddItem(type, typeName, obj);
}
}
}
else
{
AddItem(type, typeName, obj);
}
}
}
}
return dict;
void AddItem(ClrType type, string typeName, ulong objAddress)
{
foreach (ClrInstanceField field in type.Fields)
{
ulong fieldAddress = GetFieldAddressValue(runtime, field, objAddress);
if (instances.Contains(fieldAddress) == true)
{
if (dict.ContainsKey(typeName) == true)
{
dict[typeName].AddCount(objAddress);
}
else
{
dict.Add(typeName, new HeapObjectCounter(objAddress));
}
}
}
}
}
private static HashSet<ulong> GetObjectListByName(ClrRuntime runtime, string typeNameOrAddress)
{
HashSet<ulong> list = new HashSet<ulong>();
ulong result;
if (UInt64.TryParse(typeNameOrAddress, System.Globalization.NumberStyles.AllowHexSpecifier, null, out result) == true)
{
list.Add(result);
return list;
}
if (!runtime.Heap.CanWalkHeap)
{
Console.WriteLine("Cannot walk the heap!");
}
else
{
foreach (ClrSegment seg in runtime.Heap.Segments)
{
for (ulong obj = seg.FirstObject; obj != 0; obj = seg.NextObject(obj))
{
ClrType type = runtime.Heap.GetObjectType(obj);
if (type == null)
{
continue;
}
int gen = runtime.Heap.GetGeneration(obj);
if (gen != 2)
{
continue;
}
if (type.ToString() != typeNameOrAddress)
{
continue;
}
list.Add(obj);
}
}
}
return list;
}
private static void DumpHeapObject(ClrRuntime runtime)
{
if (!runtime.Heap.CanWalkHeap)
{
Console.WriteLine("Cannot walk the heap!");
}
else
{
foreach (ClrSegment seg in runtime.Heap.Segments)
{
for (ulong obj = seg.FirstObject; obj != 0; obj = seg.NextObject(obj))
{
ClrType type = runtime.Heap.GetObjectType(obj);
if (type == null)
{
continue;
}
ulong size = type.GetSize(obj);
Console.WriteLine("{0,12:X} {1,8:n0} {2,1:n0} {3}", obj, size, seg.GetGeneration(obj), type.Name);
}
}
}
}
private static void DumpHeapStat(ClrRuntime runtime)
{
Console.WriteLine("{0,12} {1,12} {2,12} {3,12} {4,4} {5}", "Start", "End", "CommittedEnd", "ReservedEnd", "Heap", "Type");
foreach (ClrSegment segment in runtime.Heap.Segments)
{
string type;
if (segment.IsEphemeral)
type = "Ephemeral";
else if (segment.IsLarge)
type = "Large";
else
type = "Gen2";
Console.WriteLine("{0,12:X} {1,12:X} {2,12:X} {3,12:X} {4,4} {5}", segment.Start, segment.End, segment.CommittedEnd, segment.ReservedEnd, segment.ProcessorAffinity, type);
}
ClrHeap heap = runtime.Heap;
foreach (var item in (from seg in heap.Segments
group seg by seg.ProcessorAffinity into g
orderby g.Key
select new
{
Heap = g.Key,
Size = g.Sum(p => (uint)p.Length)
}))
{
Console.WriteLine("Heap {0,2}: {1:n0} bytes", item.Heap, item.Size);
}
}
}
public class ObjectHistogramItem : IComparable<ObjectHistogramItem>
{
public string Key;
public HeapObjectCounter Counter;
public int CompareTo(ObjectHistogramItem other)
{
return Counter.Total.CompareTo(other.Counter.Total);
}
}
public class HeapObjectCounter : IEnumerable<KeyValuePair<ulong, int>>
{
Dictionary<ulong, int> _owners = new Dictionary<ulong, int>();
int _total;
public HeapObjectCounter(ulong address)
{
AddCount(address);
}
public int OwnerCount
{
get
{
return _owners.Keys.Count;
}
}
public int Total
{
get
{
return _total; // _owners.Values.Sum();
}
}
public IEnumerator<KeyValuePair<ulong, int>> GetEnumerator()
{
return this._owners.GetEnumerator();
}
internal void AddCount(ulong objAddress)
{
if (_owners.ContainsKey(objAddress) == true)
{
_owners[objAddress]++;
}
else
{
_owners[objAddress] = 1;
}
_total++;
}
IEnumerator IEnumerable.GetEnumerator()
{
return this._owners.GetEnumerator();
}
}
}
그래서 위의 프로그램을 이 글에서 만든 예제 프로젝트의 덤프에 대해 실행하면,
c:\temp\RefOwner\bin> RefOwner32.exe c:\tmp\ConsoleApp1.dmp System.String
Found CLR Version: v4.7.3260.00
Filesize: 6EF000
Timestamp: 5BB7BCB7
Dac File: mscordacwks_X86_X86_4.7.3260.00.dll
Local dac location: C:\Windows\Microsoft.NET\Framework\v4.0.30319\mscordacwks.dll
System.String, # of instances: 100034
1 System.AppDomainSetup(1)
31 System.Object[](3)
100003 System.String[](2)
Total: 100035
총 100,034개의 문자열 인스턴스 중에 무려 100,003개가 단 2개의 System.String[] 객체에 참조되고 있는 것을 볼 수 있습니다. 여기서 "/d" 옵션을 주면 2개의 System.String[]에 대한 인스턴스 주소와 그것들이 각각 참조하고 있는 System.String 객체의 수를 알 수 있습니다.
c:\temp\RefOwner\bin> RefOwner32.exe /d c:\tmp\ConsoleApp1.dmp System.String
...[생략]...
System.String, # of instances: 100034
System.String, # of instances: 100034
1 System.AppDomainSetup(1)
[2b714e0, 1]
31 System.Object[](3)
[3b71020, 1]
[3b72300, 2]
[3b72520, 28]
100003 System.String[](2)
[2b7156c, 3]
[3bd5570, 100000]
Total: 100035
보는 바와 같이, 두 번째 인스턴스인 3bd5570 객체가 100,000개의 String을 담고 있는데, 테스트 코드에서 100,000번의 루프로 Guid 문자열을 담은 것과 일치하는군요. ^^
windbg에서 위의 주소에 따라 dump를 해보면,
0:000> !do 3bd5570
Name: System.String[]
MethodTable: 718ff698
EEClass: 714d4b80
Size: 524300(0x8000c) bytes
Array: Rank 1, Number of elements 131072, Type CLASS (Print Array)
Fields:
None
배열의 크기 자체는 131,072라고 합니다. 즉, 그 배열 중에 String이 할당된 것은 100,000개이고 이후의 요소들은 모두 null입니다. 그럼, 여기서 다시 3bd5570 객체의 소유주를 보면,
c:\temp\RefOwner\bin\x86> RefOwner32.exe /d c:\tmp\ConsoleApp1.dmp 3bd5570
Found CLR Version: v4.7.3260.00
Filesize: 6EF000
Timestamp: 5BB7BCB7
Dac File: mscordacwks_X86_X86_4.7.3260.00.dll
Local dac location: C:\Windows\Microsoft.NET\Framework\v4.0.30319\mscordacwks.dll
3bd5570, # of instances: 1
1 System.Collections.Generic.List<System.String>(1)
[2b720a4, 1]
Total: 1
답이 나왔습니다. (만들어 놓고 보니 그런대로 쓸만한 도구가 된 듯합니다. ^^)
참고로, ClrMD 라이브러리 사용 시 다음과 같은 오류가 발생할 수 있습니다.
System.InvalidOperationException
HResult=0x80131509
Message=Mismatched architecture between this process and the dac.
Source=Microsoft.Diagnostics.Runtime
StackTrace:
at Microsoft.Diagnostics.Runtime.DataTarget.ConstructRuntime(ClrInfo clrInfo, String dac) in /_/src/Microsoft.Diagnostics.Runtime/src/DataTargets/DataTarget.cs:line 326
at Microsoft.Diagnostics.Runtime.DataTarget.CreateRuntime(ClrInfo clrInfo, String dacFilename, Boolean ignoreMismatch) in /_/src/Microsoft.Diagnostics.Runtime/src/DataTargets/DataTarget.cs:line 320
at Microsoft.Diagnostics.Runtime.ClrInfo.CreateRuntime(String dacFilename, Boolean ignoreMismatch) in /_/src/Microsoft.Diagnostics.Runtime/src/DataTargets/ClrInfo.cs:line 115
at RefOwner.Program.Main(String[] args) in c:\temp\RefOwner\RefOwner\RefOwner\Program.cs:line 39
이유는? 현재 프로세스는 x86이면서, 분석 대상이 되는 덤프는 x64 프로세스를 뜬 경우에 해당합니다. 따라서 분석하려는 프로세스를 x64로 빌드해 다시 실행하면 정상적으로 동작합니다.
이 글의 소스 코드는
https://github.com/stjeong/RefOwner/에도 있습니다.
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]