Чтение приватного поля через рефлексию занимает 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 — замеры, отчёты и выгрузки с четырёх машин
Ссылки
Всем удачи и до новых встреч!

