1. 程式人生 > >託管物件本質-第二部分-物件頭佈局和鎖成本

託管物件本質-第二部分-物件頭佈局和鎖成本

目錄

  • 託管物件本質-第二部分-物件頭佈局和鎖成本
    • 目錄
    • 輕量鎖、鎖膨脹和物件頭佈局
    • 結論


託管物件本質-第二部分-物件頭佈局和鎖成本

原文地址:https://devblogs.microsoft.com/premier-developer/managed-object-internals-part-2-object-header-layout-and-the-cost-of-locking/
原文作者:Sergey
譯文作者:傑哥很忙

目錄

託管物件本質1-佈局
託管物件本質2-物件頭佈局和鎖成本
託管物件本質3-託管陣列結構
託管物件本質4-欄位佈局

我從事當前專案時遇到了一個非常有趣的情況。對於給定型別的每個物件,我必須建立一個始終增長的識別符號,但需要注意:
1) 該解決方案可以在多執行緒環境中工作
2) 物件的數量相當大,多達千萬。
3) 標識應該按需建立,因為不是每個物件都需要它。

在最初的實現過程中,我還沒有意識到應用程式將處理的數量,因此我提出了一個非常簡單的解決方案:

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,等待執行緒將旋轉,直到這些位變為非零。另一方面,如果輕量鎖被升級為"重量鎖",開銷會變得更加明顯。特別是當獲得重量鎖的物件數量相當大時。



微信掃一掃二維碼關注訂閱號傑哥技術分享
出處:https://www.cnblogs.com/Jack-Blog/p/12259258.html
作者:傑哥很忙
本文使用「CC BY 4.0」創作共享協議。歡迎轉載,請在明顯位置給出出處及連結。