Аллокации, которых нет в коде: охота на скрытый боксинг в .NET 10

от автора

Самая дорогая аллокация в вашем сервисе та, которой нет в исходниках. Вы написали struct ради zero-allocation, прошли code review, а в проде Gen0-коллекции все равно идут косяком. Потому что между вашим кодом и машинным кодом стоит компилятор, и он молча упаковывает ваш value-тип в кучу там, где вы этого не просили — а на код-ревью этого не видно.

TL;DR. Боксинг (boxing) в .NET — это не только object o = 42. Он прячется в вызовах интерфейсных методов на struct, в дефолтном ValueType.Equals, в params object[]-аргументах, в foreach по интерфейсу и в замыканиях. При этом часть “классических” примеров боксинга из старых гайдов на современном рантайме уже не аллоцирует — JIT научился их вырезать, и слепо копировать советы десятилетней давности вредно. Ниже — карта мест, где боксинг живёт и сейчас, отдельный разбор того, что рантайм уже оптимизировал, реальный мини-кейс, воспроизводимый бенчмарк на BenchmarkDotNet с MemoryDiagnoser, способ ловить упаковку через DOTNET_JitDisasm и dotnet-gcdump, и паттерны лечения без потери читаемости.

О версиях и числах. Всё прверялось на .NET 10 (текущий LTS) и C# 13/14-уровне компилятора, Release, без отладчика, BenchmarkDotNet с MemoryDiagnoser. На .NET 8/9 поведение в основном такое же, но отдельные оптимизации JIT отличаются между мажорными версиями — поэтому главный принцип статьи: не верьте на слово (в том числе мне), гоняйте MemoryDiagnoser на своей версии рантайма. Числа в таблицах ниже — иллюстративные, порядок величины, а не точные замеры с вашего железа.

Пролог: “у нас же всё на struct, откуда Gen0?”

Сервис на горячем пути считает метрики: миллионы маленьких readonly struct-значений в секунду, никакого new, никаких классов в hot path. По задумке — ноль аллокаций. На дашборде — стабильный поток Gen0-коллекций раз в несколько секунд под нагрузкой.

Профайлер показывает аллокации, но стек ведёт в метод, где в коде нет ни одного new. Там цикл по интерфейсу, пара вызовов .Equals(), передача значения в params-метод лога. Глазами — чисто. В машинном коде — box-инструкции на каждой итерации.

Это и есть скрытый боксинг: компилятор C# и JIT упаковывают ваш struct в объект на куче, потому что в конкретной точке кода value-тип нужно представить как ссылочный. Симптом — Gen0-коллекции “из ниоткуда”, и его не видно ни в code review, ни в дампе, пока не посмотришь на IL или дизасм.

Если тема близка — я регулярно разбираю такие штуки по C# и .NET (внутренности рантайма, перформанс, неочевидные грабли с замерами и дизасмом) в своём Telegram-канале: t.me/csharp_ci. Заходите, если интересно копаться глубже.

Что такое боксинг и почему он стоит дорого

Боксинг — это упаковка value-типа (struct, enum, примитив) в объект на управляемой куче. Рантайму нужно выделить заголовок объекта, скопировать туда значение и вернуть ссылку. Анбоксинг — обратная операция с проверкой типа.

Цена не в самой инструкции, а в последствиях: каждая упаковка — это аллокация в Gen0. Много мелких аллокаций на горячем пути означают частые Gen0-коллекции, паузы (пусть и короткие), вытеснение полезных данных из кэша и общий рост CPU на ровном месте. На сервисе с SLA по p99 это бьёт по хвосту латентности так же, как и любая другая лишняя аллокация.

В IL боксинг виден явно — инструкция box. Именно её мы и будем искать.

Шпаргалка: где боксинг есть, а где его уже нет

Сводная карта на один экран — детали по каждой строке ниже. Колонка “Сейчас” — поведение на .NET 10 в типичном случае.

Конструкция

Боксинг сейчас?

Как поймать

Чем лечить

struct через переменную интерфейса

Да

box в IL, Allocated > 0

generic where T : struct, I

foreach по IEnumerable<T>

Да (enumerator)

MemoryDiagnoser

конкретный тип / индекс

struct в HashSet/Dictionary без IEquatable<T>

Да

gcdump: boxed-поля

IEquatable<T> / record struct

params object[] с value-аргументами

Да

box на каждый аргумент

[LoggerMessage], ранний выход по уровню

Лямбда с захватом переменной / this

Да (closure-аллокация)

gcdump: <>c__DisplayClass

static-лямбда

Enum.HasFlag

Нет (с .NET Core 2.1)

дизасм: чистая битовая операция

ничего не нужно

Интерполяция $"x={i}" для примитивов

