Чтение приватного поля через рефлексию занимает 8,96 наносекунды и выделяет 24 байта в куче. Тот же доступ без рефлексии — 0,25 наносекунды и ноль байт.

Разница в 35,8 раза, а на.NET 8 она доходила до 108. Способ появился в.NET 8, работает без генерации кода и применяется внутри библиотеки классов. 

Машина

Процессор

Система

Комп 1

Intel Core i9-10900KF 3.70GHz, 10 ядер

Windows 10 22H2

Комп 2

AMD Ryzen 9 5950X 3.39GHz, 16 ядер

Windows 10 1809

Комп 3

Intel Xeon W-2255 3.70GHz, 10 ядер

Windows Server 2022

Комп 4

Intel Xeon Silver 4314 2.40GHz, 2 CPU, 32 ядра

Windows Server 2022

Все машины x64

Рантаймы.NET 8, 9 и 10 — все три в одном запуске BenchmarkDotNet 0.15.8. Числа в тексте с AMD Ryzen 9 5950X, таблицы по всем четырём машинам.

1. Как достать приватное поле

Исходник

Есть класс, исходник которого править нельзя: чужая библиотека, пакет из хранилища, часть самой платформы.

internal sealed class Secret
{
    private string _token = "TopSecret";
    private int _counter = 42;
}

Привычный способ добраться до поля — рефлексия:

FieldInfo field = typeof(Secret).GetField("_token",
    BindingFlags.Instance | BindingFlags.NonPublic);
 
string token = (string)field.GetValue(secret);

Способ из.NET 8 выглядит так. Метод объявлен как extern static, тела у него нет — его подставляет рантайм:

[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_token")]
internal static extern ref string Token(Secret instance);
 
string token = Token(secret);   // чтение
Token(secret) = "Изменено";     // запись

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

Что показывает замер

Чтение поля со строкой. Всё, что можно подготовить заранее, подготовлено: описание поля найдено один раз, дерево выражений скомпилировано, метод из инструкций IL собран. 

Способ

Комп 1

Комп 2

Комп 3

Комп 4

прямое чтение внутри класса

0,3006

0,8650

0,9838

0,7647

UnsafeAccessor

0,2898

0,7547

1,0182

0,7140

дерево выражений

1,5103

1,6780

2,2035

2,7431

метод из инструкций IL

2,2986

2,1556

3,1487

4,3177

рефлексия с готовым полем

2,2552

3,0206

2,9642

3,5407

рефлексия с поиском поля

11,6615

11,1472

16,3642

15,4772

Наносекунды,.NET 10 

UnsafeAccessor работает за то же время, что прямое чтение внутри самого класса. Рефлексия с готовым описанием поля медленнее в 2,9–7,8 раза, с поиском поля на каждом обращении — в 14,8–40,2 раза.

Причина

Рефлексия работает в рантайме: описание поля хранит смещение, тип и права доступа, а GetValue каждый раз проверяет, разрешено ли обращение, и приводит результат к object.

UnsafeAccessor работает при компиляции метода. Рантайм ищет поле один раз, когда впервые компилирует метод доступа, и подставляет в него обычное чтение по смещению. Дальше это тот же машинный код, что у прямого обращения к полю.

Где легко ошибиться

Имя поля задаётся строкой, и опечатку компилятор не увидит. Сборка пройдёт, а при первом вызове будет исключение:

[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_tokenn")]
internal static extern ref string Missing(Secret instance);
 
// Сборка прошла: True
//   вызов не прошёл
//   тип исключения: System.MissingFieldException
//   сообщение:      Field not found: 'Secret._tokenn'.

Рефлексия в той же ситуации возвращает null из GetField, и проверить это можно до первого обращения.

Поиск идёт только в том типе, который указан первым аргументом. По базовым типам рантайм не проходит: поле, объявленное в родителе, таким способом не достать.

У структуры первый аргумент нужно объявлять по ссылке. Иначе метод получит копию, и запись в поле потеряется:

[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_counter")]
internal static extern ref int ValueCounter(ref SecretValue instance);

2. Откуда берутся 0,25 наносекунды

Исходник

То же самое, но поле не строка, а число:

[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_counter")]
internal static extern ref int Counter(Secret instance);

Что показывает замер

Способ

Комп 1

Комп 2

Комп 3

Комп 4

прямое чтение внутри класса

0,3987

0,3354

0,4634

0,7005

UnsafeAccessor

0,4053

0,2504

0,4826

0,7247

рефлексия

7,5632

8,9606

11,7065

13,2518

Наносекунды,.NET 10. У рефлексии дополнительно 24 байта в куче 

Разница от 18,3 до 35,8 раза. И к ней добавляются 24 байта на каждое обращение — столбец Allocated одинаков на всех четырёх машинах.

Причина

GetValue возвращает object. Число значимого типа в object не помещается: под него выделяется объект в куче, куда копируется значение. Это и есть 24 байта: 16 служебных плюс 8 под данные с округлением.

UnsafeAccessor возвращает ref int. Значение остаётся там, где было, в кучу ничего не попадает.

На миллионе обращений в секунду набирается 24 миллиона байт мусора, и вся эта память проходит через сборщик мусора.

Где легко ошибиться

Считать, что разница одинакова на всех версиях. Она разная, и сильно: 

Рантайм

Комп 1

Комп 2

Комп 3

Комп 4

NET 8

91,7

108,3

97,6

78,2

NET 9

76,6

117,4

84,9

57,2

NET 10

18,7

35,8

24,3

18,3

Во сколько раз рефлексия медленнее при чтении числа 

В.NET 10 рефлексию ускорили: на AMD Ryzen 9 5950X чтение числа занимало 33,74 наносекунды на восьмёрке и 8,96 на десятке. Отставание сократилось втрое, но никуда не делось.

3. Приватный метод

Исходник

Тем же атрибутом объявляется доступ и к методу. Меняется только вид доступа:

[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "Reveal")]
internal static extern string Reveal(Secret instance, int times);

Что показывает замер

Способ

Комп 1

Комп 2

Комп 3

Комп 4

прямой вызов внутри класса

8,886

11,70

14,05

15,76

UnsafeAccessor

9,041

10,54

13,95

17,68

делегат от описания метода

11,463

14,43

16,12

20,65

рефлексия

25,876

33,45

40,27

47,96

Наносекунды,.NET 10 

Разница меньше, чем на полях: 2,7–3,2 раза. Зато у рефлексии 104 байта против 48 у остальных.

Причина

MethodInfo.Invoke принимает аргументы массивом object. Массив выделяется на каждом вызове, число в нём упаковывается, а результат приводится к object и распаковывается обратно. Отсюда и лишние 56 байт.

Где легко ошибиться

Подпись метода, включая возвращаемый тип, должна в точности совпасть с исходной. При расхождении будет MissingMethodException — снова при первом вызове, а не при сборке.

4. Где UnsafeAccessor применяют в библиотеке классов

UnsafeAccessor применяется в библиотеке классов 7 раз. Один из случаев — ResourceReader, которому нужен BinaryFormatter:

[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "Deserialize")]
private static extern object Deserialize(
    [UnsafeAccessorType(BinaryFormatterTypeName)] object formatter,
    Stream serializationStream);
 
[UnsafeAccessor(UnsafeAccessorKind.Constructor)]
[return: UnsafeAccessorType(BinaryFormatterTypeName)]
private static extern object CreateBinaryFormatter();

Второй атрибут — UnsafeAccessorType, он появился в.NET 10. Тип задаётся строкой, ровно как в Type.GetType, и ссылка на сборку при этом не нужна. Так библиотека классов работает с типом, которого в её зависимостях нет.

То же самое в AppDomain — там таким способом достаётся GenericPrincipal из другой сборки.

Отсюда и практическая граница: при написании кода нужно знать имя поля или метода и его подпись. Там, где тип приходит в рантайме и заранее неизвестен, рефлексия остаётся единственным вариантом.

Границы замеров

Все замеры сняты на x64 под Windows, на.NET 8, 9 и 10.

У всех способов подготовка вынесена за пределы замера: описание поля найдено один раз, дерево выражений скомпилировано, метод из инструкций IL собран, делегат создан. Вариант с поиском поля на каждом обращении замерен отдельной строкой и в сравнении с UnsafeAccessor не участвует.

UnsafeAccessor не отменяет проверок доступа при сборке чужого кода: он снимает их только у того метода, который объявлен с этим атрибутом. Подпись сборки ни один из способов не обходит.

Код из статьи

  • PrivateAccessProof — замеры, отчёты и выгрузки с четырёх машин

Ссылки

Всем удачи и до новых встреч!

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Знали про UnsafeAccessor?
15.38%Все знают4
19.23%Слышал, но не пробовал5
65.38%А чё, так можно было что ли!17
Проголосовали 26 пользователей. Воздержавшихся нет.