.NET CoreCLR开发人员指南(上)
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_ENDCONTRACTL {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
原文鏈接:http://www.cnblogs.com/kmsfan/p/coreclr_guildline.html
.NET社區新聞,深度好文,微信中搜索dotNET跨平臺或掃描二維碼關注
創作挑戰賽新人創作獎勵來咯,堅持創作打卡瓜分現金大獎總結
以上是生活随笔為你收集整理的.NET CoreCLR开发人员指南(上)的全部內容,希望文章能夠幫你解決所遇到的問題。
 
                            
                        - 上一篇: Lind.DDD.RedisClient
- 下一篇: .Net Core及.Net Stand
