Чтение приватного поля через рефлексию занимает 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)


  1. vesper-bot
    10.09.2026 04:16

    на то он и unsafe, чтобы работать без каких-либо проверок в рантайме. зато вы теперь знаете, во сколько обходятся все эти “безопасные” фишки в языке.


    1. Gromilo
      10.09.2026 04:16

      Какие фишки? Чем отличается от безопасного доступа к публичному полю?


      1. vesper-bot
        10.09.2026 04:16

        может там под капотом геттер втихаря вызывается, а они виртуальные, и надо вначале найти правильный, это запрос к классу, где у него геттер поля, потом вызов, всё это обернуто в код, ловящий исключения, без дизасма вызова не сказать. А тут хоп и “получить память по смещению от базового указателя”, одна инструкция, кроме того объект, если мелкий, целиком в кэш прочитан, код даже в память не ходит лишний раз.


        1. Gromilo
          10.09.2026 04:16

          Это если есть геттер

          С полем протестил клодом, получился доступ по смещению

          // string token = Token(secret);
          mov   rax, gword ptr [rcx+0x08] ret
          
          // Token(secret) = “Изменено”; 
          lea   rcx, bword ptr [rcx+0x08] 
          call  CORINFO_HELP_ASSIGN_REF   // write barrier, т.к. поле ссылочное 
          nop 
          ret


  1. Xexa
    10.09.2026 04:16

    Уходили от плюсов(и прочего "не безопасного"), костылями приделывают обратно. А как же парадигма ООП? Как же "если не должно быть видно, то прячем поглубже из публичности"?

    Цель понятна ибо "надо", но путь по которому движется - стрёмен.

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

    Точно в рантайме? Не смотрел реализацию, но выглядит как-то не оптимально в рантайме тратить время на генерацию кода.


    1. Gromilo
      10.09.2026 04:16

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


    1. Fedorkov
      10.09.2026 04:16

      В плюсах по умолчанию всё опасно, а безопасность требует плясок с бубном.

      В шарпе - наоборот. Так и было задумано, чтобы грязные хаки выглядели как грязные хаки и привлекали особое внимание.


  1. Craftist
    10.09.2026 04:16

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


    1. Gromilo
      10.09.2026 04:16

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


    1. Geronom Автор
      10.09.2026 04:16

      Поиск по имени проходит разово когда идет первая подготовка метода к выполнению. Не при компиляции.

      Рантайм ищет поле по имени и не найдя бросает MissingFieldException — unsafeaccessors.cpp, строка 1146. Если найдет то сгенерит IL с обычным ldfld по смещению данного поля. Вариант вида «после конца класса» не существует прост не будет никакой генерации.

      Компилятор проверить не может: имя задано строкой в атрибуте, а поле приватное и в какой сборке может быть сразу неизсетно.


  1. LaRN
    10.09.2026 04:16

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


    1. vvdev
      10.09.2026 04:16

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

      Каждая оптимизация, очевидно, имеет накопительный эффект, и наоборот - каждая наивная "абы как"/"чистый код" имплементация тоже имеет накопительный эффект, но с противоположным знаком.

      Лично наблюдал/участвовал в переписывании симуляционных движков, где время прогона снижалось сначала с суток-полутора-двух до двух-трёх часов, а потом и до часа/десятков минут.

      ...да даже UI - там где нужен действительно сложный/многоуровневый пересчёт подсказок/индикаторов/итого при редактировании полей и без заметного глазу лага.