1.为什么每一个CLR开发人员都需要读这篇文章
和所有的其他的大型代码库相比,CLR代码库有很多而且比较成熟的代码调试工具去检测BUG。对于程序员来说,理解这些规则和习惯写法非常的重要。
这篇文章让所有的CLR开发者都尽量能在较少知识的情况下,去了解CLR中自己工作的那一部分内容。这篇文章将会为你呈现CLR的发展史,以及不同阶段解决的不同问题和不同阶段解决问题以后给开发者带来的一些更加便利的东西。
1.1代码规范
这是最为重要的一个章节!设想一下本文的目录里面的一些项,然后想想自己该如何设计代码。这个章节讲会分成2部分,一部分是托管代码,另一部分是非托管代码,不同的部分,将会面临的问题也不同。
规范是由不变性和团队的一些计划息息相关的。
不变性从语义上来说,是由架构来控制的。举个例子:安全的GC中托管对象的引用在非托管代码中,这是不会出现的,当你违反了这一恒定性的话,对于开发者来说,这就是一个很明显的BUG。
团队计划规范 是当我们写了一些“非常不错的练习”代码,举个例子:我现在规定每一个方法都必须附带一个契约,如果有方法违反了这个规定,那么除非你可以解释为什么这段代码不用按照规范来办事,否则会造成团队其他成员的一些想法。
团队计划相对于不变性(架构)来说并不是那么的重要。比如对于你使用safemath.h来说,遵守函数的一些规范远远比你做一个整型上溢校验要重要。但是处于安全考虑,我们通常都会把它放在优先级高的位置。
有一种规范你不会在这篇文章里找到,那就是代码整洁度,比如大括号摆放的位置,当然这不属于代码范畴。我们也不会强制性要求这些语言上的问题。这篇文章将会介绍到如下内容:
- 介绍一个实际的存在的BUG
- 大大增加严重BUG的风险
- 对于通用BUG自动检测的一些挫败感。
1.2如何插入通用任务
这一章节 在这个部分可以理解为FAQ,如果你有特别的需要的话,搜一下本文目录中的"最佳训练";如果你想在CLR中添加另一个hash表的实现,那就好好看看这篇文章,文章里有现成的代码,可以适应你的业务需求。
2.代码规范(非托管)
2.1你的代码是否是GC安全的
2.1.1 GC黑洞是如何产生的
GC黑洞这一术语引用自一个经典的GC BUG。GC黑洞是一种致命的BUG因为对于GC事故来说是很容易被引入的,它很少被复制,而且调试BUG的过程是乏味而且会花费大量的时间去找这个问题。一个简单的GC黑洞可能会让开发和测试人员花费数周的时间去排查问题。
CLR的主要功能之一就是垃圾回收机制。这意味着当你给对象分配内存空间的时候,如果是受托管的应用程序,则你不用去刻意的去释放掉分配的内存空间。除此之外,CLR会有一个定时器去周期性的执行垃圾回收机制,这时GC会把对象丢弃掉不再使用。与此同时,GC会把HEAP(堆)给收紧,以防止内存中无用黑洞的产生。因此,在托管堆中的对象并没有一个固定的内存地址;因为GC,对象在内存中就像蝌蚪一样不断的变化着自己的位置。
为了去做这项工作,GC必须告诉所有的GC对象之于它们的引用。GC必须知道每一个元素在栈中的地址;每一个注册的和非GC数据结构的对象的指针对指向一个GC对象。这些外部指针被称为根引用。
写到这里了:
当你拥有了这些信息以后,GC可以找到从外部GC堆里面直接引用的对象,这些对象会轮流被其他对象所引用。从这些引用延伸开来的话,GC将会找到所有“活的”对象,而所谓的不能被找到的对象(死的对象)将会被丢弃掉。然后GC将会移动这些活的对象以减少内存碎片;如果做了这些工作的话,GC将会更新所有的移动过的对象的引用。
任何时候一个对象呗分配内存空间的话,GC都将被触发。GC将会显示带一个请求给GarbageCollect 方法,GC不会在这些事件之外被异步调用除了其他运行中的线程会触发GC,这些线程也会异步去触发GC除非你显示调用GC,这些稍后会进行详细的介绍。
当CLR创建GC的引用的时候回形成GC黑洞。如果我们不让GC知道哪些引用的话,执行一些操作直接或者间接的触发GC,然后尝试使用最初的引用。这时候,引用将会指向垃圾内存,CLR会读取到一些错误的数据,不管引用是指向哪里。
2.1.2 第一个GC黑洞
下面的代码将会以最简单的代码来介绍系统中的GC黑洞。
//OBJECTREF 这里是指的 用typedef 来表示对象的指针所指向的地址 { MethodTable *pMT = g_pObjectClass->GetMethodTable(); OBJECTREF a = AllocateObject(pMT); OBJECTREF b = AllocateObject(pMT); //错误,a 可能会指向垃圾内存如果第二个AllocateObject 触发GC垃圾回收机制
DoSomething (a, b); }
上面的代码所做的事情只是分配了2段托管代码,然后a和b一起调用了DoSomething方法执行一些逻辑代码。
上面的代码如果你直接执行的话,看起来是没有什么问题的,但是这段代码最终会爆发出一些问题。为什么呢?因为第二段代码不经意的触发了GC,GC将会把你刚刚赋值的变量a的对象实例抛弃掉。这些代码好比在CoreCLR中的C++代码,是被非托管的编译器编译的,GC并不知道变量a是包含了一个某个对象的根引用并且是不被GC回收掉的对象。
上面说的这点是值得去重现的。GC并没有根引用存储在本地变量或者或者是非GC数据结构的知识点;对于CLR来说,你必须以正确的方式去运转它,这才是王道。
2.1.3 使用GCPROTECT_BEGIN去保持引用的时效性
下面的代码告诉如何修复上面的代码所出现的GC黑洞:
#include "frames.h" { MethodTable *pMT = g_pObjectClass->GetMethodTable(); //正确写法 OBJECTREF a = AllocateObject(pMT); GCPROTECT_BEGIN(a); OBJECTREF b = AllocateObject(pMT); DoSomething (a, b); GCPROTECT_END(); }
注意到添加的GCPROTECT_BEGIN这一行文字,GCPROTECT_BEGIN是参数为引用类型的宏,它是完全可以被引用地址&赋值的表达式。GCPROTECT_BEGIN 告诉GC两件事:
- GC并没有把变量a的引用指向的任意对象丢弃掉。
- 如果GC移动了和变量a的引用所指向的对象,那么变量a必将指向一块新开辟的内存空间。
现在如果第二个AllocateObject()方法触发了GC,a对象之后依然会在周围,本地变量a依然会指向a对象。a的地址可能不再和之前保持一致。但是它依然会指向同一个对象,因此DoSomething()的值将会是正确的。
这里我们注意到我们并没有以同样的方式保护b,因为回调函数并没有在DoSomething执行完成之后使用b,更深入的说,这里并没有指针b保持它的更新状态因为DoSomething方法其实是接受到的是引用的一个拷贝,注意不要和对象的复制混在一起了,它并不是引用了它自己。DoSomething 内部也触发了GC,DoSomething 只负责保护它们自己的a和b的一份拷贝而已。
就像之前说过的,没人应该抱怨如果你让它能够“安全”和GCPROTECT 变量b。你永远也不知道以后什么时候其他人写的代码会让你的保护变得有一样,所以这是必须的。
每一个GCPROTECT_BEGIN 都必须有一个GCPROTECT_END以便结束对变量a的保护,作为额外的保护,GCPROTECT_END 重写了变量a以至于让它变成垃圾变量,如果这时候你再使用a就会导致错误的产生。GCPROTECT_BEGIN 和GCPROTECT_END 会产生一个新的C语言作用域级别级别,如果这2个不是成对出现的,那就会抛出异常。
2.1.4 不要在GCPROTECT中使用非局部返回
永远不要使用return ,goto以及其他非局部返回在GCPROTECT_BEGIN和GCPROTECT_END之间,这会是线程框架链崩溃。
如果在GCPROTECT 块中抛出一个托管异常(通常是由COMPlusThrow()方法触发的异常),异常的子系统会知道GCPROTECT 并正确修复框架链以解决框架链断裂的问题。
为什么GCPROTECT 没有从C++智能指针基类而派生出来?因为GCPROTECT起源于.NET Framework 1.0,它其实本质是宏。所有的错误在那时候已经明确被终结了,并没有使用到任何C++的异常处理或者栈内存的分配。
2.1.5 不要在同样的位置使用GCPROTECT 2次
下面的代码是错误的并且会造成一些不同的崩溃异常:
OBJECTREF a = AllocateObject(...); GCPROTECT_BEGIN(a); GCPROTECT_BEGIN(a);
当然,如果GC足够强大可以忽略掉第二个GCPROTECT,实际上GCPROTECT 是不会被“保护”多次的,这是不可能的。
不要对引用的拷贝的引用感到迷惑,它保护2次引用是合法的;不正确的是保护了2次引用的拷贝,因此,下面的代码是正确的:
OBJECTREF a = AllocateObject(...); GCPROTECT_BEGIN(a); DoSomething(a); GCPROTECT_END(); void DoSomething(OBJECTREF a) { GCPROTECT_BEGIN(a); GCPROTECT_END(); }
2.1.6 保护多个OBJECTREF
你可以使用GCPROTECT保护多个OBJECTREF 的地址,但是它受C++多级作用域的限制,设想一下你需要如何用不确定性的时间复杂度在一个非GC数据结构里存储根引用?
解决方法是OBJECTHANDLE.OBJECTHANDLE 会告诉GC让它分配一块特别的内存块的地址;任何存储在这里的根引用都将在它的生命周期中不会被销毁,如果有对象的移动,那么它的地址将会被更新。你可以间接恢复它的正确的内存地址。
Handles是多个不同层次首相的实现,通过objecthandle.h暴露使用的一个公共的官方接口;不要对handletable.h 里面包含了这个而感到困惑。CreateHandle() API 方法分配了新的内存空间,ObjectFromHandle()间接引用了handle以及返回了最新的引用,DestroyHandle()释放内存空间。
下面的代码段告诉了我们如何使用handles,实际上,人们更愿意使用GCPROTECT.
{ MethodTable *pMT = g_pObjectClass->GetMethodTable(); // 另一种方法是使用handles.handles会使用更多的内存,对于这么简单的例子 // 如果你想长期保护某个东西,使用handles会有用。 OBJECTHANDLE ah; OBJECTHANDLE bh; ah = CreateHandle(AllocateObject(pMT)); bh = CreateHandle(AllocateObject(pMT)); DoSomething (ObjectFromHandle(ah), ObjectFromhandle(bh)); DestroyHandle(bh); DestroyHandle(ah); }
系统为我们提供了不同种类的handles.下面会列举 几个常用的,如果你想查看所有的objecthandle.h里面有 完整的。
- HNDTYPE_STRONG: 默认的。它的作用和普通引用相等,使用方法:CreateHandle(OBJECTREF).
- HNDTYPE_WEAK_LONG:在一个对象的生命周期中一直跟踪它的强类型引用而并非是它自身以防止它触发GC。使用方法:CreateWeakHandle(OBJECTREF)。
- HNDTYPE_PINNED:在一个对象的垃圾回收生命周期里阻止对象引用的移动,对于栈顶的已经添加属性的强handles.当GC启用时,传递指针给运行时之外的内部对象的时候尤为有用。
注意:如果使用第三个的话,GC垃圾回收最好是一个长周期,因为短期回收会阻止GC装箱而造成内存的不必要的消耗。所以在使用它的时候应该再三考虑。
2.1.8 正确的使用GC模式:抢占式 VS 协同工作式
早期,GC其实是不会自动触发的,对于一个已有线程来说,这是对的。但是CLR是多线程生物,如果你的线程一致执行并保持不抛出异常直到结束,那么它和这个进程里的其他线程也是没有任何关系的。
设想一下有2种不同的方式去执行GC:
- 抢占式:任何单个线程触发GC,并且这个线程不去关心其他线程的状态,也就是说,其他线程也许某个点时间会和这个线程的GC同事触发。
- 协同工作式:一个线程只能启动一次GC并且其他线程都要给这个线程开放GC启动的权限,如果当前线程发出了一个GC请求,那么它会被阻塞,直到其他线程都同意这个线程进行GC操作。
每种不同的模式都有它们自己的优点和缺点,抢占式看起来更有吸引力和效率,除了一件事情发生:完全打破我们之前讨论的GC保护机制。比如下面的代码:
OBJECTREF a = AllocateObject(...) GCPROTECT_BEGIN(a); DoSomething(a);
现在,让我们来看看相对完整的伪代码吧:
call AllocateObject mov [A],eax ;;把结果存储到a中 ... 省略GCPROTECT_BEGIN的代码 ... push [A] ;把参数传入Dosomething call DoSomething
无论是在何种情况下,这段代码运行新起来都是没问题的,表面上通过GCPROTECT来看。设想一下push指令之后会怎么样呢?其他的线程得到了这个时间片段,开始执行GC并移动A对象。本地变量A将会被正确的更新,但是对于DoSomething()方法的参数(A的一份拷贝)并没有被更新。因此,DoSomething()会接受一个指向旧的引用的指针并造成程序的崩溃。现在我们进一步知道,如果单独使用抢占式GC并不能能满足CLR。
那么在什么时候选择哪种模式更好呢?协同工作式GC?在这种情况下,上面的那些问题都不会出现,GCPROTECT 也会按照预期工作。不巧的是,CLR不得不和遗留的非托管代码进行交互。设想一下托管的应用程序唤醒等待用户点击按钮返回Win32 MessageBox API的场景,直到用户点击按钮之前,所有在同一进程当中的托管线程将被GC Block住,这显然会影响程序的执行效率。
因为没有办法单独能满足CLR的需求,所以CLR支持2种方式一起使用,而作为一个开发者来说,只需要相应的切换线程就行了。注意GC调度模式属于独立线程的一个属性,而并不是一个全局的系统属性。
精确的说:一个线程在协同工作模式中长时间运行,它保证GC只在线程触发内存分配时候起作用,唤醒可中断的托管代码或者明确的请求GC。其他线程被GC所阻挡;如果一个线程在抢占模式长时间工作,你必须假定GC能再任何时间被其他线程启动以及和其他线程一起运行。
一个好的经验法则是:一个CLR线程在任意时间运行在协同工作模式中,那么它会运行在托管代码中或者以任意方式操纵对象的引用。一个异常引擎(Exception Engine)运行在抢占模式中通常会运行非托管代码。比如它已经脱离了托管域,那么运行在抢占模式中的进程中的多个线程会从未进入CLR过,许多CLR内部的代码使用的是抢占模式运行。
如果是运行在抢占模式中,OBJECTREF将严格的不会进行任何干预,说白点,和它没关系了,这时候得到的值是完全不可靠的。实际上,如果你在抢占模式中添加了OBJECTREF那么编译器在编译的时候会检查其“正确性”。在协同工作模式中,因为GC引起其他的线程的block,所以应该减少等待时间的操作,这是提高效率的方式之一。你也必须注意被动等待的临界区段或者信号。
设置GC模式:一般通常会使用GCX_COOP 和GCX_PREEMP 宏命令。这些宏命令应该作为容器去操作,你必须在你想执行的代码区间的开始去声明它,在作用域之外的本地或非本地的退出,自动还原功能将会强制还原成初始的模式。
{ // 总是会开启一个新的C++作用域去改变模式 GCX_COOP(); Code you want run in cooperative mode } // 离开作用域以后会还原到改变之前的模式
如果一个线程处于协同工作模式去调用GCX_COOP()是合法的,GCX_COOP在那种情况下 将会是一个NOP,同样的适用于GCX_PREEMP。
GCX_COOP 和GCX_PREEMP永远不会抛出异常以及返回非错误状态。
当然有一种特别的情况对于纯粹的非托管线程(没有任何线程结构的线程),你可以把它理解为永久的处于竞争模式中,因此,如果GCX_COOP 唤醒这种线程那么
GCX_PREEMP会是NOP的一个子系统。
下面对于特殊情况下将会有一组变体:
- GCX_MAYBE_*(BOOL):这种只有在参数为TRUE的情况下才会执行,在作用域结束时,是否还原成初始状态取决于BOOL的值是否为TRUE(这一点很重要,不过只有在作用域中,如果模式通过其他的方式进行了改变,通常情况下,这不会发生)。
- GCX_*_THREAD_EXISTS(Thread*):如果你关心重复过的GetThread()以及容器中选择的空线程,使用高效的版本通过缓存线程指针以及把它传递给所有GCX_*的调用者。你不能使用这个区改变其他的线程的模式,当然你也不能在这里传递NULL。
在一个方法中多次改变模式,你必须对每次改变使用一个新的作用域,你也可以在还原模式作用域结束之前去调用GCX_POP()方法(这种模式会在作用域结束之前再次进行还原。因为模式还原是幂等的,这不应该被关心),永远不要像下面这样做:
{ GCX_COOP(); ... GCX_PREEMP(): //错误! }
系统会抛出一个编译错误:变量已经在相同的作用域中被声明过。
基于容器的宏命令有没有比较好的方式去改变模式呢?有时候需要在超出作用域范围时留下改变的模式,这时候你需要一个未加工的无作用域的方法:
GetThread()->DisablePreemptiveGC(); // 切换到协同工作模式 GetThread()->EnablePreemptiveGC(); // 切换到抢占模式
对于这些方法并没有自动的模式恢复机制,所以管理好它的生命周期就是你的义务了,另外,模式的改变不能被嵌套,如果你改变一个已经有的模式你将会得到一个断言(assert),当前的对象必须是当前执行的线程,而不是其他线程的模式。
关键点:只要可能的话,使用GCX_COOP/PREEMP比无作用域的调用DisablePreemptiveGC()更好。
你需要在契约中的特别的模式中使用断言,可以使用如下的模式去做:
CONTRACTL { MODE_COOPERATIVE } CONTRACTL_END CONTRACTL { MODE_PREEMPTIVE } CONTRACTL_END
下面的是独立版本:
{ GCX_ASSERT_COOP(); } { GCX_ASSERT_PREEMP(); }
你会注意到,独立版本相对于简单版本更像一个容器,这么做的目的是容器会在离开作用域之前会再判断一下以保证拆箱操作的正确性。但是退出检查最终因为启用而在初始化的时候在所有的拆箱代码被清理干净前,那么它是被禁用掉的。不巧的是,最终还是没有出现,在你用GCX容器去管理mode的改变时,是不会发生任何问题的。
参考资料:https://github.com/dotnet/coreclr/blob/master/Documentation/coding-guidelines/clr-code-guide.md#1