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

Xexa
10.09.2026 04:16Уходили от плюсов(и прочего "не безопасного"), костылями приделывают обратно. А как же парадигма ООП? Как же "если не должно быть видно, то прячем поглубже из публичности"?
Цель понятна ибо "надо", но путь по которому движется - стрёмен.
Способ из.NET 8 выглядит так. Метод объявлен как extern static, тела у него нет — его подставляет рантайм:
Точно в рантайме? Не смотрел реализацию, но выглядит как-то не оптимально в рантайме тратить время на генерацию кода.

Gromilo
10.09.2026 04:16Всякое бывает. Мне приходилось подменять статическую локальную функцию в библиотеке парсинга екселя, чтобы улучшить этот самый парсинг.

Fedorkov
10.09.2026 04:16В плюсах по умолчанию всё опасно, а безопасность требует плясок с бубном.
В шарпе - наоборот. Так и было задумано, чтобы грязные хаки выглядели как грязные хаки и привлекали особое внимание.

Craftist
10.09.2026 04:16Если оно по скорости такое же, как прямой доступ к полю, и работает в компайл тайме, то почему отсутствие поля с таким названием выбрасывает исключение, а не падает при компиляции? И какой IL оно подставляет, если передать несуществующее поле? Какое смещение? После конца класса, что ли?

Gromilo
10.09.2026 04:16Я так понял в момент первого вызова вычисляется смещение и jit либо генерит доступ к полю, либо генерит метод, бросающий исключение.

Geronom Автор
10.09.2026 04:16Поиск по имени проходит разово когда идет первая подготовка метода к выполнению. Не при компиляции.
Рантайм ищет поле по имени и не найдя бросает
MissingFieldException— unsafeaccessors.cpp, строка 1146. Если найдет то сгенерит IL с обычнымldfldпо смещению данного поля. Вариант вида «после конца класса» не существует прост не будет никакой генерации.Компилятор проверить не может: имя задано строкой в атрибуте, а поле приватное и в какой сборке может быть сразу неизсетно.

LaRN
10.09.2026 04:16А что за задача, где доли наносекунд играют роль? Это метод нужно миллиард раз дернуть и то даде секунды не будет.

vvdev
10.09.2026 04:16Всевозможные симуляции, например.
Специально сейчас глянул один из лёгоньких своих прогонов - более 100 миллиардов индивидуальных значений, каждое пропускается через достаточно сложный вычислительный граф, несколько раз агрегируются по не самым простым наборам правил и опять пропускаются через следующий вычислительный граф.Каждая оптимизация, очевидно, имеет накопительный эффект, и наоборот - каждая наивная "абы как"/"чистый код" имплементация тоже имеет накопительный эффект, но с противоположным знаком.
Лично наблюдал/участвовал в переписывании симуляционных движков, где время прогона снижалось сначала с суток-полутора-двух до двух-трёх часов, а потом и до часа/десятков минут.
...да даже UI - там где нужен действительно сложный/многоуровневый пересчёт подсказок/индикаторов/итого при редактировании полей и без заметного глазу лага.
vesper-bot
на то он и unsafe, чтобы работать без каких-либо проверок в рантайме. зато вы теперь знаете, во сколько обходятся все эти “безопасные” фишки в языке.
Gromilo
Какие фишки? Чем отличается от безопасного доступа к публичному полю?
vesper-bot
может там под капотом геттер втихаря вызывается, а они виртуальные, и надо вначале найти правильный, это запрос к классу, где у него геттер поля, потом вызов, всё это обернуто в код, ловящий исключения, без дизасма вызова не сказать. А тут хоп и “получить память по смещению от базового указателя”, одна инструкция, кроме того объект, если мелкий, целиком в кэш прочитан, код даже в память не ходит лишний раз.
Gromilo
Это если есть геттер
С полем протестил клодом, получился доступ по смещению