託管對象本質-第二部分-對象頭佈局和鎖成本



託管對象本質-第二部分-對象頭佈局和鎖成本

原文地址: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,等待線程將旋轉,直到這些位變爲非零。另外一方面,若是輕量鎖被升級爲"重量鎖",開銷會變得更加明顯。特別是當得到重量鎖的對象數量至關大時。


20191127212134.png
微信掃一掃二維碼關注訂閱號傑哥技術分享
出處:http://www.javashuo.com/article/p-repvuqsw-dw.html 做者:傑哥很忙 本文使用「CC BY 4.0」創做共享協議。歡迎轉載,請在明顯位置給出出處及連接。

相關文章
相關標籤/搜索