Blittable-типы

.NET C#

Вопрос дня: что выведет нижеприведённый код?

[StructLayout(LayoutKind.Explicit)]
public struct UInt128
{
    [FieldOffset(0)]
    public ulong Value1;
    [FieldOffset(8)]
    public ulong Value2;
}
[StructLayout(LayoutKind.Sequential)]
public struct MyStruct
{
    public UInt128 UInt128;
    public char Char;
}
class Program
{
    public static unsafe void Main()
    {
        var myStruct = new MyStruct();
        var baseAddress = (int)&myStruct;
        var uInt128Adress = (int)&myStruct.UInt128;
        Console.WriteLine(uInt128Adress - baseAddress);
        Console.WriteLine(Marshal.OffsetOf(typeof(MyStruct), "UInt128"));
    }
}

Если вы подумали, что в консоли напечатается два нуля (или просто два одинаковых значения), то вам нужно узнать больше про внутреннее устройство структур в .NET. Ниже представлены результаты выполнения кода в зависимости от рантайма:

MS.NET-x86MS.NET-x64Mono
uInt128Adress - baseAddress480
Marshal.OffsetOf(typeof(MyStruct), "UInt128")000

Чтобы разобраться с ситуацией, нам необходимо узнать больше про blittable-типы.

Теория

Википедия даёт следующее определение blittable-типов:

Blittable types are data types in software applications which have a unique characteristic. Data are often represented in memory differently in managed and unmanaged code in the Microsoft .NET framework. However, blittable types are defined as having an identical presentation in memory for both environments, and can be directly shared. Understanding the difference between blittable and non-blittable types can aid in using COM Interop or P/Invoke, two techniques for interoperability in .NET applications.

Другими словами, это такие типы, которые представлены одинаково в управляемой или неуправляемой памяти. Данная характеристика очень важна, если вы собираетесь маршалить ваши структуры. Согласитесь, было бы очень здорово, если бы поля структуры уже лежали бы в памяти именно в том порядке, в котором вы собираетесь их куда-то передавать. Кроме того, имеется ряд ситуаций, в которых вы можете использовать только blittable-типы. Примеры:

  • Типы, которые возвращаются через P/Invoke.
  • Типы, которые вы можете сделать pinned (Есть оптимизация, благодаря которой при маршалинге такие типы делаются pinned, а не копируются явно).

Давайте разберёмся в этой теме подробней: какие же типы являются blittable и что на это влияет?

Для понимания дальнейшего материала также полезно знать про атрибут System.Runtime.InteropServices.StructLayoutAttribute, с помощью которого можно контролировать метод физической организации данных структуры при экспорте в неуправляемый код. С помощью параметра LayoutKind можно задать один из трёх режимов:

  • Auto: Среда CLR автоматически выбирает соответствующее размещение для членов объекта в неуправляемой памяти. Доступ к объектам, определенным при помощи этого члена перечисления, не может быть предоставлен вне управляемого кода. Попытка выполнить такую операцию вызовет исключение.
  • Explicit: Точное положение каждого члена объекта в неуправляемой памяти управляется явно в соответствии с настройкой поля StructLayoutAttribute.Pack. Каждый член должен использовать атрибут FieldOffsetAttribute для указания положения этого поля внутри типа.
  • Sequential: Члены объекта располагаются последовательно, в порядке своего появления при экспортировании в неуправляемую память. Члены располагаются в соответствии с компоновкой, заданной в StructLayoutAttribute.Pack, и могут быть несмежными.

Два последних значения (Explicit и Sequential) также называются Formatted, т.к. явно задают порядок полей. C# использует Sequential в качестве значения по умолчанию.

Blittable types

Очень важно понимать, какие именно типы являются blittable. Итак, Blittable-типами являются:

Non-Blittable Types

Есть несколько non-blittable-типов, о которых хотелось бы поговорить подробней.

Decimal

Да, Decimal не является blittable-типом. Если вам нужно использовать его для blittable-целей, то придётся написать обёртку вида (основано на методе от Hans Passant, см. Why is “decimal” data type non-blittable?):

public struct BlittableDecimal
{
    private long longValue;

    public decimal Value
    {
        get { return decimal.FromOACurrency(longValue); }
        set { longValue = decimal.ToOACurrency(value); }
    }

    public static explicit operator BlittableDecimal(decimal value)
    {
        return new BlittableDecimal { Value = value };
    }

    public static implicit operator decimal (BlittableDecimal value)
    {
        return value.Value;
    }
}

DateTime

Занимательный факт: DateTime содержит единственное UInt64 поле, но LayoutKind явно выставлен в Auto:

