Advanced .Net Debugging 8:线程同步

一、介绍
这是我的《 Advanced .Net Debugging 》这个系列的第八篇文章。这篇文章的内容是原书的第二部分的【调试实战】的第六章【同步】。我们经常写一些多线程的应用程序,写的多了,有关多线程的问题出现的也就多了,因此,最迫切的任务就是提高解决多线程同步问题的能力。这一节我们将从本质上、从底层上来介绍线程的同步组件和同步原理,也会给出在多线程环境下如何解决问题的最佳实践。高级调试会涉及很多方面的内容,你对 .NET 基础知识掌握越全面、细节越底层,调试成功的几率越大,当我们遇到各种奇葩问题的时候才不会手足无措。
如果在没有说明的情况下,所有代码的测试环境都是 Net 8.0,如果有变动,我会在项目章节里进行说明。好了,废话不多说,开始我们今天的调试工作。

调试环境我需要进行说明,以防大家不清楚,具体情况我已经罗列出来。
操作系统:Windows Professional 10
调试工具:Windbg Preview(Debugger Client:1.2306.1401.0,Debugger engine:10.0.25877.1004)和 NTSD(10.0.22621.2428 AMD64)
下载地址:可以去Microsoft Store 去下载
开发工具:Microsoft Visual Studio Community 2022 (64 位) - Current版本 17.8.3
Net 版本:.Net 8.0
CoreCLR源码: 源码下载

在此说明: 我使用了两种调试工具,第一种:Windbg Preivew,图形界面,使用方便,操作顺手,不用担心干扰;第二种是:NTSD,是命令行形式的调试器,在命令使用上和 Windbg 没有任何区别,之所以增加了它的调试过程,不过是我的个人爱好,想多了解一些,看看他们有什么区别,为了学习而使用的。如果在工作中,我推荐使用 Windbg Preview,更好用,更方便,也不会出现奇怪问题(我在使用 NTSD 调试断点的时候,不能断住,提示内存不可读,Windbg preview 就没有任何问题)。
如果大家想了解调试过程,二选一即可,当然,我推荐查看【Windbg Preview 调试】。

二、目录结构
为了让大家看的更清楚,也为了自己方便查找,我做了一个目录结构,可以直观的查看文章的布局、内容,可以有针对性查看。
1、 同步的基础知识
A、基础知识
B、眼见为实
1)、KD 和 NTSD 调试
2)、Windbg Preview 调试
2、 线程同步原语
2.1、 事件同步原语(内核锁)
A、 基础知识
B、 眼见为实
1)、 KD 和 NTSD 调试
2)、 Windbg Preview 调试
2.2、 互斥体(内核锁)
A、 基础知识
B、 眼见为实
1)、 KD 和 NTSD 调试
2)、 Windbg Preview 调试
2.3、 信号量(内核锁)
A、 基础知识
B、 眼见为实
1)、 KD 和 NTSD 调试
2)、 Windbg Preview 调试
2.4、 监视器
A、 基础知识
B、 眼见为实
1)、 NTSD 调试
2)、 Windbg Preview 调试
2.5、 读写锁
A、 基础知识
B、 眼见为实
1)、 NTSD  调试
2)、 Windbg Preview 调试
2.6、 线程池

3、 同步的内部细节
3.1、 对象头
3.2、 同步块
A、 基础知识
B、 眼见为实
1)、 NTSD 调试
2)、 Windbg Preview 调试
3.3、 瘦锁
A、 基础知识
B、 眼见为实
1)、 NTSD 调试
2)、 Windbg Preview 调试
4、 同步任务
4.1、 死锁
A、 基础知识
B、 眼见为实
1)、 NTSD 调试
2)、 Windbg Preview 调试
4.2、 孤立锁:异常
A、 基础知识
B、 眼见为实
1)、 NTDS 调试
2)、 Windbg Preview 调试
4.3、 线程中止
4.4、 终结器挂起

三、调试源码
废话不多说,本节是调试的源码部分,没有代码,当然就谈不上测试了,调试必须有载体。
3.1、ExampleCore_6_1

3.2、ExampleCore_6_2

3.3、ExampleCore_6_3

3.4、ExampleCore_6_4

3.5、ExampleCore_6_5

3.6、ExampleCore_6_6

3.7、ExampleCore_6_7

3.8、ExampleCore_6_8

3.9、ExampleCore_6_9

3.10、ExampleCore_6_10


四、基础知识
在这一段内容中,有的小节可能会包含两个部分,分别是 A 和 B,也有可能只包含 A,如果只包含 A 部分,A 字母会省略。A 是【基础知识】,讲解必要的知识点,B 是【眼见为实】,通过调试证明讲解的知识点。

4.1、同步的基础知识
A、基础知识
进程: 它描述了当一个程序在运行起来所需要的资源总和的统称,包括:CPU、内存、磁盘、网络、GPU 等,最明显我们可以通过【任务管理器】查看我们电脑上运行的进程。
线程: 它是应用程序针对用户操作做出反应的最小执行单元,也就是说,应用软件响应用户的任何操作都是通过一个线程完成的。切记,线程是操作系统的资源,不是 CLR 的,鉴于此,线程具有启动、运行和停止不确定性,也就是启动 N 个线程,每次的启动顺序都可能不一样,同一份代码,同一线程执行的时间也是不同的,启动不同,运行不同,当然,结束的时机也是不同的。
句柄: 是用来标识对象或者项目的标识符,可以用来描述窗体、控件、文件等。
多线程: 能够并发的运行任意数量的线程。

在这节开始之前,我们必须先弄懂以上 4 个概念,我用自己的语言解释了一下,如果大家不懂,可以自行去网上恶补了。多线程的应用程序如何设计的好的话,会有三个特征:1、应用程序的用户体验更好,不卡界面;2、应用程序的性能好,处理速度更快;3、多线程具有不确定性,需要我们做更多的工作来协调。

C# 的 Thread 类表示一个线程类,其实,在背后会有一些底层的数据结构做支撑,比如在 CLR 层会有一个对应的线程类生成,同时操作系统层也会有一个数据结构与之对应,所以说,我们简简单单声明一个 Thread 类,会有三个数据结构来承载。
a)、C# 层的 Thread。
C# 中的 Thread 类,其实是对 CLR 层 Thread 线程类的封装,在 C# Thread 类的定义中,会有一个 private IntPtr DONT_USE_InternalThread 实例字段,该字段就是引用的 CLR 层的线程指针引用。

b)、CLR 层的 Thread
Net Core 是开源的,所以是可以看到 CLR 线程 Thread 的定义。类名是:Thread.cpp,Net 5、6、7、8都可以看。

c)、OS 层的 KThread。
操作系统层的线程对象是通过 _KThread 来表示的。

多线程编程有一个无法避免的问题就是同步的问题,在.NET 中实现同步的方式还是挺多的,比如:事件同步、信号量、互斥体、监视器、瘦锁等。

B、眼见为实
调试源码:ExampleCore_6_1
调试任务:我们查看 C# Thread 线程所对应的 OS 层的数据结构表示
我们直接运行的 EXE 应用程序,程序启动成功,在控制台中输出:tid=4,这个值大家可能不一样。程序运行成功,就产生了一个线程对象。我们想要查看内核态线程的id,需要在借助一个【ProcessExplorer】工具,这个工具有32位和64位两个版本,根据自己系统特特性选择合适的版本,我选择的是64位版本的。
效果如图:

接着,我们在过【通过名称过滤(Filter by name)】中输入我们项目的名称:ExampleCore_6_1,来进程查找。效果如图:

接着,我们在进程名上双击,打开进程属性对话框,如图:

我们找到了我们项目进程的主键线程编号,然后就可以使用 Windbg 查看内核态的线程表示了。 我们主线程的编号是:15560,这个是十进制的,要注意。

1)、KD 和 NTSD 调试
说明一下:主线程 ID 不是 15560,我重启了,现在是 2316,效果如图:

我们以管理员身份打开【Visual Studio 2022 Developer Command Prompt v17.9.6】命令行工具,并输入以下命令:【kd -kl】打开调试器。这个是内核调试器,和【NTSD】是有区别的,【NTSD】是用户态的调试器。
如图:

打开的调试器窗口如图:

太多了无用内容了,使用【.cls】清理一下。
执行命令【!process 0 2 ExampleCore_6_1.exe

红色标注就是需要注意的内容,他会把这个进程中的所有线程找出来。我们通过【ProcessExploler】看到我们项目的主线程是:2316,这个值是十进制的,我们看看十六进制是多少。

我再来一个截图显示一下他们的关系,就更清楚了。

