1. 程式人生 > >託管物件本質-第三部分-託管陣列結構

託管物件本質-第三部分-託管陣列結構

目錄

  • 託管物件本質-第三部分-託管陣列結構
    • 目錄
    • 陣列協方差和一些歷史
    • 內部結構和實現細節
    • 通過刪除型別檢查來提高效能


託管物件本質-第三部分-託管陣列結構

原文地址:https://devblogs.microsoft.com/premier-developer/managed-object-internals-part-3-the-layout-of-a-managed-array-3/
原文作者:Sergey
譯文作者:傑哥很忙

目錄

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

陣列是每個應用程式的基本構建基塊之一。即使你不是每天直接使用陣列,你也可以將他們作為庫包的一部分間接使用。

C# 一直都有陣列結構,陣列結構也是唯一一個類似泛型而且型別安全的資料結構。現在你可能沒有那麼頻繁的直接使用他們,但是為了提升效能,都有可能會從一些更高階的資料結構(比如List<T>)切換回陣列。

陣列和CLR有著非常特殊的關係,但是今天我們將從使用者的角度來探討它們。我們將討論以下內容:
* 探索一個最奇怪的 C# 功能,稱為陣列協方差
* 討論陣列的內部結構
* 探索一些效能技巧,我們可以這樣做,從陣列中擠壓更多的效能

陣列協方差和一些歷史

C# 語言中最奇怪的特徵之一是陣列協方差:能夠將T型別的陣列賦值給object型別或任何其他T型別的基類的陣列的能力。

string[] strings = new[] { "1", "2" };
object[] objects = strings;

這個轉換並完全是型別安全的。如果objects變數僅用於讀取資料,那麼一切正常。但是,如果嘗試修改陣列時,如果引數的型別不相容,則可能會失敗。

objects[0] = 42; //執行時錯誤

關於這個特性,.NET 社群中有一個眾所周知的笑話:C# 作者在一開始非常努力地將 Java 生態系統的各個方面複製到 CLR 世界,所以他們也複製了語言設計問題。

但是我並不認為這是原因:)

在 90 年代後期,CLR 並沒有泛型,對嗎?在這種情況下,語言使用者如何編寫處理任意資料型別陣列的可重用程式碼?例如,如何編寫將任意陣列轉儲到控制檯的函式?

一種方法是定義接收object[]的函式,並通過將陣列複製到物件陣列來強制每個呼叫方手動轉換陣列。這是可行的,但效率很低。另一種解決方案是允許從任何引用型別的陣列轉換為object[],即保留 Derived[]Base[]IS-A 關係,其中派生類從基類繼承。在值型別的陣列中,轉換不起作用,但至少可以實現一些通用性。

第一個 CLR 版本中缺少泛型,迫使設計人員削弱型別系統。但是這個決定(我想)是經過深思熟慮的,而不僅僅是來自Java生態系統的模仿。

內部結構和實現細節

陣列協方差在編譯時在型別系統中開啟一個洞,但這並不意味著型別錯誤會使應用程式崩潰(C++中的類似"錯誤"將導致"未定義行為")。CLR 將確保型別安全,但檢查會在執行時進行。為此,CLR 必須儲存陣列元素的型別,並在使用者嘗試更改陣列例項時進行檢查。幸運的是,此檢查僅對引用型別的陣列是必需的,因為struct是密封的(sealed),因此不支援繼承。

譯者補充:struct由於是值型別,我們可以檢視它的IL語言,可以看到struct前會有sealed關鍵字。

儘管不同值型別(如 intbyte)之間存在隱式轉換,但 int[]byte[]之間沒有隱式或顯式轉換。陣列協方差轉換是引用轉換,它不會更改轉換物件的佈局,並保留要轉換物件的引用標識。

在舊版本的 CLR 中,引用陣列和值型別具有不同的佈局。引用型別的陣列具有對每個例項中元素的型別控制代碼的引用:

這在最新版本的 CLR 中已更改,現在元素型別儲存在方法表中:

有關佈局的詳細資訊,請參閱 CoreClr 程式碼庫中的以下程式碼段:

  • ArrayBase::GetArrayElementTypeHandle宣告:
// Get the element type for the array, this works whether the element
// type is stored in the array or not
inline TypeHandle GetArrayElementTypeHandle() const;
  • PtrArray::GetArrayElementTypeHandle 實現:
TypeHandle GetArrayElementTypeHandle()
{
    LIMITED_METHOD_CONTRACT;
    return GetMethodTable()->GetApproxArrayElementTypeHandle();
}
  • Table::GetApproxArrayElementTypeHandle實現:
TypeHandle GetApproxArrayElementTypeHandle()
{
    LIMITED_METHOD_DAC_CONTRACT;
    _ASSERTE(IsArray());
    return TypeHandle::FromTAddr(m_ElementTypeHnd);
}
  • MethodTable::m_ElementTypeHnd 宣告:
union
{
    PerInstInfo_t m_pPerInstInfo;
    TADDR         m_ElementTypeHnd;
    TADDR         m_pMultipurposeSlot1;
};

我不確定陣列佈局是什麼時候改變的,但似乎在速度和(託管)記憶體之間有一個權衡。由於記憶體區域性性,初始實現(當型別控制代碼儲存在每個陣列例項中時)訪問應該更快,但肯定有不可忽略的記憶體開銷。當時,所有引用型別的陣列都有共享方法表。但現在情況不一樣了:每個引用型別的陣列都有自己的方法表,該表指向相同的 EEClass ,指標指向元素型別控制代碼的指標。