[StructLayout(LayoutKind.Auto)]
[Serializable]
public struct DateTime : 
  IComparable, IFormattable, IConvertible, ISerializable, IComparable<DateTime>,IEquatable<DateTime> {
    
    // ...
                    
    // The data is stored as an unsigned 64-bit integeter
    //   Bits 01-62: The value of 100-nanosecond ticks where 0 represents 1/1/0001 12:00am, up until the value
    //               12/31/9999 23:59:59.9999999
    //   Bits 63-64: A four-state value that describes the DateTimeKind value of the date time, with a 2nd
    //               value for the rare case where the date time is local, but is in an overlapped daylight
    //               savings time hour and it is in daylight savings time. This allows distinction of these
    //               otherwise ambiguous local times and prevents data loss when round tripping from Local to
    //               UTC time.
    private UInt64 dateData;
    
    // ...
}

Это означает, что DateTime не является blittable-типом. Значит, если ваша структура содержит DateTime-поле, то она также будет non-blittable. Данный факт имеет исторические причины и вызывает массу недоумения у людей, см: Why does the System.DateTime struct have layout kind Auto?, Why does LayoutKind.Sequential work differently if a struct contains a DateTime field? (для понимания происходящего рекомендую прочитать вот этот ответ от Hans Passant).

Для DateTime можно написать blittable-обёртку:

public struct BlittableDateTime
{
    private long ticks;

    public DateTime Value
    {
        get { return new DateTime(ticks); }
        set { ticks = value.Ticks; }
    }

    public static explicit operator BlittableDateTime(DateTime value)
    {
        return new BlittableDateTime { Value = value };
    }

    public static implicit operator DateTime(BlittableDateTime value)
    {
        return value.Value;
    }
}

Guid

Вы наверняка знаете про тип Guid, но знаете ли вы то, как он устроен внутри? Давайте взглянем на исходный код:

private int         _a;
private short       _b;
private short       _c;
private byte       _d;
private byte       _e;
private byte       _f;
private byte       _g;
private byte       _h;
private byte       _i;
private byte       _j;
private byte       _k;

// Creates a new guid from an array of bytes.
public Guid(byte[] b)
{
    // Some checks ...

    _a = ((int)b[3] << 24) | ((int)b[2] << 16) | ((int)b[1] << 8) | b[0];
    _b = (short)(((int)b[5] << 8) | b[4]);
    _c = (short)(((int)b[7] << 8) | b[6]);
    _d = b[8];
    _e = b[9];
    _f = b[10];
    _g = b[11];
    _h = b[12];
    _i = b[13];
    _j = b[14];
    _k = b[15];
}

Интересненько, не правда ли? Если мы почитаем википедию, то найдём там следующую табличку:

BitsBytesNameEndianness (Microsoft GUID Structure)Endianness (RFC 4122)
324Data1NativeBig
162Data2NativeBig
162Data3NativeBig
648Data4BigBig

GUID имеет следующий Type library representation:

typedef struct tagGUID {
    DWORD Data1;
    WORD  Data2;
    WORD  Data3;
    BYTE  Data4[ 8 ];
} GUID;

Важным является тот факт, что представление GUID в памяти является платформозависимым. Если вы работаете с little-endian-архитектурой (а это скорее всего так, см. Endianness), то представление Guid будет отличаться от RFC 4122, что может создать некоторые проблемы при взаимодействии .NET с другими системами (например, Java UUID использует RFC 4122).

Char

Char также является non-blittable-типом, при маршалинге он может конвертироваться в Unicode или ANSI символ. За тип маршалинга отвечает CharSet атрибута StructLayout, который может принимать значения: Auto, Ansi, Unicode. На современных версиях Windows Auto превращается в Unicode, но во времена Windows 98 и Windows Me Auto превращался в Ansi. C# компилятор использует значение Ansi по умолчанию, что делает char не blittable-типом. Однако, мы можем написать следующую обёртку, чтобы победить проблему:

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct BlittableChar
{
    public char Value;

    public static explicit operator BlittableChar(char value)
    {
        return new BlittableChar { Value = value };
    }

    public static implicit operator char (BlittableChar value)
    {
        return value.Value;
    }
}

Boolean

MSDN говорит нам следующую вещь про Boolean:

Converts to a 1, 2, or 4-byte value with true as 1 or -1.

Давайте напишем ещё одну обёртку, чтобы решить проблему:

public struct BlittableBoolean
{
    private byte byteValue;

    public bool Value
    {
        get { return Convert.ToBoolean(byteValue); }
        set { byteValue = Convert.ToByte(value); }
    }

    public static explicit operator BlittableBoolean(bool value)
    {
        return new BlittableBoolean { Value = value };
    }

    public static implicit operator bool (BlittableBoolean value)
    {
        return value.Value;
    }
}

Blittable или Non-Blittable?

Порой очень полезно понять, является ли наш тип Blittable. Как это сделать? Нам поможет знание о том, что мы не можем аллоцировать pinned-версию экземпляра такого типа. Для удобства мы можем написать следующий helper-класс (основано на методе IllidanS4, см. The fastest way to check if a type is blittable?):

public static class BlittableHelper
{
    public static bool IsBlittable<T>()
    {
        return IsBlittableCache<T>.Value;
    }