ffffa2066e728080 这个值就是线程的内核态的数据结构,我们可以继续使用【dt nt!_KThread ffffa2066e728080】命令查看一下详情。

当然,我们也可以通过【NTSD -pn ExampleCore_6_1.exe】直接查看正在执行中项目,通过【!t】或者【!threads】命令,查看线程三者的对应关系。

ID是 1 就是 C# 的托管线程编号, OSID 的值是 90c 就是操作系统层面的线程的数据结构,ThreadOBJ 就是 CLR 层面的线程。


2)、Windbg Preview 调试
然后,我们打开 Windbg,点击【File】-->【Attach to kernel(附加内核态)】,在右侧选择【local】,就是本机的内核态,点击【ok】按钮,进入调试界面。然后,我们使用【!process】命令查找一下我们的项目。

我们通过【ProcessExploler】看到我们项目的主线程是:1204,这个值是十进制的,我们看看十六进制是多少。

我们如果使用的调试器是【Windbg Preview】,它有一个特性,选择一个文本,和文本内容相同的也会被凸显出来,我们选择 3cc8,发现我们使用【!process】命令的结果中也有被选择了,如图:

ffffa20677bb90c0 这个值就是线程的内核态的数据结构,我们可以继续使用【dt】命令查看一下详情。

这个线程的数据结构内容还是不少的。
我们可以使用【 !thread ffffa20677bb90c0 】命令查看更易阅读的结果。

当然,我们也可以通过 Windbg Preview 直接查看了,我们的项目正在执行中,所以我们可以通过【Attach to process】进入调试界面,然后,通过【!t】或者【!threads】命令,查看线程三者的对应关系。

我们在【!t/threads】命令的结果中,查看【OSID】列,也能看到 3cc8 的标识。ID是1就是C#的托管线程编号, OSID的值是 3cc8 就是操作系统层面的线程的数据结构,ThreadOBJ 就是 CLR 层面的线程。


4.2、线程同步原语
在开始之前,先解释一下以下概念:用户态和内核态,这两个概念不清楚,就会搞得云里雾里的。
用户态:
用户态也被称为用户模式,是指应用程序的运行状态。在这种模式下,应用程序拥有有限的系统资源访问权限,只能在操作系统划定的特定空间内运行。用户态下运行的程序不能直接访问硬件设备或执行特权指令,所有对硬件的访问都必须通过操作系统进行。
在用户态下,应用程序通过系统调用来请求操作系统提供的服务。例如,文件操作、网络通信等都需要通过系统调用来实现。当应用程序发出系统调用时,会触发上下文切换,将CPU的控制权交给操作系统内核,进入内核态。

内核态:
内核态也被称为内核模式或特权模式,是操作系统内核的运行状态。处于内核态的CPU可以执行所有的指令,访问所有的内存地址,拥有最高的权限。内核态下运行的程序可以访问系统的所有资源,包括CPU、内存、I/O等。
在内核态下,操作系统可以响应所有的中断请求,处理硬件事件和系统调用。当应用程序发出系统调用时,CPU会切换到内核态,执行相应的操作,然后返回用户态。此外,当发生严重错误或异常时,也会触发内核态的切换。

4.2.1、事件同步原语(AutoResetEvent 和 ManulResetEvent(内核锁))
A、基础知识
事件同步的本质实在内核态维护了一个 bool 值,通过 bool 值来实现线程间的同步,具体的使用方法网上很多,我这里就不过多的赘述了,这里我们看看是如何通过 bool 值的变化实现线程间的同步的。
事件是一种内核态的原语,可以在用户态中通过句柄来访问。事件也是一个同步对象,它有两种状态:已触发(signaled)和未触发(nonsignaled)。当事件是未触发的状态,在这个事件上的线程就会处于等待的状态,如果事件的状态变为已触发时,这个线程也会恢复执行。
事件对象经常用于对多个线程之间的代码执行流程进行同步。
AutoResetEvent 和 ManulResetEvent 区别: ManulResetEvent 在手动重置事件中,事件对象保持为已触发的状态,直到被手动重置,因此,所有在这个事件对象上等待的线程都会被释放。 AutoResetEvent 自动重置事件只允许其中一个等待线程被释放,然后,又立即自动的回到未触发状态。如果没有任何等待的线程,那么这个事件对象将保持为未触发的状态,直到第一个线程在这个事件上开始等待。

我们都知道 AutoResetEvent 和 ManulResetEvent 的功能就是 Windows 底层的功能,说白了就是 C# 只是使用了 Windows 内核提供的事件,C# 不过是对其进行了包装,如果你想要查看内存地址,必须到内核态去看。

AutoResetEvent 或者 ManulResetEvent 类型内部包含了 SafeWaitHandle 引用类型的一个字段 _waitHandle, _waitHandle 类型内部包含了一个值类型的(System.IntPtr)的 handle 实现的同步操作。

B、眼见为实
调试源码:ExampleCore_6_2
调试任务:我们看看 AutoResetEvent 是如何通过 bool 值变化实现线程间的同步的。
注意:这里的调试都需要用到两种调试器,分别是用户态的和内核态的,还有一个获取对象内核地址的工具【Process Explorer】。在用户态调试器执行调用,在内核态调试器里看具体地址内容的变化。
1)、KD 和 NTSD 调试
在这里,我只测试 ManualResetEvent 类型的变化,AutoResetEvent 暂时我忽略,因为它们没区别。调试器使用用户态的 NTSD 和内核态的 KD。
编译我们的项目,打开【Visual Studio 2022 Developer Command Prompt v17.9.6】命令行工具,输入命令【NTSD E:\Visual Studio 2022\Source\Projects\AdvancedDebug.NetFramework.Test\ExampleCore_6_2\bin\Debug\net8.0\ExampleCore_6_2.exe】打开调试器。
进入调试器后,【g】直接运行,直到调试器输出“ 选择事件模型:1、Manual(手动模式) 2、Auto(自动模式) 3、Exit(退出) ”字样,我们输入 manual,不区分大小写,就进入到了 RunManualResetEvent 方法内,调试器会输出“ mre 默认为 false,即等待状态,请查看! ”字样。调试器中断执行,开始我们的调试了。
首先,我们在托管堆上查找 ManualResetEvent 类型的对象,执行命令【!DumpHeap -type ManualResetEvent】。

ManualResetEvent 对象的地址是 0000020f29414180 ,我们继续使用【!do】或者【!DumpObj】命令查看它的详情。

红色标注的就是一个引用类型实例,地址是 0000020f294142d8 ,针对该地址,继续执行【!do】命令。

红色标注的是一个 handle 对象,我们可以使用【! handle 00000000000002B8 f】命令继续查看,必须具有 f 参数。

到此,说明 ManualResetEvent(false) 默认是等待的状态。
此刻,我们在借助【Process Explorer】工具,找到事件同步对象的内核地址,看看内核地址上的数据的变化。打开这个工具,然后在【Filter by name】输入项目名称 ExampleCore_6_2,结果如图:

我们在【Handles】选项里,找到我们的事件对象,然后双击,打开属性框,找到内核的地址。如图:

我们找到了事件对象在内核上的地址,我们需要再打开一个【kd】调试器,开始内核调试。
我们就找到了内核地址【0xFFFF940C4DC558E0】了。然后,我们到 kd 的内核态中去查看一下这个地址,使用【dp 0xFFFF940C4DC558E0 l1】命令。当前值:0(00000000)

说明 ManualResetEvent 的 fase 表示的是等待,通过用户态命令【!handle 00000000000002B8 f】和内核态命令【dp 0xFFFF940C4DC558E0 l1】都能证明。
然后我们【g】一下用户态的 NTSD 调试器,控制台输出“ mre 默认为 true,即放行状态,请查看! ”字样,再次执行命令【!handle 00000000000002B8 f】。

然后切换到【内核态】的 KD 调试器,继续使用【dp 0xFFFF940C4DC558E0 l1】命令,查看一下。

【!handle】命令的结果是 Set,【dp】命令变成了 00000001,后面的不用管。
最后,我们再【g】一下【用户态】的 KD,控制台输出“ mre Reset后为 false,即等待状态,请查看! ”字样,再次执行【! handle 00000000000002B8 f 】命令。

Reset 后是等待的状态,然后切换到【内核态】的 KD,继续使用【dp 0xFFFF940C4DC558E0 l1】命令,查看一下。

我们就看到了,状态是0和1相互切换的。


