Самая дорогая аллокация в вашем сервисе та, которой нет в исходниках. Вы написали 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 в типичном случае.
|
Конструкция |
Боксинг сейчас? |
Как поймать |
Чем лечить |
|---|---|---|---|
|
|
Да |
|
generic |
|
|
Да (enumerator) |
|
конкретный тип / индекс |
|
|
Да |
gcdump: boxed-поля |
|
|
|
Да |
|
|
|
Лямбда с захватом переменной / |
Да (closure-аллокация) |
gcdump: |
|
|
|
Нет (с .NET Core 2.1) |
дизасм: чистая битовая операция |
ничего не нужно |
|
Интерполяция |
Нет (C# 10+) |
дизасм |
ничего не нужно |
|
Generic-метод со |
Нет |
— |
— |
Карта боксинга, который актуален и на .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>, где RouteKey — struct из двух полей (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...).
Чек-лист: что проверить на горячем пути
-
Любой
struct, который присваивается переменной интерфейсного типа. Кандидат на generic-ограничениеwhere T : struct, IInterface. -
Методы, принимающие
IEnumerable<T>на горячем пути. Кандидаты на конкретный тип или индексную итерацию. -
structвDictionary/HashSetбезIEquatable<T>иGetHashCode()— переведите наrecord struct. -
params object[]-API и старые сигнатуры логгеров с value-аргументами. -
Лямбды, захватывающие локальные переменные или
this. Кандидаты наstatic-лямбды. -
Прежде чем чинить “боксинг” по старому гайду (
HasFlag, интерполяция) — проверьте дизасмом, что он вообще есть на вашей версии рантайма. -
MemoryDiagnoserкак обязательная часть перформанс-бенчмарков в CI: колонкаAllocatedне должна расти между релизами на ключевых методах.
Когда боксинг — это нормально
Не каждый боксинг нужно убивать. Если код не на горячем пути — упаковка одного-двух значений на запрос ничего не стоит, и читаемость важнее. Преждевременная борьба с аллокациями ради методов, которые вызываются десять раз в минуту, — это усложнение кода без выгоды. Правило простое: сначала измерь MemoryDiagnoser-ом и профайлером, найди реально горячие методы, и только там разворачивай тяжёлую артиллерию из generic-специализации и record struct.
Что дальше
В следующей части — разбор родственных скрытых аллокаций: async state machine на горячем пути (когда async-метод аллоцирует, а когда нет), ValueTask против Task и где он реально экономит, а где делает хуже, и Span<T>/stackalloc как способ убрать аллокации буферов целиком. Плюс — готовый набор Roslyn-анализаторов, которые ловят скрытый боксинг ещё на этапе компиляции, до того как он доедет до прода.
Расскажите в комментариях, где у вас в проде нашёлся самый неожиданный боксинг и через какой инструмент вы его в итоге увидели — и какой “боксинг” на современном рантайме оказался уже оптимизированным.
ссылка на оригинал статьи https://habr.com/ru/articles/1049236/