Чтение приватного поля через рефлексию занимает 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 — замеры, отчёты и выгрузки с четырёх машин
Ссылки
Всем удачи и до новых встреч!
ссылка на оригинал статьи https://habr.com/ru/articles/1080538/