目錄html
原文地址:https://devblogs.microsoft.com/premier-developer/managed-object-internals-part-2-object-header-layout-and-the-cost-of-locking/
原文做者:Sergey
譯文做者:傑哥很忙node
託管對象本質1-佈局
託管對象本質2-對象頭佈局和鎖成本
託管對象本質3-託管數組結構
託管對象本質4-字段佈局c++
我從事當前項目時遇到了一個很是有趣的狀況。對於給定類型的每一個對象,我必須建立一個始終增加的標識符,但須要注意:
1) 該解決方案能夠在多線程環境中工做
2) 對象的數量至關大,多達千萬。
3) 標識應該按需建立,由於不是每一個對象都須要它。編程
在最初的實現過程當中,我尚未意識到應用程序將處理的數量,所以我提出了一個很是簡單的解決方案:c#
public class Node { public const int InvalidId = -1; private static int s_idCounter; private int m_id; public int Id { get { if (m_id == InvalidId) { lock (this) { if (m_id == InvalidId) { m_id = Interlocked.Increment(ref s_idCounter); } } } return m_id; } } }
代碼使用雙重檢查的鎖模式,容許在多線程環境中初始化標識字段。在其中一個分析會話中,我注意到具備有效 ID 的對象數量達到數百萬個實例,主要令我驚訝的是,它並無在性能方面引發任何問題。數組
以後,我建立了一個基準測試,以查看與無鎖定方法相比,鎖語句在性能方面的影響。微信
public class NoLockNode { public const int InvalidId = -1; private static int s_idCounter; private int m_id = InvalidId; public int Id { get { if (m_id == InvalidId) { // Leaving double check to have the same amount of computation here if (m_id == InvalidId) { m_id = Interlocked.Increment(ref s_idCounter); } } return m_id; } }
爲了分析性能差別,我將使用基準DotNet數據結構
List<NodeWithLock.Node> m_nodeWithLocks => Enumerable.Range(1, Count).Select(n => new NodeWithLock.Node()).ToList(); List<NodeNoLock.NoLockNode> m_nodeWithNoLocks => Enumerable.Range(1, Count).Select(n => new NodeNoLock.NoLockNode()).ToList(); [Benchmark] public long NodeWithLock() { // m_nodeWithLocks has 5 million instances return m_nodeWithLocks .AsParallel() .WithDegreeOfParallelism(16) .Select(n => (long)n.Id).Sum(); } [Benchmark] public long NodeWithNoLock() { // m_nodeWithNoLocks has 5 million instances return m_nodeWithNoLocks .AsParallel() .WithDegreeOfParallelism(16) .Select(n => (long)n.Id).Sum(); }
在這種狀況下,NoLockNode 不適合多線程方案,但咱們的基準測試也不會嘗試同時從不一樣的線程獲取兩個實例的 Id。當爭用不多發生時,基準測試模擬了真實場景,在大多數狀況下,應用程序只是使用已建立的標識符。多線程
Method | 平均值 | 標準差 |
---|---|---|
NodeWithLock | 152.2947 ms | 1.4895 ms |
NodeWithNoLock | 149.5015 ms | 2.7289 ms |
咱們能夠看到,差異很是小。CLR 是如何作到得到 100 萬個鎖而幾乎無開銷呢?併發
爲了闡明 CLR 行爲,讓咱們用另外一個案例來擴展咱們的基準測試套件。咱們添加另外一個Node
類,該類在構造函數中調用 GetHashCode
方法(其非重寫版本),而後丟棄結果:
public class Node { public const int InvalidId = -1; private static int s_idCounter; private object syncRoot = new object(); private int m_id = InvalidId; public Node() { GetHashCode(); } public int Id { get { if (m_id == InvalidId) { lock(this) { if (m_id == InvalidId) { m_id = Interlocked.Increment(ref s_idCounter); } } } return m_id; } } }
Method | 平均值 | 標準差 |
---|---|---|
NodeWithLock | 152.2947 ms | 1.4895 ms |
NodeWithNoLock | 149.5015 ms | 2.7289 ms |
NodeWithLockAndGetHashCode | 541.6314 ms | 4.0445 ms |
GetHashCode
調用的結果被丟棄,調用自己不會影響總體的測試時間,由於基準從測量中排除了構造時間。但問題是:有在NodeWithLock
這個例子中,爲何鎖語句的開銷幾乎爲0,而在NodeWithLockAndGetHashCode
中對象實例調用GetHashCode
方法時,開銷明險不一樣?
CLR 中的每一個對象均可用於建立關鍵區域以實現互斥執行。你可能會認爲,爲了作到這一點,CLR爲每一個CLR對象建立一個內核對象。可是,這種方法沒有意義,由於只有很小一部分對象用做同步的句柄。所以,CLR 按需建立同步所需的重量級的數據結構很是有意義。此外,若是 CLR 不須要冗餘數據結構,就不會建立它們。
如你所知,每一個託管對象都有一個稱爲對象頭的輔助字段。對象頭自己可用於不一樣的目的,而且能夠根據當前對象的狀態保留不一樣的信息。
CLR 能夠同時存儲對象的哈希代碼、領域特定信息、與鎖相關的數據以及和一些其餘內容。顯然,4 個字節的對象頭根本不足以知足全部這些功能。所以,CLR 將建立一個稱爲同步塊表的輔助數據結構,而且只在對象頭自己中保留一個索引。可是 CLR 會盡可能避免這種狀況,並嘗試在標頭自己中放置儘量多的數據。
下面是對象頭最重要的字節的佈局:
若是BIT_SBLK_IS_HASH_OR_SYNCBLKINDEX
位爲 0,則頭自己保留全部與鎖相關的信息,鎖稱爲"輕量鎖"。在這種狀況下,對象頭的整體佈局以下:
若是BIT_SBLK_IS_HASH_OR_SYNCBLKINDEX
位爲 1,爲對象建立的同步塊或計算哈希代碼。若是BIT_SBLK_IS_HASHCODE
爲 1(第26位),則雙字其他部分(0 ~ 25位)是對象的哈希代碼,不然,0 ~ 25位表示同步塊索引:
譯者補充:1字=2字節,雙字即爲4字節
雙字的其他部分說的就是對象頭4字節低於26位的部分。上一節咱們說了即便64位對象頭是8字節,實際也只是用了4個字節。
咱們可使用 WinDbg 和 SoS 擴展來研究輕量鎖。首先,咱們對一個簡單對象的鎖語句中中止執行,這不會調用 GetHashCode 方法:
object o = new object(); lock (o) { Debugger.Break(); }
在 WinDbg 中,咱們將運行 .loadby sos clr
來加載 SOS 調試擴展,而後運行兩個命令:DumpHeap -thinlock
查看全部輕量鎖, DumpObj obj
查看咱們在鎖語句中使用實例的狀態:
0:000> !DumpHeap -thinlock Address MT Size 02d223e0 725c2104 12 ThinLock owner 1 (00ea5498) Recursive 0 Found 1 objects. 0:000> !DumpObj /d 02d223e0 Name: System.Object MethodTable: 725c2104 ThinLock owner 1 (00ea5498), Recursive 0
至少有兩種狀況能夠將輕量鎖升級爲"重量鎖":
(1) 另外一個線程的同步根上的爭用,須要建立內核對象;
(2) CLR 沒法將全部信息保留在對象標頭中,例如,對 GetHashCode
方法的調用。
CLR 監視器實現了一種"混合鎖",在建立真正的 Win32 內核對象以前嘗試先自旋。如下是來自 Joe Duffy 的《Windows併發編程》中的監視器的簡短描述:"在單 CPU 計算機上,監視器實現將執行縮減的旋轉等待:當前線程的時間片經過在等待以前調用 SwitchToThread
切換到調度器。在多 CPU 計算機上,監視器每隔一段時間就會產生一個線程,可是在返回到某個線程以前,繁忙的線程會旋轉一段時間,使用指數後退方案來控制它從新讀取鎖狀態的頻率。全部這一切都是爲了在英特爾超線程計算機上正常工做。若是在固定旋轉等待期用完後鎖仍然不可用,就會嘗試將回退到使用基礎 Win32 事件的真實等待。咱們討論一下它是如何工做的。
譯者補充: CLR使用的是混合鎖,先嚐試使用輕量鎖,若鎖長時間被佔用,自旋帶來的開銷會大於用戶態到內核態轉換帶來的開銷,此時就會嘗試使用重量鎖。
譯者補充: 換句直白的話來講,單線程下在未獲取待鎖等待以前,會嘗試切換到其餘線程,而在多線程下使用鎖時,首先會嘗試用自旋鎖,而自旋的時間以指數變化上升,若最終仍然沒有獲取到,則會調用實際的win32 內核模式的真實等待時間。
咱們能夠檢查,在這兩種狀況下,鎖膨脹確實發生,一個輕量鎖被升級爲重量鎖:
object o = new object(); // Just need to call GetHashCode and discard the result o.GetHashCode(); lock (o) { Debugger.Break(); }
0:000> !dumpheap -thinlock Address MT Size Found 0 objects. 0:000> !syncblk Index SyncBlock MonitorHeld Recursion Owning Thread Info SyncBlock Owner 1 011790a4 1 1 01155498 4ea8 0 02db23e0 System.Object
正如您所看到的,只需在同步對象上調用 GetHashCode
,咱們將得到不一樣的結果。如今沒有輕量鎖,同步根具備與其關聯的同步塊。
若是其餘線程長時間佔用鎖,咱們能夠獲得相同的結果:
object o = new object(); lock (o) { Task.Run(() => { // 線程徵用輕量級鎖 lock (o) { } }); // 10 ms 不夠,CLR 自旋會超過10ms. Thread.Sleep(100); Debugger.Break(); }
在這種狀況下,會有同樣的結果:輕量鎖會升級同時會建立同步塊。
0:000> !dumpheap -thinlock Address MT Size Found 0 objects. 0:000> !syncblk Index SyncBlock MonitorHeld Recursion Owning Thread Info SyncBlock Owner 6 00d9b378 3 1 00d75498 1884 0 02b323ec System.Object
如今,基準輸出應該更容易理解。若是 CLR 可使用輕量鎖,則能夠獲取數百萬個鎖,而開銷幾乎爲0。輕量鎖很是高效。要獲取鎖,CLR 將更改對象頭中的幾個位用來存儲線程 ID,等待線程將旋轉,直到這些位變爲非零。另外一方面,若是輕量鎖被升級爲"重量鎖",開銷會變得更加明顯。特別是當得到重量鎖的對象數量至關大時。
微信掃一掃二維碼關注訂閱號傑哥技術分享
出處:http://www.javashuo.com/article/p-repvuqsw-dw.html 做者:傑哥很忙 本文使用「CC BY 4.0」創做共享協議。歡迎轉載,請在明顯位置給出出處及連接。