2)、Windbg Preview 调试
我们编译项目,打开【Windbg Preview】调试器,点击【文件】----》【Launch executable】加载我们的程序,打开调试器的界面,程序已经处于中断状态。我们使用【g】命令,继续运行程序,在【Debugger.Break()】语句处停止,我们的控制台应用程序输出: mre 默认为 false,即等待状态,请查看! ,Windbg 处于暂停状态,我们就可以调试了。
首先,我们去托管堆中查找一下 ManualResetEvent 这个对象,执行【!dumpheap -type ManualResetEvent】命令。
ManualResetEvent 对象的地址是 012b87014180 ,针对这个地址,我们使用【!do】或者【!DumpObj】命令,查看它的详情。
红色标注的是一个 instance 引用类型(VT=0)实例对象,我们可以使用【!DumpObj 0000012b870142d8 】命令继续查看。

红色标注的是一个 System.IntPtr 值类型(VT=1)实例对象,我们可以使用【! DumpVC 00007ff8da2870a0 0000000000000248 】命令继续查看。

我们可以不使用【!DumpVC】命令,直接使用【!handle】命令。
红色标注的是一个 handle 对象,我们可以使用【! handle 0000000000000248 f】命令继续查看,必须具有 f 参数。

说明 false 是等待的状态,然后,我们继续【g】运行一下,等我们的控制台项目输出: mre 默认为 true,即放行状态,请查看!, 我们继续执行【! handle 0000000000000248 f】命令查看。

然后,我们继续【g】运行一下,等我们的控制台项目输出:mre Reset后为 false,即等待状态,请查看!我们继续执行【! handle 0000000000000248 f】命令查看。

我们再次输入 auto 测试一下 AutoResetEvent。
【g】继续运行,提示【选择事件模型:1、Manual(手动模式) 2、Auto(自动模式) 3、Exit(退出)】,此次,我们输入 auto,控制台程序输出“ are 默认为 false,即等待状态,请查看! ”字样。
我们在托管堆上查找一下 AutoResetEvent 对象,执行命令【!DumpHeap -type AutoResetEvent】。

AutoResetEvent 对象的地址是 012b87014318 ,我们直接使用【!do】或者【!DumpObj】命令查看对象详情。

_waitHandle 是应用类型的实例变量,我们继续使用【!do 0000012b87014330 】命令查看该类型的详情。

SafeWaitHandle 类型内部又包含了一个 handle 类型对象,值是 00000000000002A4 ,针对这个值我们可以使用【!dumpvc】查看,也可以使用【!handle】命令查看。

【g】继续运行,控制台程序输出“ are 默认为 true,即放行状态,请查看! ”字样,再次执行【! handle 00000000000002A4 f 】命令。

【g】继续运行,控制台程序输出“ are Reset 后为 false,即等待状态,请查看! ”字样,再次执行【! handle 00000000000002A4 f】命令。

我们都知道 AutoResetEvent 和 ManulResetEvent 的功能就是 Windows 底层的功能,说白了就是 C# 只是使用了 Windows 内核提供的事件,C# 不过是对其进行了包装,如果你想要查看内存地址,必须到内核态去看。
我们有了句柄的值了 00000000000002A4 ,我们需要借助【Process Explorer】工具找到句柄的内核态地址。打开这个工具,然后在【Filter by name】输入项目名称 ExampleCore_6_2,结果如图:

我们在【ProcessExplorer】工具下面【Handles】选项中找到我的事件对象,然后双击打开属性对话框,如图:

我们就找到了内核地址了。打开一个 Windbg,点击【File】-->【Attach to Kernel】,右侧选择【local】,点击【ok】进入调试器界面。使用【dp 0xFFFF940C4DC47A60】命令。当前值:0( 00000000) ,控制台程序输出“ are 默认为 false,即等待状态,请查看!

切换到用户态 Windbg 继续【g】运行,控制台程序输出“ are 默认为 true,即放行状态,请查看! ”字样。回到内核态 Windbg 继续运行【dp 0xFFFF940C4DC47A60】命令。

然后,我们再【g】一下【用户态】的 Windbg,控制台输出“ are Reset后为 false,即等待状态,请查看! ”字样,当前值:0(00000000),然后切换到【内核态】的Windbg,继续使用【dp】命令,查看一下。

我们就看到了,状态是0和1相互切换的。

4.2.2、互斥体(内核锁)
A、基础知识
互斥体(Mutex)是一个内核态的同步结构,即可以用于对某个进程内的线程进行同步,也可以在多个进程之间进行同步(通过在创建互斥体时指定名称)。通常来说,如果所有同步操作都位于同一个进程内,那么应该使用监视器对象(Monitor/Lock)或者其他的用户态同步原语。而另一方面,如果需要在多个进程之间进行同步,最合适的就是使用命名互斥体了。
由于互斥体是一种内核态结构,因此,用户态代码需要 System.Threading.Mutex 来访问互斥体。
当在用户态中进行调试时,可以使用【!do】或者【!DumpObj】命令来获取关于互斥体更多详细的信息。

在内核态的数据的 0 表示拥有锁,1 表示释放锁。

Mutex 类型内部包含了 SafeWaitHandle 引用类型的一个字段 _waitHandle, _waitHandle 类型内部包含了一个值类型的(System.IntPtr)的 handle 实现的同步操作。

B、眼见为实
调试源码:ExampleCore_6_3
调试任务:分别在用户态和内核态两中情况下 Mutex 值的变化。
由于我们需要在用户态和内核态查看同步对象具体值的变化,需要开启两种调试器,一种是内核态的调试器,一种是用户态的调试器。
1)、KD 和 NTSD 调试
编译项目,打开【Visual Studio 2022 Developer Command Prompt v17.9.6】命令行工具,输入命令【NTSD E:\Visual Studio 2022\Source\Projects\AdvancedDebug.NetFramework.Test\ExampleCore_6_3\bin\Debug\net8.0\ExampleCore_6_3.exe】,打开调试器。
【g】开始运行我们的调试器,直到调试器输出如图,并进入中断模式,就可以开始我们的调试了。效果如图:

我们现在托管堆上查找一下 Mutex 对象,执行【!DumpHeap -type Mutex】命令。

红色标注的就是 Mutex 对象的地址 0000013097009628 ,针对该地址执行【!do 0000013097009628 】命令查看详情。

我们看到了Mutex 类型的内部包含了 SafeWaitHandle 类型的对象 _waitHandle ,地址是 0000013097009780 ,针对该地址继续执行【!do 0000013097009780 】命令查看其详情。

SafeWaitHandle 类型的内部包含了句柄对象 handle ,它的值是 0000000000000290 ,针对该值执行【!handle 0000000000000290 f】命令查看句柄的详情。

我们可以使用【!t】命令验证这一点。

关系如图:

我们看到了用户态下 Mutex 值的变化,也需要看看内核态上数据的变化,因此,我们需要借助【Process Explorer】工具。
具体操作如图:

我们需要双击【ProcessExplorer】下方的【Handles】标红的数据项,打开 Mutex 属性对话框,就能找到内核地址了。

在内核态的地址是 0xFFFFD2824D881CD0,有了地址,我们需要打开【KD】内核调试器,打开【Visual Studio 2022 Developer Command Prompt v17.9.6】命令行工具,数据命令【kd -kl】打开调试器,直接执行命令【dp 0xFFFFD2824D881CD0 l1】。

Mutex 有了锁,内核数据的值是 00000000 。我们需要切换到【NTSD】用户态调试器,继续【g】执行,直到调试器自动进入中断模式。输出如图:

说明此时已经释放了锁,再次执行【!handle 0000000000000290 f】查看句柄的变化。

同样,我们切换到内核【kd】调试器,执行命令【dp 0xFFFFD2824D881CD0 l1】,查看结果。

内核态的数据的值现在是 1 了,说明 Mutex 已经释放了锁。


2)、Windbg Preview 调试
编译项目,打开【Windbg Preview】调试器,依次点击【文件】---【Launch executable】,加载我们的调试项目:ExampleCore_6_3.exe,进入到调试器。
直接使用【g】命令运行调试器,直到我们的控制台程序输出“ 已进入保护区 ”字样,调试器也进入了中断模式。
我们先在堆上查找一下 Mutex 对象,执行【!DumpHeap -type Mutex】命令。

红色标注的 020ea5409628 数据就是 Mutex 对象的地址,然后,执行命令【!do 020ea5409628 】,查看 Mutex 详情。

我们知道了 Mutex 内部还包含了一个 SafeWaitHandle 类型的 _waitHandle ,这个类型是引用类型,我们继续【!do 0000020ea5409780 】命令,查看这句柄类型的信息。

_waitHandle 类型的里面包含了一个值类型的 handle 句柄类型,它的值是 00000000000002A0 。有了句柄的值,我们可以使用【!DumpVC 00007ffecda070a0 00000000000002A0 】命令查看明细,也可以直接使用【!handle 00000000000002A0 f】命令查看。

