А чё, так можно было? Три недооценённых атрибута .NET

Три атрибута, которые меняют результат компиляции. Один запускает метод раньше Main. Второй передаёт текст выражения вместо результата. Третий ускоряет stackalloc в 50 раз.
Все три описаны в документации и применяются внутри .NET. За его пределами их практически не найти.
Процессор | Система |
Intel Core i9-10900KF 3.70GHz, 10 ядер | Windows 10 22H2 |
AMD Ryzen 9 5950X 3.39GHz, 16 ядер | Windows 10 1809 |
Intel Xeon W-2255 3.70GHz, 10 ядер | Windows Server 2022 |
Intel Xeon Silver 4314 2.40GHz, 2 CPU, 32 ядра | Windows Server 2022 |
Все машины x64
Рантаймы .NET 8, 9 и 10 — все три в одном запуске BenchmarkDotNet 0.15.8.
1. Код, который выполняется до Main
Исходник
Метод помечен атрибутом и больше нигде не встречается:
internal static class Startup
{
[ModuleInitializer]
internal static void Init()
{
Order.Add("инициализатор модуля");
}
}В Main его нет. Там только своя отметка первой строкой:
private static int Main(string[] args)
{
Startup.Order.Add("точка входа");
// ...
}Что выводит отчёт
Порядок событий:
1. инициализатор модуля
2. точка входаОдинаково на четырёх машинах и трёх рантаймах.
Причина
Компилятор находит все методы с этим атрибутом и вызывает их из инициализатора модуля. До этого момента ни одна строка кода сборки не выполняется.
К методу есть требования: статический, без параметров, возвращает void, не обобщённый и не внутри обобщённого типа, доступность internal или public. Любое нарушение — ошибка сборки.
Где встречается
В библиотеке классов такой метод один, в EventSource. Основное применение — генераторы исходного кода: настройка выполняется до Main.
Где легко ошибиться
Рассчитывать на порядок, если инициализаторов несколько. Три метода в разных классах вызвались в порядке объявления и от прогона к прогону не менялись, но в спецификации этот порядок не закреплён.
Бросить исключение. Программа падает до точки входа, и Main не выполняется:
Unhandled exception. System.TypeInitializationException:
The type initializer for '<Module>' threw an exception.
---> System.InvalidOperationException: из инициализатораПрименять в библиотеке. Начиная с .NET 10 включено правило CA2255: библиотека меняет порядок запуска приложения и мешает выбросить неиспользуемый код при публикации.
2. Текст выражения вместо значения
Исходник
Второй параметр помечен атрибутом и получает значение по умолчанию:
static void Check(bool condition,
[CallerArgumentExpression(nameof(condition))] string? text = null)
{
Console.WriteLine(text + " = " + condition);
}Что выводит отчёт
Что попадает в text при разных вызовах:
a + b > 10 ложь
a * b == 6 истина
empty is null истина
!string.IsNullOrEmpty(empty) ложьВ параметр приходит не результат, а то, что написано в месте вызова.
Причина
Подстановка происходит при сборке: компилятор берёт исходный текст аргумента и передаёт его строковой константой. Программа получает готовую строку и ничего с ней не делает.
Замер это подтверждает. Библиотечная проверка и такая же, написанная явно, исключений в замере нет:
Способ | i9-10900KF | Ryzen 9 5950X | Xeon W-2255 | Xeon Silver 4314 |
из библиотеки | 0,3944 | 0,2656 | 0,7058 | 0,6679 |
вручную | 0,3969 | 0,2646 | 0,6427 | 0,7231 |
Наносекунды, .NET 10
Где встречается
В 21-м месте библиотеки классов. Например, ArgumentNullException.ThrowIfNull:
public static void ThrowIfNull([NotNull] object? argument,
[CallerArgumentExpression(nameof(argument))] string? paramName = null)
{
if (argument is null)
{
Throw(paramName);
}
}Поэтому исключение показывает имя переменной из места вызова, а не имя параметра argument. Отчёт это подтверждает:
ArgumentNullException имя аргумента: empty
ArgumentOutOfRangeException имя аргумента: a - 5Во втором случае именем аргумента стало целое выражение — то, что передали.
Где легко ошибиться
Передать текст вторым аргументом. Тогда компилятор ничего не подставляет и берёт то, что написано:
Check(a + b > 10); // текст: a + b > 10
Check(a + b > 10, "передано вручную"); // текст: передано вручнуюЗабыть про размер сборки. Текст каждого аргумента строкой попадает в метаданные: после шести вызовов сборка увеличилась с 4608 байт до 5120.
3. Отказ от обнуления памяти
Исходник
Два одинаковых метода, различаются одной строкой:
[MethodImpl(MethodImplOptions.NoInlining)]
internal static int StackWithInit(int size)
{
Span<byte> buffer = stackalloc byte[size];
buffer[0] = 1;
buffer[^1] = 2;
return buffer[0] + buffer[^1];
}
[SkipLocalsInit]
[MethodImpl(MethodImplOptions.NoInlining)]
internal static int StackWithoutInit(int size)
{
// тело то же самое
}Что показывает замер
Буфер | i9-10900KF | Ryzen 9 5950X | Xeon W-2255 | Xeon Silver 4314 |
64 байта | 2,452 / 1,765 | 2,082 / 4,695 | 2,793 / 2,419 | 5,409 / 4,512 |
256 байт | 6,475 / 1,729 | 5,741 / 4,691 | 8,530 / 2,208 | 13,015 / 4,501 |
1024 байта | 25,505 / 1,769 | 17,712 / 4,703 | 31,281 / 2,574 | 48,282 / 4,566 |
4096 байт | 103,474 / 2,043 | 71,811 / 4,939 | 126,066 / 2,685 | 189,264 / 4,827 |
Наносекунды, с обнулением и без, .NET 10
На четырёх килобайтах разница от 14,5 до 50,6 раза. С обнулением время растёт вместе с буфером, без обнуления — не меняется.
Причина
У метода есть флаг от компилятора, и по нему джит заполняет нулями всю память под локальные переменные, включая ту, что выделена через stackalloc. Атрибут его убирает.
Отсюда и числа в таблице: чем больше буфер, тем дольше заполнение, а без него размер не важен.
Где встречается
Атрибут прописан не в коде, а в общем файле сборки: свойство SkipLocalsInit включено для каждого проекта, входящего в .NET.
Где легко ошибиться
Поставить атрибут ради небольшого буфера. На 64 байтах выигрыш в лучшем случае 1,39 раза, а на Ryzen 9 5950X версия без обнуления медленнее: 4,695 против 2,082.
На этой машине результат отличается от остальных: без заполнения нулями с ростом буфера время не меняется и составляет около 4,7 наносекунды, а на других процессорах оно в пределах 1,7–2,7.
Прочитать буфер раньше, чем в него что-то записали. Атрибут не заполняет память нулями и не требует этого от рантайма — в буфере будут данные от предыдущих вызовов.
Забыть про файл проекта. Без небезопасного контекста атрибут не работает: сборка упадёт с ошибкой CS0227.
Границы замеров
Все замеры сняты на x64 под Windows, на .NET 8, 9 и 10.
У всех измеряемых методов запрещено встраивание. Иначе компилятор перенесёт код метода в замер, а вместе с ним заполнение памяти нулями.
SkipLocalsInit проставлен на отдельных методах, а не на классе: на классе он подействует и на тот вариант, который заполняет память нулями.
В замере на вход всегда передаётся непустая строка, поэтому проверка на null ни разу не срабатывает. Так в замер попадает только сама проверка, без обработки исключения.
Код из статьи
AttributeProof — замеры, отчёты и выгрузки с четырёх машин
Ссылки
Всем удачи и до новых встреч!
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.