也許CLR團隊的人可以解釋一下.

我們知道 CLR 如何儲存陣列的元素型別,現在我們可以探索 CoreClr 程式碼庫,看看實現型別檢查。

首先,我們需要找到檢查發生的位置。陣列是 CLR 的一種非常特殊的型別,IDE 中沒有"轉到宣告"按鈕來"反編譯"陣列並顯示原始碼。但是我們知道,檢查發生在索引器setter中,它與一組IL指令StElem*相對應:
* StElem.i4 用於整型陣列
* StElem 用於任意值型別陣列
* StElem.ref 用於引用型別陣列

瞭解指令後,我們可以輕鬆地在程式碼庫中找到實現。據我所知,實現在了jithelpers.cpp中。下面是方法JIT_Stelem_Ref_Portable稍微簡化的版本:

/****************************************************************************/
/* assigns 'val to 'array[idx], after doing all the proper checks */

HCIMPL3(void, JIT_Stelem_Ref_Portable, PtrArray* array, unsigned idx, Object *val)
{
    FCALL_CONTRACT;

    if (!array)
    {
        // ST: explicit check that the array is not null
        FCThrowVoid(kNullReferenceException);
    }
    if (idx >= array->GetNumComponents())
    {
        // ST: bounds check
        FCThrowVoid(kIndexOutOfRangeException);
    }

    if (val)
    {
        MethodTable *valMT = val->GetMethodTable();
        // ST: getting type of an array element
        TypeHandle arrayElemTH = array->GetArrayElementTypeHandle();

        // ST: g_pObjectClass is a pointer to EEClass instance of the System.Object
        // ST: if the element is object than the operation is successful.
        if (arrayElemTH != TypeHandle(valMT) && arrayElemTH != TypeHandle(g_pObjectClass))
        {
            // ST: need to check that the value is compatible with the element type
            TypeHandle::CastResult result = ObjIsInstanceOfNoGC(val, arrayElemTH);
            if (result != TypeHandle::CanCast)
            {
                // ST: ArrayStoreCheck throws ArrayTypeMismatchException if the types are incompatible
                if (HCCALL2(ArrayStoreCheck, (Object**)&val, (PtrArray**)&array) != NULL)
                {
                    return;
                }
            }
        }

        HCCALL2(JIT_WriteBarrier, (Object **)&array->m_Array[idx], val);
    }
    else
    {
        // no need to go through write-barrier for NULL
        ClearObjectReference(&array->m_Array[idx]);
    }
}

通過刪除型別檢查來提高效能

現在我們知道,CLR 確實在底層確保引用型別陣列的型別安全。對陣列例項的每個"寫入"都有一個附加檢查,如果陣列在熱路徑上中使用,則該檢查不可忽略。但在得出錯誤的結論之前,讓我們先看看這個檢查的效能消耗程度。

譯者補充:熱路徑指的是那些會被頻繁呼叫的程式碼塊。

為了避免檢查,我們可以更改 CLR,或者使用一個眾所周知的技巧:將物件包裝到結構中

public struct ObjectWrapper
{
    public readonly object Instance;
    public ObjectWrapper(object instance)
    {
        Instance = instance;
    }
}

比較 object[] 和 ObjectWrapper[]的時間

private const int ArraySize = 100_000;
private object[] _objects = new object[ArraySize];
private ObjectWrapper[] _wrappers = new ObjectWrapper[ArraySize];
private object _objectInstance = new object();
private ObjectWrapper _wrapperInstanace = new ObjectWrapper(new object());

[Benchmark]
public void WithCheck()
{
    for (int i = 0; i < _objects.Length; i++)
    {
        _objects[i] = _objectInstance;
    }
}

[Benchmark]
public void WithoutCheck()
{
    for (int i = 0; i < _objects.Length; i++)
    {
        _wrappers[i] = _wrapperInstanace;
    }
}

結果如下:

Method 平均值 錯誤 標準差
WithCheck 807.7 us 15.871 us 27.797 us
WithoutCheck 442.7 us 9.371 us 8.765 us

不要被"幾乎 2 倍"的效能差異所迷惑。即使在最壞的情況下,分配 100K 元素也不到一毫秒。效能表現非常好。但在現實世界中,這種差異是顯而易見的。

許多效能關鍵的 .NET 應用程式使用物件池。池允許重用託管例項,而無需每次都建立新例項。此方法降低了記憶體壓力,並可能對應用程式效能產生非常合理的影響。

可以基於併發資料結構(如ConcurrentQueue)或基於簡單陣列實現物件池。下面是 Roslyn 程式碼庫中物件池實現的程式碼段:

internal class ObjectPool<T> where T : class
{
    [DebuggerDisplay("{Value,nq}")]
    private struct Element
    {
        internal T Value;
    }

    // Storage for the pool objects. The first item is stored in a dedicated field because we
    // expect to be able to satisfy most requests from it.
    private T _firstItem;
    private readonly Element[] _items;

    // other members ommitted for brievity
}

該實現管理一個快取項陣列,但池化並不是直接使用 T[],而是將 T 包裝到結構元素中,以避免在執行時進行檢查。

前段時間,我在應用程式中修復了一個物件池,使得解析階段的效能提升了的 30%。這不是由於我在這裡描述的技巧,是與池的併發訪問相關。但關鍵是,物件池可能位於應用程式的熱路徑上,甚至像上面提到的小效能改進也可能對整體效能產生明顯影響。



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