我们可以使用【!t】命令,证明一下。

效果如图:

此时,我们可以使用【Process Explorer】工具查找一下 Mutex 对象在内核态上的地址,看看内核态地址上的内容的变化。我们打开【Process Explorer】,如图操作:

我们点击【ProcessExplorer】工具【Handles】选项,双击 Mutant 打开属性对话框。效果如图:

我们找到了内核中的数据的地址 0xFFFFD2824D1A5BB0,此时,我们需要再重新打开另外一个【Windbg Preview】,依次点击【文件】---【Attach to kernel】,在右侧选择【local】,进入到调试器。
继续执行命令【dp 0xFFFFD2824D1A5BB0 l1】命令,看看内核数据是怎么表示的。

此时,我们再次切换到用户态的【Windbg Preview】,【g】继续运行调试器,控制台程序会输出“ 正在离开保护区 ”的字样。我们继续执行【 !handle 00000000000002A0 f 】命令,看看是什么结果。

已经执行了 ReleaseMutex 方法了,所以就是释放了锁了。
此时,我们再次切换到内核态的【Windbg Preview】,继续执行【 dp 0xFFFFD2824D1A5BB0 l1 】命令,结果如下:

此时,内核态的数据已经变成 1 了。也就是说在内核态的数据的 0 表示拥有锁,1 表示释放锁。


4.2.3、信号量(内核锁)
A、基础知识
Semaphore(信号量)是一种内核态的同步对象,可以在用户态访问。它类似 Mutex(互斥体),可以实现对资源的互斥访问。它们的区别在于,信号量采用了资源计数,因此可以同时允许 X 个线程访问这个资源。
AutoResetEvent、ManulResetEvent 维护的是 bool 类型的值,信号量本质上就是维护了一个 int 值,这就是两者的区别,我们可以使用 Windbg 来查看一下 waitHandle 的值,可以发现 Semaphore 的 Count 的值在不断的变化。
Semaphore(信号量)可以使用【!do】或者【!DumpObj】命令查看对象信息,也可以使用【!handle】命令查看句柄的信息。

Semaphore 类型内部包含了 SafeWaitHandle 引用类型的一个字段 _waitHandle, _waitHandle 类型内部包含了一个值类型的(System.IntPtr)的 handle 实现的同步操作。

B、眼见为实
调试源码:ExampleCore_6_4
调试任务:分别在用户态和内核态看 Semaphore 值的变化。
1)、KD 和 NTSD 调试
编译项目,打开【Visual Studio 2022 Developer Command Prompt v17.9.6】命令行工具,输入命令【NTSD E:\Visual Studio 2022\Source\Projects\AdvancedDebug.NetFramework.Test\ExampleCore_6_4\bin\Debug\net8.0\ExampleCore_6_4.exe】,打开调试器。
进入调试器后,就可以执行【g】命令运行调试器,直到调试器输出如图就可以开始调试了。

我们现在托管堆上查找一下Semaphore 对象,直接执行【!DumpHeap -type Semaphore】命令。

我们知道了 Semaphore 对象的地址是 000002754fc09628 ,然后执行【!do 000002754fc09628 】命令。

System.Threading.Semaphore 类型内部包含了一个 SafeWaitHandle 类型的域 _waitHandle ,该 _waitHandle 类型的地址是 000002754fc09780 ,我们有了地址,继续执行【!do 000002754fc09780 】命令查看它的详情。

Microsoft.Win32.SafeHandles.SafeWaitHandle 类型内部包含了 System.IntPtr 类型一个域 handle ,它的值是 0000000000000290 ,有了这个值,我们就可以使用【!handle 0000000000000290 f】命令查看句柄的详情了。

内容很简单,就不做过多解释了。这个句柄的值 0000000000000290 要记住,后面找内核地址要使用这个。
我们想要找到句柄的内核地址,必须 借助【ProcessExplorer】工具,操作如图:

双击【ProcessExloprer】下方【Handles】的 Semaphore 记录,打开详情,内核地址就在里面。

handle 句柄的内核地址是 0xFFFFA68F9E3CE2E0,有了地址,我们就可以使用【kd】内核调试器显示数据内容了。
打开【Visual Studio 2022 Developer Command Prompt v17.9.6】命令行工具,输入命令【kd -kl】打开调试器,执行命令【!dp 0xFFFFA68F9E3CE2E0 l4】。效果如图:

我们再次切换到用户态的【NTSD】调试器中,执行【g】命令和【!handle 0000000000000290 f】,查看变化。

我们再切换到内核态【kd】调试器上,执行【dp 0xFFFFA68F9E3CE2E0 l4】命令。

数值已经变为为 3 了,和用户态调试器输出是一致的。我们可以重复多次,每次查看变化,很简单,我就省略了。
我在用户态下执行执行到计数数字 10,然后在执行,看看会不会发生异常。

我们在看看内核态数据的变化,切换到【kd】调试器上,执行命令【dp 0xFFFFA68F9E3CE2E0 l4】。

我们看到内核态的值已经变成 0000000a 了。
我们回到用户态的【NTSD】调试器,继续【g】,看看会发生什么。

我们看到发生了 CLR exception 异常了,和我们期望的一样。


2)、Windbg Preview 调试
编译项目,打开【Windbg Preview】,依次点击【文件】----【Launch executable】,加载我们的项目文件 ExampleCore_6_4.exe,直接进入调试器。
进入到调试器后,【g】直接运行调试器,我们的控制台程序会输出“ 查看当前的 sem 值。 ”字样,调试器会自动进入中断模式,此时,就可以开始我们的调试了。
我们先在托管堆上查找一下 Semaphore 对象是否存在,执行命令【!DumpHeap -type Semaphore】。

我们找到了 Semaphore 对象的地址,有了地址就好办了,我们直接执行【!do 027685409628 】命令,查看它的详情。

System.Threading.Semaphore 内部包含了一个 SafeWaitHandle 类型的 _waitHandle 域,针对该域我们使用【!do 0000027685409780 】命令,查看 _waitHandle 的详情。

Microsoft.Win32.SafeHandles.SafeWaitHandle 内部包含了一个 System.IntPtr 类型的域 handle 。我们有了 handle 的值 0000000000000290 ,就可以使用命令【!handle 0000000000000290 f】查看这个句柄的详情了。

这些都是在用户态调试器下的显示,我们也要看看在内核态下是怎么显示的,记住 handle 的值,后面会用到。
我们想要在内核态想查看数据的变化,必须找到句柄的内核态地址,所以我们要借助【ProcessExplorer】工具,操作如图:

我们在【ProcessExplorer】下方的【Handles】找到 Semaphore 信号量对象,继续双击就可以看到它的内核态的地址。

很简单,就不多说了,我们知道了它的内核地址 0xFFFFA68F9E3E1CE0。此时,我们需要在打开一个【Windbg Preview】,依次点击【文件】----【Attach to kernel】,在窗口的右侧选择【local】,点击【ok】进去调试器,就可以使用【dp 0xFFFFA68F9E3E1CE0 l4】命令查看数据了。