    public static bool IsBlittable(this Type type)
    {
        if (type.IsArray)
        {
            var elem = type.GetElementType();
            return elem.IsValueType && IsBlittable(elem);
        }
        try
        {
            object instance = FormatterServices.GetUninitializedObject(type);
            GCHandle.Alloc(instance, GCHandleType.Pinned).Free();
            return true;
        }
        catch
        {
            return false;
        }
    }

    private static class IsBlittableCache<T>
    {
        public static readonly bool Value = IsBlittable(typeof(T));
    }
}

Но есть один особый тип, для которого приведённый helper будет работать неправильно: decimal. Удивительно, но вы можете сделать pinned alloc для decimal-а! Впрочем, pinned alloc для структуры, которая содержит decimal, работать не будет, т.к. decimal всё-таки не является blittable-типом. Я не знаю других типов, с которыми возникает подобная проблема, поэтому можно позволить себе немного похакать и добавить в начало метода IsBlittable вот такие строчки:

if (type == typeof(decimal))
    return false;

Если вы знаете более элегантное решение, то буду рад комментариям.

CoreCLR-исходники

CoreCLR нынче имеет открытый исходный код, так что можно посмотреть, как же там всё устроено внутри. Сегодня нас больше всего будет интересовать файл fieldmarshaler.cpp, там можно найти следующие строчки:

if (!(*pfDisqualifyFromManagedSequential))
{
    // This type may qualify for ManagedSequential. Collect managed size and alignment info.
    if (CorTypeInfo::IsPrimitiveType(corElemType))
    {
        pfwalk->m_managedSize = ((UINT32)CorTypeInfo::Size(corElemType)); // Safe cast - no primitive type is larger than 4gb!
        pfwalk->m_managedAlignmentReq = pfwalk->m_managedSize;
    }
    else if (corElemType == ELEMENT_TYPE_PTR)
    {
        pfwalk->m_managedSize = sizeof(LPVOID);
        pfwalk->m_managedAlignmentReq = sizeof(LPVOID);
    }
    else if (corElemType == ELEMENT_TYPE_VALUETYPE)
    {
        TypeHandle pNestedType = fsig.GetLastTypeHandleThrowing(ClassLoader::LoadTypes,
                                                                CLASS_LOAD_APPROXPARENTS,
                                                                TRUE);
        if (pNestedType.GetMethodTable()->IsManagedSequential())
        {
            pfwalk->m_managedSize = (pNestedType.GetMethodTable()->GetNumInstanceFieldBytes());

            _ASSERTE(pNestedType.GetMethodTable()->HasLayout()); // If it is ManagedSequential(), it also has Layout but doesn't hurt to check before we do a cast!
            pfwalk->m_managedAlignmentReq = pNestedType.GetMethodTable()->GetLayoutInfo()->m_ManagedLargestAlignmentRequirementOfAllMembers;
        }
        else
        {
            *pfDisqualifyFromManagedSequential = TRUE;
        }
    }
    else
    {
        // No other type permitted for ManagedSequential.
        *pfDisqualifyFromManagedSequential = TRUE;
    }
}

Разбор примера

Давайте вернёмся к примеру из начала поста. Теперь понятно, почему под MS.NET мы можем наблюдать разницу. Marshal.OffsetOf(typeof(MyStruct), "UInt128") выдаёт нам «честный» offset, который получается при маршалинге, он равен 0. А вот про внутреннее устройство структуры никаких гарантий CLR не даёт, ведь наша структура не является blittable:

Console.WriteLine(BlittableHelper.IsBlittable<MyStruct>()); // False

Но теперь мы знаем, как исправить ситуацию и сделать код более предсказуемым: заменим char на нашу обёртку blittableChar:

[StructLayout(LayoutKind.Sequential)]
public struct MyStruct
{
    public UInt128 UInt128;
    public BlittableChar Char;
}

Console.WriteLine(uInt128Adress - baseAddress); // 0
Console.WriteLine(Marshal.OffsetOf(typeof(MyStruct), "UInt128")); // 0
Console.WriteLine(BlittableHelper.IsBlittable<MyStruct>()); // True

Не советую закладываться на то, что вы можете предсказать устройство non-blittable-типов в памяти, оно зависит от многих факторов. Следующая модификация примера показывает, что non-blittable-типы также могут быть представлены в памяти без переставления полей:

[StructLayout(LayoutKind.Sequential)]
public struct UInt128
{
    public ulong Value1;
    public ulong Value2;
}
[StructLayout(LayoutKind.Sequential)]
public struct MyStruct
{
    public UInt128 UInt128;
    public char Char;
}

Console.WriteLine(uInt128Adress - baseAddress); // 0
Console.WriteLine(Marshal.OffsetOf(typeof(MyStruct), "UInt128")); // 0
Console.WriteLine(BlittableHelper.IsBlittable<MyStruct>()); // False

NuGet & GitHub

Приведённые в посте обёртки я выложил на GitHub и оформил в виде NuGet-пакета:

Надеюсь, кому-нибудь это будет полезно. Если у вас есть что добавить, то пул-реквесты приветствуются.

Ссылки