Нет (C# 10+)

дизасм

ничего не нужно

Generic-метод со struct-аргументом

Нет

Карта боксинга, который актуален и на .NET 10

Места, где компилятор упаковывает ваш struct без явного (object) и где это не оптимизируется даже на свежем рантайме. Это фундамент системы типов, а не временные грабли.

1. Интерфейсный метод на struct через переменную интерфейса

public interface IShape { double Area(); }public struct Circle : IShape{    public double R;    public double Area() => Math.PI * R * R;}// Боксинг: Circle упакован в IShape при присваивании.IShape s = new Circle { R = 2 };double a = s.Area();

Как только struct присваивается переменной интерфейсного типа, он боксится — ведь интерфейс ссылочный. Вызов s.Area() идёт уже по упакованной копии. Если вы держите Circle в локальной переменной конкретного типа и вызываете Area() напрямую — боксинга нет. Грабли вылезают, когда struct “прячут” за интерфейс ради полиморфизма.

Лечение: generic-ограничение where T : struct, IShape вместо переменной интерфейса — JIT специализирует код по value-типу и вызывает метод без упаковки.

2. foreach по интерфейсной коллекции

// List<int>.Enumerator — struct. Но через IEnumerable<int>// он боксится: foreach дёргает IEnumerator<int>.MoveNext().IEnumerable<int> numbers = new List<int> { 1, 2, 3 };foreach (var n in numbers) { /* ... */ }

List<T> отдаёт struct-энумератор, и foreach по List<T> напрямую аллокаций не делает. Но стоит привести коллекцию к IEnumerable<T> — и foreach получает энумератор уже через интерфейс, то есть в боксированном виде. Классическая ловушка: метод принимает IEnumerable<T> “для гибкости” и теряет zero-alloc энумерацию.

Лечение: на горячем пути принимайте конкретный тип (List<T>, массив) или работайте по индексу. Для публичного API — отдельная generic-перегрузка.

3. Дефолтные компараторы и Equals на struct

public struct Point { public int X, Y; }// Если не реализовать IEquatable<Point>,// EqualityComparer<Point>.Default падает на ValueType.Equals// с рефлексией и боксингом полей.var set = new HashSet<Point>();

Если struct не реализует IEquatable<T>, то EqualityComparer<T>.Default для типов с управляемыми полями уходит на медленный путь ValueType.Equals, который сравнивает поля через рефлексию и может боксить. Это бьёт по Dictionary, HashSet и любым местам, где работает дефолтный компаратор на горячем пути.

Лечение: всегда реализуйте IEquatable<T> и переопределяйте GetHashCode() для struct, которые попадают в коллекции или сравниваются на горячем пути. Самый дешёвый способ в C# 10+ — объявить тип как record struct: компилятор сгенерирует корректные Equals, GetHashCode и реализацию IEquatable<T> за вас, без боксинга и без ручного кода.

// Одна строка вместо ручных Equals/GetHashCode/IEquatable.public readonly record struct Point(int X, int Y);

4. params object[] и старые сигнатуры логгеров

int code = 42;// Аргумент уходит в object → боксинг каждого value-аргумента.Log("code={0}, retries={1}", code, retries);void Log(string fmt, params object[] args) { /* ... */ }

Любой params object[]-API упаковывает каждый переданный value-аргумент: int, enum, DateTime, ваш struct. Это до сих пор живая проблема — она не про форматирование строки, а про то, что значение приводится к object. Старые сигнатуры логгеров и многие “удобные” хелперы построены ровно так.

Лечение: на горячем пути логирования — structured logging с типизированными перегрузками через source generator ([LoggerMessage]), либо ранний выход по уровню лога (if (logger.IsEnabled(...))), чтобы не доходить до упаковки аргументов вообще. Современный params Span<T> (C# 13) и перегрузки на ReadOnlySpan<> тоже снимают аллокацию массива — но именно боксинг value→object остаётся, если целевой параметр всё ещё object.

5. Замыкания, захватывающие переменную или this

int counter = 0;Action inc = () => counter++;   // counter поднят в closure-класс на куче

Это не боксинг в строгом смысле, но родственная скрытая аллокация: захват локальной переменной в лямбду порождает класс замыкания (<>c__DisplayClass...) на куче. Захват this тащит за собой весь объект. На горячем пути это такая же Gen0-аллокация, как и box.

Лечение: выносите неизменяемые данные в статические лямбды (static () => ..., C# 9+), передавайте состояние через аргумент-параметр делегата, а не через захват.

Боксинг, который уже НЕ боксит: чему научился рантайм

Половина советов “избегайте боксинга вот тут” в интернете устарела. JIT за последние релизы научился вырезать целый класс упаковок, и слепо переписывать такой код — это усложнение ради нуля выгоды. Несколько примеров, где упаковки уже нет (но проверяйте дизасмом на своей версии!).

Enum.HasFlag давно не боксит. Канонический “грабли”-пример из старых статей: state.HasFlag(State.A) якобы упаковывает оба операнда. Это было правдой во времена .NET Framework, но JIT научился разворачивать типичный HasFlag с известным типом enum в обычную битовую операцию ((state & State.A) == State.A) ещё в эпоху .NET Core 2.1. На .NET 8/10 в большинстве случаев это ноль аллокаций. Ручная замена на (state & State.A) != 0 всё ещё оправдана для абсолютного горячего пути и читается явнее, но “оно боксит” — больше не аргумент.

Интерполяция строк для примитивов часто без боксинга. С C# 10 интерполяция компилируется через DefaultInterpolatedStringHandler, у которого есть generic-перегрузка AppendFormatted<T>(T value). Для int, double и прочих примитивов это путь без упаковки. Боксинг возвращается только тогда, когда значение всё-таки приводится к object — например, уходит в params object[] (см. пункт 4 выше). То есть боксит не интерполяция, а object-параметр на её пути.

Generic-методы со struct-аргументом не боксят сам аргумент. EqualityComparer<T>.Default, List<T>, Dictionary<TKey,TValue> специализируются по value-типу: JIT генерирует отдельный нативный код на каждый struct-аргумент, и упаковки value→object там нет (при условии корректного IEquatable<T> — см. пункт 3).

Вывод раздела: прежде чем “оптимизировать” боксинг по гайду из 2015 года — посмотрите на дизасм. Возможно, рантайм уже сделал работу за вас, и ваша “оптимизация” только ухудшит читаемость.

Мини-кейс: словарь по struct-ключу, который грел Gen0

Сервис роутинга: входящие события раскладываются по Dictionary<RouteKey, Handler>, где RouteKeystruct из двух полей (int TenantId, ServiceType Type). Под нагрузкой 8k событий/с профиль показывал ровный поток Gen0-коллекций, хотя в горячем методе Resolve не было ни одного new.

dotnet-gcdump на боевом инстансе показал десятки тысяч живущих недолго объектов System.Object и обёрток над полями ключа — классический след ValueType.Equals. Причина: RouteKey был обычным struct без IEquatable<RouteKey>. На каждый dict.TryGetValue дефолтный компаратор уходил на рефлексивный путь и боксил поля ключа для сравнения.

Фикс — одна строка, замена объявления:

// было:public struct RouteKey { public int TenantId; public ServiceType Type; }// стало:public readonly record struct RouteKey(int TenantId, ServiceType Type);

record struct сгенерировал IEquatable<RouteKey>, EqualityComparer<RouteKey>.Default пошёл по быстрому пути без упаковки. Gen0-коллекции на горячем пути Resolve практически исчезли, p99 просел на ощутимую величину просто за счёт снятого давления на GC. Изменение кода — ровно одна строка; изменение поведения в проде — заметное на дашборде.

Мини-кейс №2: лямбда в горячем хендлере, тянувшая Gen0

Второй случай — не боксинг в строгом смысле, а родственная closure-аллокация, которую часто валят в ту же кучу. Хендлер запросов внутри цикла обработки батча регистрировал колбэк через лямбду:

// Горячий путь: вызывается на каждый элемент батча.foreach (var item in batch)    _processor.Enqueue(() => Handle(item, _ctx));  // захват item и this(_ctx)

Лямбда захватывает локальную item и неявно this (через _ctx), поэтому на каждой итерации компилятор аллоцирует объект замыкания <>c__DisplayClass. dotnet-gcdump показал ровно их — тысячи короткоживущих <>c__DisplayClass-объектов с именем, ведущим в этот метод.

Фикс — убрать захват, передав состояние явно через параметр, чтобы лямбда стала статической и кэшируемой:

// Состояние передаётся аргументом, лямбда больше ничего не захватывает.foreach (var item in batch)    _processor.Enqueue(static (s) => Handle(s.item, s.ctx), (item, ctx: _ctx));

static-лямбда не порождает closure-класс: компилятор кэширует делегат один раз. Аллокации на горячем пути ушли, profile стал ровным. Мораль та же, что и в первом кейсе: симптом (Gen0 «из ниоткуда») один, а причина прячется не в new, а в том, как компилятор разворачивает ваш синтаксический сахар.

Как это поймать: инструменты

MemoryDiagnoser в BenchmarkDotNet — первая линия

[MemoryDiagnoser][SimpleJob(RuntimeMoniker.Net10_0)]public class BoxingBench{    private readonly List<int> _list = Enumerable.Range(0, 1000).ToList();    [Benchmark(Baseline = true)]    public int OverConcrete()    {        int sum = 0;        foreach (var n in _list) sum += n;   // struct enumerator, 0 B        return sum;    }    [Benchmark]    public int OverInterface()    {        int sum = 0;        IEnumerable<int> seq = _list;        foreach (var n in seq) sum += n;      // боксированный enumerator        return sum;    }}

Колонка Allocated в отчёте — главный индикатор. Если у метода, где вы не делаете new, она не нулевая — где-то прячется боксинг или closure. Сравнение двух почти одинаковых бенчмарков мгновенно показывает цену “гибкости через интерфейс”. Иллюстративно отчёт выглядит так:

Method

Mean

Allocated

OverConcrete (baseline)

~0.6 µs

0 B

OverInterface

~2.1 µs

40 B

Ноль против ненуля в колонке Allocated — вот и весь диагноз. Абсолютные числа у вас будут другими, важен сам факт аллокации на ровном месте.

DOTNET_JitDisasm — увидеть box своими глазами

DOTNET_JitDisasm="BoxingBench:OverInterface" dotnet run -c Release

В дизассемблере вы увидите вызовы аллокатора и обращения к IEnumerator через интерфейс. Для прямого боксинга в IL ищите инструкцию box — её удобно смотреть через ILSpy или dotnet ildasm на релизной сборке. Работает на .NET 8+.

В IL прямой боксинг выглядит так — ищите инструкцию box:

// IL для: object o = 42;ldc.i4.s   42box        [System.Runtime]System.Int32   // <- упаковка int в кучуstloc.0

А вот характерный след в нативном коде на горячем пути: вызов аллокатора прямо в цикле (имена раннеймов и регистры у вас будут другими, важен сам call в аллокатор на каждой итерации):

; фрагмент Tier 1 для метода с боксингом в циклеG_M000_IG04:    mov      rcx, <MethodTable System.Int32>    call     CORINFO_HELP_BOX            ; <- аллокация на каждой итерации    mov      rsi, rax    ; ... дальше работа с упакованной копией

Ключ простой: видите CORINFO_HELP_BOX (или box в IL) внутри горячего цикла — там Gen0-аллокация, которой быть не должно.

dotnet-gcdump и dotnet-trace — в проде

dotnet-trace collect с GC-провайдером даёт аллокационные стеки. dotnet-gcdump снимает снапшот кучи — по нему видно неожиданные boxed-объекты (System.Int32 в куче там, где должны быть только value-типы на стеке) и closure-классы (<>c__DisplayClass...).

Чек-лист: что проверить на горячем пути

  1. Любой struct, который присваивается переменной интерфейсного типа. Кандидат на generic-ограничение where T : struct, IInterface.

  2. Методы, принимающие IEnumerable<T> на горячем пути. Кандидаты на конкретный тип или индексную итерацию.

  3. struct в Dictionary/HashSet без IEquatable<T> и GetHashCode() — переведите на record struct.

  4. params object[]-API и старые сигнатуры логгеров с value-аргументами.

  5. Лямбды, захватывающие локальные переменные или this. Кандидаты на static-лямбды.

  6. Прежде чем чинить “боксинг” по старому гайду (HasFlag, интерполяция) — проверьте дизасмом, что он вообще есть на вашей версии рантайма.

  7. MemoryDiagnoser как обязательная часть перформанс-бенчмарков в CI: колонка Allocated не должна расти между релизами на ключевых методах.

Когда боксинг — это нормально

Не каждый боксинг нужно убивать. Если код не на горячем пути — упаковка одного-двух значений на запрос ничего не стоит, и читаемость важнее. Преждевременная борьба с аллокациями ради методов, которые вызываются десять раз в минуту, — это усложнение кода без выгоды. Правило простое: сначала измерь MemoryDiagnoser-ом и профайлером, найди реально горячие методы, и только там разворачивай тяжёлую артиллерию из generic-специализации и record struct.

Что дальше

В следующей части — разбор родственных скрытых аллокаций: async state machine на горячем пути (когда async-метод аллоцирует, а когда нет), ValueTask против Task и где он реально экономит, а где делает хуже, и Span<T>/stackalloc как способ убрать аллокации буферов целиком. Плюс — готовый набор Roslyn-анализаторов, которые ловят скрытый боксинг ещё на этапе компиляции, до того как он доедет до прода.

Расскажите в комментариях, где у вас в проде нашёлся самый неожиданный боксинг и через какой инструмент вы его в итоге увидели — и какой “боксинг” на современном рантайме оказался уже оптимизированным.

ссылка на оригинал статьи https://habr.com/ru/articles/1049236/