00000002 就是当前值, 00000000`0000000a 就是极限值。
接下来就简单了,我们多次执行用户态的调试器,然后再在内核态调试器里查看变化,一目了然。
我先执行一次用户态下【g】命令,在执行【 !handle 0000000000000290 f 】命令,查看变化。

我们在切换到内核态调试器中执行【 dp 0xFFFFA68F9E3E1CE0 l4 】命令。

00000003 变为 3了。
我们可以继续连续执行同样的命令,查看结果。
当我在用户态执行的时候,当当前计数大于10的时候,会发生异常。

我们在看看内核态的数据,继续执行命令。

当前的计数值就是 10(十六进制 0xa) 了。


4.2.4、监视器(混合锁)
A、基础知识
监视器是一种对某个对象的访问操作进行监视的结构,它能在对象上创建一个锁,因而只有当持有该监视器对象的线程离开监视器对象后,其他线程才能访问。
监视器和其他同步原语不同,它不是对内核 Windows 同步原语进行是简单的封装,而是在 .NET 中定义的类,即:System.Threading.Monitor,Monitor 类不能实例化,而是包含了一组静态方法,用于获取一个锁。Enter 和 Exit 是很常用的两个方法,Enter 用于获取指定对象上的互斥锁,Exit 用于指定对象上的互斥锁。
lock 关键字就是对 Monitor 对象的封装,lock 语句会自动进入一个监视器,并将保护区域内的代码封装在一个 try/finally 块中,以确保监视器在作用域结束后释放锁。
由于 Monitor 类是一个不能被实例化的对象,因此无法看到它的任何状态,锁的信息保存在被锁定的对象中。
监视器是由 C# 中的 AwareLock 实现的,底层是基于 AutoResetEvent 机制,可以参见 coreclr 源码。因为 Monitor 是基于对象头的同步块索引来实现的,我们可以查看对象头的数据结构就可以明白了。

B、眼见为实
调试源码:ExampleCore_6_5
调试任务:我们使用 Windbg 查看 Monitor 的实现
1)、NTSD 调试
编译项目,打开【Visual Studio 2022 Developer Command Prompt v17.9.6】命令行工具,输入命令【NTSD E:\Visual Studio 2022\Source\Projects\AdvancedDebug.NetFramework.Test\ExampleCore_6_5\bin\Debug\net8.0\ExampleCore_6_5.exe】打开调试器。【g】直接运行调试器,调试器会输出“ 4 已进入 Person 锁中 111111 ”字样,自动进入中断模式,现在,就可以开始我们的调试了。如图:

因为我们知道是锁的问题,所以可以直接执行【!syncblk】命令。

我们说过 Monitor 的底层实现就是 AwareLock,这个标红 0000015A8405C070 地址就是指向  AwareLock。我们使用【dt coreclr! AwareLock 0000015A8405C070 】命令查看一番。

我们继续使用【dx -r1 (*((coreclr!CLREvent *) XXXXXXXXX ))】命令查看 m_SemEvent 是什么。 XXXXXXXXX 是 m_SemEvent 的地址,我没有算出来,下面的步骤就没办法进行了。在【Windbg Preview】里是直接可以点击查看的,这就是【Windbg】和 命令行工具的区别。


2)、Windbg Preview 调试
编译项目,打开【Windbg Preview】,依次点击【文件】----【Launch executable】,加载我们的项目文件 ExampleCore_6_5.exe,进入到调试器。
我们使用【g】命令,继续运行调试器,我们的控制台程序输出:6 已进入 Person 锁中 222222(这里不一定是这个,我的输出是这个),Windbg 有一个 int 3 中断,就可以调试程序了。
然后,我们使用【!syncblk】命令,查看一下同步块。

我们说过 Monitor 的底层实现就是 AwareLock,这个标红 00000217A549CE10 地址就是指向  AwareLock。我们使用【dt coreclr!AwareLock 00000217A549CE10 】命令查看一番。

我们继续使用【 dx -r1 (*((coreclr!CLREvent *)0x217a549ce30)) 】命令查看 m_SemEvent 是什么,不用执行命令,直接点击就可以了。

既然是一个 handle,我们就使用【!handle 0x314 f】命令查看一下就知道了。

我们看到了吧,Monitor 底层也是使用 AutoResetEvent 实现的。


4.2.5、读写锁(ReaderWriterLock)
A、基础知识
Monitor 类每次只允许一个线程独占式的访问一个对象。虽然,在写入操作非常频繁的情况下,Monitor 能工作的很好,但当读取操作多于写操作或者在锁上存在高度竞争的情况下,Monitor 的性能就很受影响了。
为了解决这个问题,系统为我们提供了读写锁,即 ReaderWriterLock 。ReaderWriterLock 能够使多个线程并发的执行读操作,而每次只允许一个线程执行写操作。ReaderWriterLock 类本身就包含了状态来控制对锁的访问。

注意:
.NET Framework 有两个读取器-写入器锁和 ReaderWriterLockSlim、ReaderWriterLock。 建议对所有新开发的项目使用 ReaderWriterLockSlim。 虽然 ReaderWriterLockSlim 类似于 ReaderWriterLock,但不同之处在于,前者简化了递归规则以及锁状态的升级和降级规则。 ReaderWriterLockSlim 避免了许多潜在的死锁情况。 另外,ReaderWriterLockSlim 的性能显著优于 ReaderWriterLock。

B、眼见为实
调试源码:ExampleCore_6_6
调试任务:使用调试器从底层了解 ReaderWriterLock 到底是什么。
1)、NTSD 调试
编译项目,打开【Visual Studio 2022 Developer Command Prompt v17.9.6】命令行工具,输入命令【NTSD E:\Visual Studio 2022\Source\Projects\AdvancedDebug.NetFramework.Test\ExampleCore_6_6\bin\Debug\net8.0\ExampleCore_6_6.exe】直接进入调试器。
直接【g】运行调试器,直到调试器输出“ Press ENTER to exit... ”字样时,按组合键【ctrl+c】进入中断模式,开始调试了。
我们现在托管堆上查找一下 ReaderWriterLock 对象,执行【!DumpHeap -type ReaderWriterLock】命令。

标红的 000001354f409848 就是 ReaderWriterLock 对象的地址,继续执行【!do 000001354f409848 】命令,查看它的详情。

_readerEvent _writerEvent 是指针类型,分别用来控制对读取队列和写入队列的访问。 _state 表示锁的各种不同的内部状态。 _lockID 持有锁线程的内部标识。 _writerID 持有锁线程的 ID, _writerLevel 持有写入线程的递归锁计数(Recursive lock count)。


2)、Windbg Preview 调试
编译项目,打开【Windbg Preview】调试器,依次点击【文件】---【Launch executable】,加载我们的项目文件 ExampleCore_6_6.exe,直接进入调试器。执行【g】命令,运行调试器,直到我们的控制台程序输出“ Press ENTER to exit... ”字样,然后点击调试器的【break】按钮,进入中断状态,现在开始我们的调试吧。
我们现在托管堆上查找一下 ReaderWriterLock 对象,执行【!DumpHeap -type ReaderWriterLock】命令。

红色标注的 022afb409848 就是 ReaderWriterLock 对象的地址,有了地址,我们执行【!do 022afb409848 】命令。

_readerEvent 和 _writerEvent 是指针类型,分别用来控制对读取队列和写入队列的访问。 _state 表示锁的各种不同的内部状态。 _lockID 持有锁线程的内部标识。 _writerID 持有锁线程的 ID, _writerLevel 持有写入线程的递归锁计数(Recursive lock count)。


4.2.6、线程池
创建新线程的方式很多,比如:Thread、ThreadPool、Task、Parallel 等,除了 Thread 类,其他都是使用了线程池技术,让 CLR 来高效的管理这个线程池,所以,.NET 开发建议使用具有线程池的类型。每个进程有且只有一个线程池。需要注意一点,当线程被还回线程池时,在线程上设置的任何状态都会保留下来。如果同一个线程被用于服务另一个任务请求,并且该任务请求与线程状态不兼容,那么程序可能会失败。

4.3、同步的内部细节
4.3.1、对象头
在托管堆上保存的每个对象都包含一个对象头,在对象头中包含了与对象相关的一组信息。在对象头中可以包含包括散列码、锁信息、同步块索引等。如图所示:

在对象中需要保存的所有信息总量大于对象头本身的大小。这句话的意思,任何一个对象都可能需要(也可能不需要)所有的信息,这取决于具体的执行流程。只要在执行操作中需要的信息(例如:对象的散列码)不超过对象头的大小,这些信息就会直接保存在对象头中。如果对象头中无法保存所需的信息,CLR 会创建一个独立的同步块数据结构,并将当前保存在对象头中的所有信息都复制到这个同步块中,并且,将对象头中保存的信息替换成同步块在同步块表中的索引。同步块位于非 GC 的内存中,通过同步块表中的索引来访问。

CLR 通过对象头中的位元的组织方式区分对象头中包含的信息的种类。如果在对象头中设置了掩码 0x08000000,就表示对象头中包含要么是对象的散列码,要么是同步块索引。如果同时设置了掩码 0x04000000,就表示对象头中保存的是散列码。


4.3.2、同步块
A、基础知识
这一节主要是验证对象头保存数据的方式,例如:如何保存锁信息,如何保存散列码等信息。和同步块相关的有一个命令很重要,就是【!syncblk】,如果该命令不携带任何参数,表示它将输出某个线程中所有对象的同步块。当然,我们也可以将同步块的索引值作为参数,输出指定同步块的信息。
请记住,对象指针指向的是类型句柄域,紧接着才是实际的对象数据。在类型句柄前的 4 或者 8 个字节也是对象布局的一部分,其中就包含了对象头,所以,如果我们想找到对象头,就要使用对象的地址减去 4 或者 8 个字节(32位减去4字节,4 字节就是 0x4,64位减去8字节,8字节就是 0x8)就是对象头的数据。

如果我们想得到同步块索引,可以执行如下操作:
1)、通过使用【!ClrStack -a】命令输出这个线程的所有的调用栈及其所有参数和局部变量。最底层的栈帧对应于 Main 方法。
2)、继续使用【!do】命令,确认是否是我们需要的对象。
3)、最后使用【dp】命令输出对象头,它位于对象指针减去 4 或者 8 个字节(32位减去4字节,4 字节就是 0x4,64位减去8字节,8字节就是 0x8)的位置上。

接下来,我们在说说【!syncblk】命令各列的意思。
Index :同步块索引
SyncBlock :同步块数据结构的地址(未公开)
MonitorHeld :持有的监视器的数量
Recursion :同一个线程获取这个锁的次数
Owning thread info :第一个数据项是指向内部线程数据结构的指针,第二个数据项是操作系统线程ID,第三个数据项是调试器线程ID
SyncBlock Owner :第一个数据项是指向持有锁的对象的指针,第二个数据项是锁所在的对象的类型

B、眼见为实
调试源码:ExampleCore_6_7
调试任务:通过调试器了解对象头保存数据的方式。
1)、NTSD 调试
编译项目,打开【Visual Studio 2022 Developer Command Prompt v17.9.6】命令行工具,输入命令【NTSD E:\Visual Studio 2022\Source\Projects\AdvancedDebug.NetFramework.Test\ExampleCore_6_7\bin\Debug\net8.0\ExampleCore_6_7.exe】打开调试器。
进入调试器后,直接【g】运行调试器,直到调试器输出如图:

此时,我们按组合键【ctrl+c】进入中断模式,由于我们是手动中断的,需要执行【~0s】命令将调试器上下文切换到托管线程上下文中。

继续执行【!clrstack -a】命令,查看托管线程调用栈和所有参数和变量。

0x0000020b49409628 这个就是 Program 对象地址,我们可以使用【!do 0x0000020b49409628 】命令,确认一下。

证明了我们的猜想。我们知道对象的地址指向的是类型句柄,如果想要查看对象头的数据,还要减去 4 或者 8 个字节才是对象头的地址,4 或者 8 是根据系统的位数 32 位就减去 4,64 位就减去 8,从对象的地址也可以看出是该减去 8 还是 4,我的对象地址是 0x0000020b49409628 ,就要减去 8 了。
执行【dp 0x0000020b49409628 -0x8 l1】命令,查看对象头的数据。

我们看到了对象头的值是 0f78734a ,这个值是可以推出来的。我们知道对象的 HashCode 的值是 58225482,这个数字是十进制的结果值,我们转换成十六进制,看看是多少。

0378734a 这个值和【dp】命令的结果 0f78734a 类似,我们再使用 58225482 十六进制表示 0378734a ,分别加上 0x08000000 0x04000000 ,执行命令【? 0378734a + +0x08000000+0x04000000 】,这个值就是对象头的值。

00000000`0f78734a 这个值和【dp】命令的输出是一样的,说明对象头保存是散列码了。
我们恢复调试器的执行,直到调试器输出“ Press any key to release lock ”字样,点击【ctrl+c】组合键,进入中断模式。
如图:

由于 GC 会执行垃圾回收,内存压缩和对象地址转移,我们避免产生误操作。还是先执行线程切换【~0s】。

我们执行【!clrstack -a】命令查看托管线程调用栈,查找我们的Program 对象。

0x0000020c1a409628 这个地址就是我们的 Program对象的地址,我们可以使用【!DumpObj 0x0000020c1a409628 】命令确认一下。

我们现在就可以查看对象头中的内容了。执行命令【dp 0x0000020c1a409628 -0x8 l1】,由于我的程序是64位的,所以需要减去 8,32位减去4就可以了。

由于内容太多了,需要创建同步块存储内容,所以在对象头中就存储同步块的索引了。08000000 表示是同步块,1 表示同步块在同步块表中的索引位置。
此时,我们可以使用【!syncblk 0x1】命令查看同步块的信息了。


2)、Windbg Preview 调试
编译项目,打开【Windbg Preview】,依次点击【文件】----【Launch Excutable】,加载我们的项目文件 ExampleCore_6_7.exe,进入到调试器后,我们使用【g】命令直接运行调试器,直到控制台程序输出“ Press any key to acquire lock ”字样。我们回到调试器界面,点击【Break】按钮,进入中断模式,开始我们的调试旅程。
由于我们手动中断,所以必须切换到托管线程上下文中,因为当前在调试器的上下文环境中,执行命令【~0s】切换线程上下文。

继续执行【!clrstack -a】命令,查看托管线程调用栈和所有参数。

红色标注的地址就是 0x000001c4c6409628 就是 Program 类型对象的地址,我们可以使用【!do 0x000001c4c6409628 】命令验证。

继续使用【dp 0x000001c4c6409628 -0x8 l1】命令,查看对象头的数据。

对象头的当前值 0f78734a,表示在对象头中保存的是散列码,我们控制台程序散列码的输出值是 58225482,这个数字是十进制的,我们转换为十六进制,看看结果。

我们看到了十进制的 58225482 转换为十六进就是  0378734a, 0x08000000 这个掩码只能确定是不是散列码,也有可能是同步块索引,只有在加上一个 0x04000000 掩码才能确定是散列码,所以,我们使用执行【 ? 00000000`0378734a+0x08000000+0x04000000 】命令,这个结果就是对象头的值。

0f78734a 这个值和【dp】命令的输出是一样的,说明对象头保存是散列码了。
我们恢复调试器的执行,直到控制台程序输出“ Press any key to release lock ”字样,回到调试器,点击【Break】按钮,继续进入中断模式。如图:

我们继续执行【 dp 0x000001c4c6409628-0x8 l1 】命令,看看对象头的输出。 说明一下,在执行此命令之前,最好执行一次【!clrstack -a】命令获取对象地址,然后执行【!do】命令确认对象,最后在执行这个【dp】命令,因为垃圾收集器会在任意时刻移动对象,对象的地址也可能变化。

08000001 这个结果值就很合理了,就是同步块索引了。此时,我们可以使用【!syncblk 0x1】命令查看同步块的信息了。


4.3.3、瘦锁
A、基础知识
在 CLR 2.0 中引入了瘦锁,它实现了一种更高效的机制管理锁。在使用瘦锁时,保存在对象头中唯一的信息就是获取锁的线程 ID(既没有同步块),它是一个自旋锁(spinning lock)。因为要实现一个更为高效的等待锁,需要保存更多的信息。然后,这个瘦锁并不会无限的循环,而是当自旋到某个阈值就会停止。如果超过了这个阈值还不能获取这个锁,那么接下来就会创建一个实际的同步块,并将相应的信息保存下来来实现一个高效的等待(例如一个事件)。
CLR 通常采用以下算法来判断是使用同步块和瘦锁。
I、如果同步块存在,则使用同步块存储锁信息。
II、如果同步块不存在,判断在当前对象的对象头中是否可以包含一个瘦锁。
如果可以容纳,就将线程 ID 保存在对象头中。如果后面需要保存更多的信息,那么将自动创建一个同步块,并把当前对象头中的内容转移到新的同步块中。
如果不可以容纳,就会创建一个新的同步块,并将对象头的内容转移到新的同步块中,并保存锁。
我们可以通过调试器来验证这个算法,通过以下三步就可以了。
1】、在获取锁之前,将同步块转储出来,验证其为空。
2】、获取这个锁,中断程序执行,并验证已经创建了一个瘦锁。
3】、获取散列码,中断程序执行,并验证这个瘦锁已经被一个同步块替代了。

我们可以使用【!DumpHeap -thinlock】命令找出托管堆上所有带有瘦锁的对象。

B、眼见为实
调试源码:ExampleCore_6_8
调试任务:验证瘦锁存储的算法。
1)、NTSD 调试
编译项目,打开【Visual Studio 2022 Developer Command Prompt v17.9.6】命令行工具,输入命令【NTSD E:\Visual Studio 2022\Source\Projects\AdvancedDebug.NetFramework.Test\ExampleCore_6_8\bin\Debug\net8.0\ExampleCore_6_8.exe】打开调试器。
进入调试器,【g】直接运行,直到调试器输出,并暂停,如图:

按【ctrl+c】组合键进入中断模式,还需要切换到托管线程上下文中,执行【~0s】命令,继续执行【!clrstack -a】命令查找 Program 对象。

0x000001c613c09628 就是 Program 类型对象地址,执行【!do 0x000001c613c09628 】命令验证一下。

执行【dp 0x000001c613c09628-8 l1】命令查看对象头的内容。

0 就是表示没有任何值。继续【g】恢复调试器的执行,直到调试器输出,如图:

继续执行切换线程和查看线程的命令,分别是【~0s】、【!clrstack -a】查找我们的 Program 对象。

继续执行【!do 0 x000001c613c09628 】命令,查看内容。

ThinLock owner 1 (000001C60F9C8A80), Recursive 0 说明对象上有了一个瘦锁,线程对象的 ID 是 000001C60F9C8A80 ,递归技术是 0。
继续执行【dp 0x000001c613c09628-8 l1】命令,查看对象头。

这里的 1 就是持有锁的线程 ID,是托管线程的 ID 值。可以使用【!t】或者【!threads】命令验证。

【dp】命令和【!t】命令都能找到 000001C60F9C8A80 这个指针的值。
我们继续【g】恢复调试器的执行,直到调试器输出如图:

此时,说明对象的锁和散列值都保存了,然后我们【ctrl+c】进入中断模式,切换线程【~0s】,并且执行【!clrstack -a】命令查找 Program 对象,查一下它的状态。

执行【!do 0x000001c613c09628 】命令,查看一下该对象有什么变化吗?

继续执行【dp 0x000001c613c09628 -8 l1】命令,查看一下对象头保存的数据。

08000001 看到这个值就知道是同步块索引了。我们使用【!syncblk】命令查看同步块的数据。

我们也可以使用【!DumpHeap -thinlock】命令查找托管堆上所有具有瘦锁的对象。

内容很简单,就不解释了。


2)、Windbg Preview 调试
编译项目,打开【Windbg Preview】,依次点击【文件】---【Launch executable】,加载我们的控制台项目 ExampleCore_6_8.exe,点击【打开】进入调试器。
进入调试器后,直接执行【g】命令,运行调试器,直到我们的控制台程序输出“ Press any key to acquire lock ”,此时,回到调试器,点击【Break】按钮,进入到中断模式,开始我们的调试。
由于我们是手动中断的,当前是调试器的上下文,需要切换到托管上下文中,需要执行【~0s】命令。

我们使用【!clrstack -a】命令,查看托管线程调用栈,找出我们的 Program 类型的局部变量 program。

0x000001ace3409628 就是Program 类型的实例对象的地址,我们可以使用【!do 0x000001ace3409628 】来验证。

我们执行命令【dp 0x000001ace3409628 -8 l1】查看它的对象头。

00000000`00000000 表示没有任何数据。
我们【g】恢复调试器的执行,直到控制台程序输出“ Press any key to get hashcode ”,此时,对象已经获取了锁,但是还没有获取散列值。回调调试器中,点击【Break】按钮,再次进入中断模式,继续我们的调试。
由于手动进入中断模式,所以需要有调试器上下文切换到托管线程上下文中,执行命令【~0s】。

