高级调试:垃圾回收、线程管理与性能分析
1. 垃圾回收与内存管理
在应用程序中,垃圾回收(Garbage Collection,简称 GC)是一个重要的机制,它负责自动回收不再使用的内存。在某些情况下,可能会出现一些看似错误但实际上是由于非确定性集合导致的问题。
以应用程序 Store 为例,每个事务最初是一个 Item 对象,随后会用这个 Item 事务初始化一个 LargeItem 对象。此时,Item 对象不再需要,但内存并未立即释放,每个 LargeItem 都会在内存中留下一个“幽灵元素”,这就解释了额外的 Item 实例的存在。不过,在下一次垃圾回收时,这些元素会被从内存中移除。
为了说明这一点,提供了一个 Store 应用程序的版本,它有一个“Collect Memory”按钮,点击该按钮会调用
GC.Collect
方法来清除多余的 Item 实例。在测试这个应用程序时,可以在点击“Collect Memory”按钮前后对 Item 实例进行转储,以确认
GC.Collect
方法是否成功清除了这些对象。需要注意的是,一般不建议调用这个方法,因为它是一个开销较大的操作。
| 元素 | 地址 |
|---|---|
| LargeItem2 | 013ae5a4 |
| Item3 | 0x013aadd8 |
| Item2 | 0x013aabb0 |
| Item1 | 0x013a8bd0 |
| 第 2 代开始 | 0x01391000 |
| largeStuff4 | 02f08918 |
| largeStuff3 | 02b37fe8 |
| largeStuff2 | 027676c8 |
| largeStuff1 | 02396da8 |
| largeStuff2 | 027676c8 |
| 大对象堆 | 0x02391000 |
2. 终结化过程
终结化(Finalization)在垃圾回收中起着至关重要的作用,它也会影响垃圾回收操作的性能和效率。在 C# 中,终结化与类的析构函数相关。在 .NET 环境中,C++ 程序员使用的析构函数方法与 C# 有所不同。
Object.Finalize
是 .NET 中的通用析构函数。在 C# 中,
Finalize
会调用类的析构函数;而在 C++ 中,析构函数是确定性调用的,但在垃圾回收过程中,CLR 会以非确定性的方式调用析构函数。因此,不应该直接调用析构函数,并且只能在析构函数中清理非托管资源。如果需要确定性的垃圾回收,可以实现
IDisposable
接口。
析构函数会给对象带来额外的处理开销,这种开销在对象被回收之前就会产生。当对象创建时,GC 会将带有析构函数的对象的引用添加到终结化队列中。对于那些有析构函数但没有挂起引用的对象,至少需要两次垃圾回收才能被回收。
第一次垃圾回收时,可回收对象的引用会从
Finalization
队列转移到
FReachable
队列,这个队列由一个专用线程处理。这些对象会被添加到
FReachable
队列中等待调用析构函数。终结化线程负责调用对象的析构函数,然后将其从
FReachable
队列中移除。此时,对象可以在下一次垃圾回收时被回收并从内存中移除。
使用
!finalizequeue
命令可以生成一个关于等待调用析构函数的对象的报告,这些对象就是
FReachable
队列中的对象。
3. 性能分析器
性能分析器提供了多个计数器,这些计数器可以帮助调试托管应用程序。以下是一些与内存相关的重要计数器:
| 名称 | 描述 |
| — | — |
| Nb. Handles GC | 指向外部资源(如窗口和文件)的 GC 句柄数量 |
| Nb. D’octets dans tous les tas | 为第 0、1、2 代和大对象堆分配的总字节数 |
| Nb. GC induit | 由于
GC.Collect
导致的垃圾回收的最大即时次数 |
| Nb. d’objets épinglés | 在上一次垃圾回收中识别出的固定对象的数量 |
| Nb. de blocs de synchronisation utilisés | 同步块的入口数量 |
| 第 0 代的收集次数 | 第 0 代被垃圾回收处理的次数 |
| 第 1 代的收集次数 | 第 1 代被垃圾回收处理的次数 |
| 第 2 代的收集次数 | 第 2 代被垃圾回收处理的次数 |
| Nb. total d’octets validés | GC 验证的虚拟内存量 |
| Nb. total d’octets réservés | GC 保留的虚拟内存量 |
| Taille tas génération 0 | 为第 0 代分配的最大字节数 |
| Taille tas génération 1 | 为第 1 代分配的最大字节数 |
| Taille tas génération 2 | 为第 2 代分配的最大字节数 |
| Taille du tas des objets volumineux | 当前为大对象堆分配的字节数 |
| % temps dans le GC | 在垃圾回收中花费的时间百分比,每次垃圾回收周期都会更新 |
| Octets alloués /s | 每秒分配的字节数,每次垃圾回收周期都会更新 |
| Finalization Survivors | 经过垃圾回收过程并等待调用析构函数的对象数量 |
| Octets promus génération 0/s | 每秒从第 0 代提升到第 1 代的字节数 |
| Taille tas génération 1 | 第 1 代中的当前字节数 |
| Octets promus génération 1/s | 每秒从第 1 代提升到第 2 代的字节数 |
| Taille tas génération 2 | 第 2 代中的当前字节数 |
| Promoted Finalization - Memory from Gen 0 | 由于等待的终结器而提升到第 1 代的字节数 |
| Promoted Finalization - Memory from Gen 1 | 由于等待的终结器而提升到第 2 代的字节数 |
| Mémoire promue de la génération 0 | 总共提升到第 1 代的字节数,不考虑正在等待终结器的对象 |
| Mémoire promue de la génération 1 | 总共提升到第 2 代的字节数,不考虑正在等待终结器的对象 |
4. 线程管理
多线程应用程序包含多个线程,每个线程代表一个执行路径,并拥有自己的资源,如堆栈、本地存储位置、局部变量和环境块。合理使用线程可以提高应用程序的性能,而错误的实现则可能导致性能下降。
线程管理的核心是线程同步,它涉及到多个任务,如避免竞争条件和控制对资源的访问。如果线程实现不当,可能会导致高利用率或低利用率的情况。
高利用率的情况表现为一个或多个线程几乎消耗了 100% 的 CPU 资源,导致其他线程无法获得足够的 CPU 资源,应用程序看起来像是被阻塞或运行不正常。低利用率则相反,进程和其中的线程只获得很少的 CPU 资源。
高利用率的主要原因有:
- 循环线程占用了所有的 CPU 资源。
- 高优先级的活动线程阻止了低优先级线程被 CPU 考虑。
低利用率的原因有:
- 线程等待永远不可用的资源,导致它们被无限期挂起,只能获得很少的 CPU 资源。
- 线程相互阻塞,都被无限期挂起,获得的 CPU 时间很少。
- 低优先级线程在大量高优先级线程的环境中很难获得 CPU 时间。
- 错误地多次挂起的线程无法重新启动,因此获得的 CPU 时间很少。
除了高利用率和低利用率的问题,多线程同时访问非线程安全的资源也是应用程序崩溃的一个原因。实际上,多线程操作可能存在无数潜在的违规情况,因此线程管理是调试的一个重要领域。
在调试线程时,常用的同步工具包括分析器(Monitor)和互斥体(Mutex)。分析器用于同步对单个资源的访问,这个资源可以是对象、数据结构或算法等。分析器只能在同一个进程中同步线程,而互斥体则可以在多个进程之间同步线程。
在 .NET 中,对象实例有一个额外的字段,称为同步块索引,它表示在同步块表中的索引,用于跟踪分析器。对象的同步块索引的默认值为 0,当对象与监视器关联时,该索引会更新以指向同步块表中的一个条目。在 .NET 2.0 中,如果监视器尚未与同步块关联,则会创建一个 thinlock。使用
!syncblk –all
命令可以显示所有当前的同步块列表,而
!dumpheap -thinlock
命令可以显示 thinlock。
线程同步的标记可以在调用堆栈中找到。如果在调用堆栈中发现
AwareLock.Enter
、
WaitHandle.WaitAll
和
WaitForMultipleObjects
,则表示线程同步正在进行。
-
AwareLock.Enter
:当对象与监视器关联时会调用此方法。可以设置断点
bp mscorwks!AwareLock::Enter
来监控。
-
WaitHandle.WaitAll
:.NET 中大多数同步对象都派生自
WaitHandle
类,如果在托管调用堆栈中发现该类的方法调用,如
WaitOne
和
WaitAll
,则表示即将进行同步。
-
WaitForMultipleObjects
:大多数同步对象的等待操作都来自这个 API,它被广泛用于线程同步。其语法如下:
DWORD WaitForMultipleObjects(DWORD nCount, const HANDLE* lphandles, BOOL bWaitAll, DWORD dwMilliseconds)
在 WinDbg 和 SOS 中调试线程的第一步是执行线程命令。在 WinDbg 中,波浪号(~)是线程命令;在 SOS 中,是
!threads
命令。
以下是 WinDbg 线程命令的执行示例:
0:000> ~
. 0 Id: f7c.f80 Suspend: 1 Teb: 7ffdd000 Unfrozen
1 Id: f7c.f9c Suspend: 1 Teb: 7ffdc000 Unfrozen
2 Id: f7c.fa0 Suspend: 1 Teb: 7ffdb000 Unfrozen
3 Id: f7c.fa4 Suspend: 1 Teb: 7ffda000 Unfrozen
4 Id: f7c.de0 Suspend: 1 Teb: 7ffd9000 Unfrozen
各列依次表示线程编号、进程标识符、线程标识符、中断次数、线程环境块的地址和线程状态。
以下是 SOS 线程命令的执行示例:
0:000> !threads
ThreadCount: 4
UnstartedThread: 1
BackgroundThread: 1
PendingThread: 0
DeadThread: 0
Hosted Runtime: no
PreEmptive GC Alloc Lock
ID OSID ThreadOBJ State GC Context Domain Count APT Exception
0 1 f80 001501f8 6020 Disabled 013c2cf0:013c32bc 001483a8 0 STA
2 2 fa0 00153e40 b220 Enabled 00000000:00000000 001483a8 0 MTA (Finalizer)
4 3 de0 0018b898 b020 Disabled 013b4cdc:013b52bc 001483a8 2 MTA
XXXX 4 0 0018e750 9400 Enabled 00000000:00000000 001483a8 0 Ukn
关于托管线程的信息如下:
- ID:非托管线程编号。
- OSID:托管线程编号。
- ThreadObject:相关线程对象的地址。
- State:线程状态。
- Preemptive GC:线程是否需要垃圾回收。
- AppDomain:托管线程的 AppDomain 地址。
- Lock Count:锁的数量。
- APT:隔离模式。
5. 线程调试示例
5.1 第一个示例
在应用程序 Store 中添加了多线程功能,有两个额外的按钮:“Enumerate” 按钮将事务写入
forward.txt
文件,“Reverse Enumerate” 按钮将事务以相反的顺序写入
reverse.txt
文件。每个按钮的处理程序都会创建并启动一个线程来完成其功能。
操作步骤如下:
1. 关闭所有 Store 应用程序的实例。
2. 启动 Store 应用程序并添加三个事务,点击 “Enumerate” 按钮将事务写入
forward.txt
文件,关闭 Store 应用程序后打开
forward.txt
文件,发现文件为空,但它应该包含三个事务。
3. 再次尝试:重新启动 Store 应用程序并添加三个事务,点击 “Enumerate” 按钮时会抛出未处理的异常,需要进行调查。
4. 再次启动 Store 应用程序,使用
tlist
实用程序获取进程标识符,发现有两个 Store 应用程序在运行,一个旧版本似乎在后台仍然运行,这是典型的线程阻塞问题,导致用户关闭主窗口后应用程序仍处于活动状态。
5. 执行
ADPlus
命令获取 Store 应用程序的转储文件:
adplus -hang -o c:\dumps -p 2696
- 在 WinDbg 中打开获取的转储文件。
- 加载 SOS 调试器扩展并显示托管线程列表:
0:000> .load sos
0:000> !threads
ThreadCount: 3
UnstartedThread: 0
BackgroundThread: 2
PendingThread: 0
DeadThread: 0
Hosted Runtime: no
看起来问题出在线程 4 上,它是唯一有正锁数量的线程。可以使用
!dumpheap –thinlock
命令来查看它在等待什么:
0:000> !dumpheap -thinlock
在上述列表中,两个 thinlock 都由线程 4 持有,线程 4 的对象地址是 0x00199a78,这可能表示存在嵌套锁。可以执行
!dumpmt
命令来查看 thinlock 的方法表,以了解线程 4 在等待什么:
0:000> !dumpmt 00d443e8
EEClass: 79126bb0
Module: 790c2000
Name: System.Collections.Generic.List`1[[Store.Item, Store]]
mdToken: 02000287 (C:\WINDOWS\assembly\GAC_32\mscorlib\2.0.0.0__b77a5c561934e089\mscorlib.dll)
BaseSize: 0x18
ComponentSize: 0x0
Number of IFaces in IFaceMap: 6
Slots in VTable: 30
0:000> !dumpmt 790fa098
EEClass: 790fa034
Module: 790c2000
Name: System.Object
mdToken: 02000002 (C:\WINDOWS\assembly\GAC_32\mscorlib\2.0.0.0__b77a5c561934e089\mscorlib.dll)
BaseSize: 0xc
ComponentSize: 0x0
Number of IFaces in IFaceMap: 0
Slots in VTable: 14
当程序阻塞时,线程 4 在一个 Item 实例和一个 Object 实例上持有锁,没有其他线程持有锁,因此问题可以定位到线程 4。以下是线程 4 的源代码:
private void Forward()
{
lock (items)
{
lock (syncObj)
{
StreamWriter sw = new StreamWriter("forward.txt");
IEnumerator<Item> enumerator = items.GetEnumerator();
while (true)
{
// 代码块
}
while (true)
{
if (enumerator.MoveNext())
{
Item current = enumerator.Current;
string message = current.ItemId + " Product Mask: "
+ ((int)current.Products).ToString();
sw.WriteLine(message);
}
else
{
break;
}
}
sw.Close();
}
}
}
分析这段代码可以发现,
lock
语句是调用
Monitor.Enter
方法的快捷方式。在获取锁后,程序进入了一个无限循环,导致执行无法继续到循环之后,锁无法释放。因此,需要删除多余的
while(true)
循环,然后重新测试程序。在重新编译应用程序之前,需要删除阻塞的进程,之后
forward.txt
文件应该会被创建并包含事务。
5.2 第二个示例
尽管 Store 应用程序似乎可以正常工作,但在某些情况下仍然存在问题。删除
forward.txt
和
reverse.txt
文件,结束所有正在运行的 Store 应用程序会话,启动一个新的会话并添加两个事务。
操作步骤如下:
1. 点击 “Reverse Enumerate” 按钮,然后点击 “Enumerate” 按钮,关闭应用程序后检查
forward.txt
和
reverse.txt
文件,发现两个文件都没有创建,需要找出原因。
2. 重新启动 Store 应用程序,添加两个新的事务,点击 “Reverse Enumerate” 按钮,然后点击 “Enumerate” 按钮。
3. 将 WinDbg 附加到 Store 应用程序。
4. 执行
!threads
命令显示可用线程,发现线程 4 和线程 5 有正锁数量,它们都在等待某些资源:
0:006> !threads
ThreadCount: 4
UnstartedThread: 0
BackgroundThread: 1
PendingThread: 0
DeadThread: 0
Hosted Runtime: no
使用
!syncblk
命令显示同步块条目,发现线程 5 使用一个 Item 实例作为监视器,线程 4 使用一个 Object 实例作为监视器,这确认了线程 4 和线程 5 可能是问题的根源,其他线程没有使用监视器:
0:006> !syncblk
Index SyncBlock MonitorHeld Recursion Owning Thread Info SyncBlock Owner
2 00174854 3 1 00190b98 ccc 5 01392440
System.Collections.Generic.List`1[[Store.Item, Store]]
21 001747f4 3 1 00191f60 2e0 4 01392468 System.Object
-----------------------------
Total 44
CCW 0
RCW 0
ComClassFactory 0
Free 0
-
对线程 4 和线程 5 执行堆栈跟踪,注意到
AwareLock::Enter方法的存在,它是线程同步的标记,同时调用堆栈顶部有WaitForMultipleObjects方法,这表示线程当前被阻塞:
0:004> kb
ChildEBP RetAddr Args to Child
0358f4a0 7c90e9ab 7c8094f2 00000001 0358f4cc ntdll!KiFastSystemCallRet
0358f4a4 7c8094f2 00000001 0358f4cc 00000001 ntdll!ZwWaitForMultipleObjects+0xc
0358f540 79f4aa60 00000001 00174868 00000000 KERNEL32!WaitForMultipleObjectsEx+0x12c
0358f5a8 79f16d92 00000001 00174868 00000000 mscorwks!WaitForMultipleObjectsEx_SO_TOLE
RANT+0x6f
0358f5c8 79f16d03 00000001 00174868 00000000 mscorwks!Thread::DoAppropriateAptStateWai
t+0x3c
0358f64c 79f16b9e 00000001 00174868 00000000 mscorwks!Thread::DoAppropriateWaitWorker+
0x144
0358f69c 79f4a9d9 00000001 00174868 00000000 mscorwks!Thread::DoAppropriateWait+0x40
0358f6f8 79ebc06e ffffffff 00000001 00000000 mscorwks!CLREvent::WaitEx+0xf7
0358f708 7a0fd093 ffffffff 00000001 00000000 mscorwks!CLREvent::Wait+0x17
0358f794 7a0fd28f 00191f60 ffffffff 00191f60 mscorwks!AwareLock::EnterEpilog+0x94
0358f7b0 79f0fe6a 8a20e59b 0358f888 01392214 mscorwks!AwareLock::Enter+0x61
-
检查
WaitForMultipleObjects方法的参数,线程 4 只等待一个同步对象,第二个参数是一个地址,它是描述符数组的指针,对这个参数进行转储以获取同步对象的句柄:
0:004> dd 00174868
00174868 00000658 0000000d 00000000 00000000
00174878 00000000 00000000 00000000 00000000
00174888 00000000 00000000 00000000 80000023
-
同步对象的句柄是 0x00000658,执行
!handle命令获取该描述符的更多信息,发现它是一个事件描述符:
0:004> !handle 00000658
Handle 658
Type Event
- 对线程 5 重复步骤 1 到 7,结果应该类似。
问题出在线程 4 和线程 5 上,这两个线程分别在
Forward
和
Reverse
方法中启动。以下是这两个方法的源代码:
private void Forward()
{
lock (items)
{
lock (syncObj)
{
StreamWriter sw = new StreamWriter("forward.txt");
IEnumerator<Item> enumerator = items.GetEnumerator();
while (true)
{
// 代码块
}
sw.Close();
}
}
}
Object syncObj = new Object();
private void Reverse()
{
lock (syncObj)
{
Thread.Sleep(5000);
lock (items)
{
StreamWriter sw = new StreamWriter("reverse.txt");
items.Reverse();
IEnumerator<Item> enumerator = items.GetEnumerator();
while (true)
{
// 代码块
}
items.Reverse();
sw.Close();
}
}
}
可以看到,
Forward
和
Reverse
方法中的锁顺序相反,这导致了阻塞。通过反转其中一个方法中的锁顺序,可以解决这个问题。重新启动 Store 应用程序并进行测试,问题应该得到解决。
综上所述,在开发和调试多线程应用程序时,需要仔细考虑垃圾回收、终结化过程、线程同步等问题,合理使用性能分析器来监控和优化应用程序的性能。通过以上的示例和方法,可以更好地定位和解决多线程应用程序中可能出现的问题。
高级调试:垃圾回收、线程管理与性能分析
6. 线程同步问题总结与预防
线程同步问题是多线程应用程序中常见且复杂的问题,从前面的示例可以看出,锁的使用不当很容易导致程序出现各种异常情况,如无限循环导致锁无法释放、锁顺序不一致导致线程阻塞等。为了避免这些问题,开发者需要遵循一些最佳实践:
- 避免无限循环 :在使用锁的代码块中,要确保不会进入无限循环,否则锁将无法释放,其他线程将无法获取该锁,从而导致程序阻塞。
- 统一锁顺序 :如果多个线程需要获取多个锁,要确保它们以相同的顺序获取锁,避免因锁顺序不一致而导致死锁。
-
使用
try-finally块 :在获取锁之后,使用try-finally块来确保锁在任何情况下都能被释放,即使在代码块中抛出异常。例如:
lock (items)
{
try
{
// 执行操作
}
finally
{
// 锁会在这里自动释放
}
}
7. 性能分析器的进一步应用
性能分析器提供的计数器可以帮助开发者深入了解应用程序的性能瓶颈。通过监控这些计数器,开发者可以及时发现问题并进行优化。以下是一些利用性能分析器计数器进行性能优化的建议:
-
内存管理优化 :
-
如果
Nb. D’octets dans tous les tas持续增长,可能表示存在内存泄漏问题,需要检查代码中是否有未释放的资源。 -
观察
% temps dans le GC计数器,如果该值过高,说明垃圾回收占用了过多的时间,可能需要优化对象的创建和销毁逻辑。
-
如果
-
线程管理优化 :
-
当
Nb. d’objets épinglés数量较多时,可能会影响垃圾回收的效率,需要检查代码中是否有不必要的固定对象。 -
如果
Nb. GC induit频繁增加,说明GC.Collect被频繁调用,这会带来较大的性能开销,应尽量避免手动调用GC.Collect。
-
当
8. 调试工具命令总结
在调试多线程应用程序时,掌握各种调试工具的命令是非常重要的。以下是一些常用命令的总结:
| 命令 | 描述 |
|---|---|
!finalizequeue
| 生成关于等待调用析构函数的对象的报告 |
!syncblk –all
| 显示所有当前的同步块列表 |
!dumpheap -thinlock
| 显示 thinlock |
!threads
(SOS)
| 显示托管线程列表 |
~
(WinDbg)
| 线程命令 |
bp mscorwks!AwareLock::Enter
|
设置断点监控
AwareLock.Enter
方法调用
|
kb
| 执行堆栈跟踪 |
dd
| 转储内存数据 |
!handle
| 获取描述符的更多信息 |
9. 多线程应用程序开发流程建议
为了开发出稳定、高效的多线程应用程序,建议遵循以下开发流程:
graph LR
A[需求分析] --> B[设计线程模型]
B --> C[编写代码]
C --> D[单元测试]
D --> E[性能测试]
E --> F{是否满足性能要求}
F -- 是 --> G[发布上线]
F -- 否 --> H[性能分析与优化]
H --> C
- 需求分析 :明确应用程序的功能需求,确定是否需要使用多线程来提高性能。
- 设计线程模型 :根据需求设计合理的线程模型,包括线程的数量、线程的职责以及线程之间的同步方式。
- 编写代码 :按照设计好的线程模型编写代码,注意遵循线程同步的最佳实践。
- 单元测试 :对每个线程和线程同步逻辑进行单元测试,确保代码的正确性。
- 性能测试 :使用性能分析器对应用程序进行性能测试,收集性能数据。
- 性能分析与优化 :根据性能测试结果,分析性能瓶颈并进行优化。
- 发布上线 :经过充分测试和优化后,将应用程序发布上线。
10. 总结
多线程应用程序的开发和调试是一个复杂的过程,涉及到垃圾回收、终结化过程、线程同步等多个方面。通过合理使用性能分析器和调试工具,遵循线程同步的最佳实践,以及按照科学的开发流程进行开发,可以有效地提高多线程应用程序的性能和稳定性。同时,开发者需要不断学习和实践,积累经验,才能更好地应对多线程开发中遇到的各种问题。
在实际开发中,要时刻关注应用程序的性能指标,及时发现并解决潜在的问题。通过不断优化代码和调整线程模型,确保应用程序在各种环境下都能稳定运行,为用户提供良好的体验。
343



被折叠的 条评论
为什么被折叠?