继续执行【!clrstack -a】命令查找 Program 对象。

0x000001ace3409628 这就是我们的 Program 类型实例的地址,可以执行【!do 0x000001ace3409628 】命令来验证,我就省略了。
此时,该对象已经获取锁了,我们查看对象头的数据,执行【dp 0x000001ace3409628 -8 l1】命令。

00000001 这个就是所有者线程的 ID,此时我们可以执行【!do 0x000001ace3409628 】或者【!DumpObj 0x000001ace3409628 】命令,查看Program 对象,也有体现。

红色标注的告诉我们 Program 对象上获取了一个瘦锁,线程对象指针是 000001ACDEFF2770 ,且递归计数位0,我们可以使用【!t】或者【!threads】命令来验证。

我们看到了【!do】命令和【!t】命令的输出线程ID都是 000001ACDEFF2770 ,在对象头中包含了持有锁的线程 ID。
接下来,我们执行代码,获取散列码,再次中断执行,查看同步块和瘦锁的状态。
【g】继续运行,直到我们的控制台程序输出“ HashCode:58225482 Press any key to release lock ”。此时已经有了锁,并且也获取了散列码。回到调试器,点击【Break】按钮,进入中断模式,继续调试。
继续切换线程上下文【~0s】,并执行【!clrstack -a】命令查找我们的 Program 对象。

执行【!do 0x000001ace3409628 】命令,查看 Program 对象。

继续执行【dp 0x000001ace3409628 -8 l1 】命令,查看对象头。

08000001 说明现在已经在使用同步块保存数据了,索引值是 1。
我们使用【!syncblk】命令来验证一下。

当然,我们可以使用【!DumpHeap -thinlock】命令找出托管堆上所有带有瘦锁的对象。

很简单,就不多说了。

4.4、同步任务
4.4.1、死锁
A、基础知识
死锁:当两个或者多个线程分别持有一些被保护的资源,并且都拒绝释放各自的资源而等待另一方释放资源时,死锁就产生了。
这里会用到一些【k】命令,我就稍作介绍,【k】命令显示给定线程的堆栈帧以及相关信息,【kp】显示堆栈跟踪中调用的每个函数的所有参数。【kb】显示传递给堆栈跟踪中每个函数的前三个参数。
如果想学更多的命令,可以去微软官网: https://learn.microsoft.com/zh-cn/windows-hardware/drivers/debuggercmds/k--kb--kc--kd--kp--kp--kv--display-stack-backtrace-

B、眼见为实
调试源码:ExampleCore_6_9
调试任务:手动调试线程死锁的问题。
1)、NTSD 调试
编译项目,然后直接运行我们的 EXE 可执行程序,直到我们的程序输出如图:

此时,打开【Visual Studio 2022 Developer Command Prompt v17.9.6】命令行工具,输入命令【NTSD -pn ExampleCore_6_9.exe】通过进程名称附加我们的程序,当然,也可以通过进程 id 来附加我们的程序。
回车,直接进入调试器,调试器会有一个 int 3 的中断,就可以开始我们的调试了。
已经成功附加进程,截图效果,不是全部:

此时,调试已经处于中断模式了,效果如图:

我们可以使用【~*e!clrstack】命令,将托管线程和非托管线程的栈回溯都转储出来。

其实,我们从红色标注的可以看出一些端倪, OS Thread Id: 0x1d7c (4) 4号托管线程执行源码 ExampleCore_6_9.Program+<>c.<Main>b__2_0() 这个代码时,调用同步原语 Monitor 的 System.Threading.Monitor.ReliableEnter 方法想进入,却没进入,处于等待,因为后面没有调用栈了。 说明一下,Windbg Preview 是可以显示源码行号的,可以直到在哪里处于等待,但是在 NTSD 是没有的。

OS Thread Id: 0x1130 (6) 的 6 号托管线程执行源码 ExampleCore_6_9.Program+<>c.<Main>b__2_1() 时调用了 System.Threading.Monitor.ReliableEnter 方法,想获取锁,由于后面没有执行,所以也是出于等待状态。
此时,我们知道他们都是处于等待状态,虽然输出的信息很简单,但是它却展示了一种常见的死锁识别技术。
这个输出的信息有点多,其实我们还可以使用另外一个命令,【!syncblk】查看同步快表的数据,也能看出一些信息。

4 号托管线程持有 0000026a14010a28 ExampleCore_6_9.Person 对象,也就是锁定了该对象,我们的控制台程序输出也能说明这一点,输出是“ tid=4,已经进入 Person(1111) 锁 ”,结合【~*e!clrstack】命令的输出,我们知道,4 号线程在执行 Monitor 的 Enter 方法的时候处于等待状态,我们就可以退出等待的位置在源码的 17 行,如图:

再用同样的道理分析,6 号托管线程已经持有 0000026a14010a40 ExampleCore_6_9.Student 对象,说明该对象已经被锁定了,在结合【~*e!clrstack】命令的输出,我们知道 6 号线程在执行 Monitor 的 Enter 方法时是处于等待的状态,我们在结合我们控制台程序的输出“ tid=6,已经进入 Student(22222) 锁 ”,我们可以知道源码在 32 行处于等待的。如图:

代码很简单,所以我们分析也不难。我们可以根据【~*e!clrstack】命令的输出,分别切换到 4 和 6 号线程上查看一下具体调用栈,也能找出问题。
我们先切换到 4 号线程,执行命令【~4s】。

我们继续执行【!clrstack -a】命令,查看一下调用栈的局部变量,主要观察 Person 和 Student 。

0x0000026a14010a28 0x0000026a14010a40 就是我们的 ExampleCore_6_9.Person 对象和 ExampleCore_6_9.Student 对象,我们可以执行【!do 0x0000026a14010a28】和【!do 0x0000026a14010a40】命令来确认它们。

我们在分别查看一下这两个对象的对象头包含了什么数据,执行命令【dp 0x0000026a14010a28-8 l1】和【dp 0x0000026a14010a40-8 l1】。

说明它们都使用了同步块保存数据和锁信息了。此时,可以再使用【!syncblk】命令查看同步块表的数据,上面已经执行,此处省略。
以下就简单了,根据我们的代码查找问题吧。

2)、Windbg Preview 调试
编译项目,然后直接运行我们的 EXE 可执行程序,我们的程序输出如图:

然后,打开【Windbg Preview】,依次点击【文件】----【Attach to Process】,附加我们的进程,进入调试器,我们先把进程中所有线程转储出来看看,执行【 ~*e!clrstack 】命令。

【~*e!clrstack】命令将托管线程和非托管线程所有的栈回溯都输出出来了。 OS Thread Id: 0x3dd8 (6) 号的线程执行 System.Threading.Monitor.ReliableEnter 方法就不执行了,说明卡住了,卡在什么地方呢,就是 ExampleCore_6_9.Program+c.b__2_1 () 这样代码最后的行号,32,也就是源码的第32行,换句话说,就是 6 号线程持有 Student 锁,等待 Person 释放锁。效果如图:

OS Thread Id: 0x6c (4) 号线程执行了 System.Threading.Monitor.ReliableEnter 方法也没有后续了,说明卡住了,同样,卡住的位置在哪里,就是 ExampleCore_6_9.Program+c.b__2_0() 这行表示的意思,最后有一个数字,就是源码的行号,它是17,换句话说,就是 4 号线程持有 Person 锁,在登台 student 上的锁释放。效果如图:

其实,我们从以上也能看出一些端倪来。输出信息虽然简单,但是却展示一种常见死锁的识别技术。

我们也可以使用【!syncblk】命令查看一下同步块数据,这个也能说明一些问题。

我们看到了 ID 是 4 的线程持有 ExampleCore_6_9.Person 对象,ID 是 6 的线程持有 ExampleCore_6_9.Student 对象,我们可以切换到 4 和 6 号线程上查看一下。
通过以上的分析,剩下就去代码里找问题吧。


4.4.2、孤立锁:异常
A、基础知识
孤儿锁是因为开发者使用 Monitor.Enter 获取一个对象后,因为某种原因没有正确调用 Monitor.Exit,导致这个对象一直处于占用状态,其他线程也就无法进入了,强烈建议使用 lock 语法。

B、眼见为实
调试源码:ExampleCore_6_10
调试任务:重现孤立锁。
1)、NTSD 调试
编译项目,直接双击我们项目的 EXE 可执行程序,直到我们的控制台程序有如图输出:

我们打开【Visual Studio 2022 Developer Command Prompt v17.9.6】,输出命令【NTSD -pn ExampleCore_6_10.exe】,进入调试器,开始我们的调试了。
我们先执行【~*e!clrstack】命令,查看一下所有线程的调用栈是什么情况。

OS Thread Id: 0x29c (0) 这个就是 0 号主线程,它执行了 Main 方法,又执行 System.Threading.Monitor.Enter 方法,处于挂起的状态,其他线程没有任何有用信息。
我们的被锁的对象是 ExampleCore_6_10.DBWrapper,又是在主线程出的问题,我们就去主线程上找一下 DBWrapper 对象。
执行命令【~0s】切换到主线程。

继续执行【!dumpstackobjects】命令。

ExampleCore_6_10.DBWrapper 类型的地址是 000002cf3c009630 ,执行【dp 000002cf3c009630 -8 l1】命令查看一下它的对象头。

说明对象头已经创建同步块了,索引值是 2,所以我们执行【!syncblk 2】命令查看一下同步块的数据。

说明 XXX 号线程持有 ExampleCore_6_10.DBWrapper 类型,也可以说 XXX 线程拥有 ExampleCore_6_10.DBWrapper 的锁。XXX 表示的是调试器线程的ID,0 表示操作系统线程的 ID。
XXX 的含义就是,CLR 无法将操作系统线程的 ID 映射到调试器线程,出现这样情况的一个原因是,某个线程在某个时刻获取一个对象的锁,然后,这个线程消失了,却没有释放锁。

我们可以执行【!t】或者【!threads】命令验证 XXX 的说法。

只要没有执行终结操作,即使处于死亡状态的线程也会被输出。
到这里就差不多了,我们还需要结合代码和调试器一起来找问题,很简单,我直接贴图了。

图上说的很情况,就不多解释了。

2)、Windbg Preview 调试
编译项目,直接双击我们项目的 EXE 可执行程序,直到我们的控制台程序有如图输出:

我们打开【Windbg Preview】,依次点击【文件】---【Attach to process】,在右侧选择我们运行的程序,点击【附加】,附加我们的进程,进入调试器,开始我们的调试了。
我们先执行【~*e!clrstack】命令,查看一下所有线程的调用栈是什么情况。

我们从命令的输出中可以看到,有用的信息不多,红色标注的就是主线程的运行情况。我们发现 0 号线程,也就是主线程在执行 System.Threading.Monitor.Enter 方法时挂起了,不执行了,问题大概也就是在这里。
既然主线程有了问题,我们就切换到主线程看看情况,执行命令【~0s】。

我们执行【!dumpstackobjects】命令,找到我们要分析的对象 DBWrapper。

ExampleCore_6_10.DBWrapper 就是我们要找的对象,它的地址是 02cf3c009630 ,我们执行【dp 02cf3c009630 -8 l1】命令查看该对象的对象头包含的是什么东西。

08000002 说明对象头已经创建了一个同步块了,索引值是 2,我们查看同步块,执行命令【!syncblk 2】。

输出信息告诉我们 ExampleCore_6_10.DBWrapper 对象已经被锁定了,被 XXX 线程锁定的。XXX 表示的是调试器的线程 ID,0 表示的是操作系统线程的 ID。
XXX 表示 CLR 无法将操作系统线程的 ID 无法映射到调试器线程。出现这种情况的原因是,这个线程在某个时刻获取了该对象上的锁,然后这个线程消失了但是却没有释放锁。

我们执行【!t】或者【!threads】命令验证这一点。

只要没有执行终结操作,即使处于死亡状态的线程也会被出输出。

要分析具体是哪里的错误,肯定要结合代码来分析。我们的代码是这里出问题了,如图:

代码很简单,就不多说了。

4.4.3、线程中止
这节的内容就略过了,探索的意义不是很大,首先,我使用的平台是 8.0 跨平台版本,不是 .NET Framework 版本了,如果在 .NET 8.0 版本里调用  Thread.Abort() 方法是不支持的。会有绿色波浪线提示,如图:

如果大家使用的 .NET Framework 平台,可以自己试试。


4.4.4、终结器挂起
系统内存暴涨有很多原因,不良线程可以是原因之一,访问非托管资源也可以是原因之一。如果查看内容暴涨,其实还是有很多方法的,比如:我们可以使用【任务管理器】,也可以使用【ProcessExplorer】工具。具体的使用方法就不介绍了,大家可以网上自行恶补。
原书上的内容我省略了,由于没有原书的源码,所以我也无法调试了。这里是我用的以前的代码(我之前写过一个系列的代码),和终结器挂起也没关系,但是和内存暴涨有关系,原书的调试方法还是可以使用的,特此说明。

有些查找问题的方法和步骤还是很有用的,如果我们发现系统内存暴涨,可以尝试执行一下步骤排查。
1)、我们可以先执行【!eeheap -loader】命令,查看一下加载器堆是否存在异常。
2)、如果加载器堆没问题,我们可以尝试执行【!eeheap -gc】命令查看托管堆是否有什么情况。
3)、我们也可以执行【!heap -s】命令,查看所有堆的统计情况,来查找问题,如果数据有问题,可以继续使用【!heap -h】命令是否存在句柄数据。
4)、当然,我们也可以使用【!DumpHeap -stat】命令,统计一下托管堆上的对象,看看对象数据是否存在问题。
5)、直到了对象,我们就可以使用【!DumpHeap -type】查找指定对象的地址。
6)、有了对象的地址,我们就可以使用【!gcroot】命令,观察对象的根引用。
7)、我们也可以使用【FinalizeQueue】命令查看一下中介对象的情况来查找问题。
8)、通过【!t】或者【!thread】命令,了解线程的情况,直到了线程标识 ID,我们就可以使用【!clrstack】命令查看 指定线程的调用栈。


五、总结
这篇文章的终于写完了,这篇文章的内容相对来说,不是很多。写完一篇,就说明进步了一点点。Net 高级调试这条路,也刚刚起步,还有很多要学的地方。皇天不负有心人,努力,不辜负自己,我相信付出就有回报,再者说,学习的过程,有时候,虽然很痛苦,但是,学有所成,学有所懂,这个开心的感觉还是不可言喻的。不忘初心,继续努力。做自己喜欢做的,开心就好。

标签:游戏攻略