惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

S
Schneier on Security
The Register - Security
The Register - Security
月光博客
月光博客
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
The GitHub Blog
The GitHub Blog
博客园 - 司徒正美
罗磊的独立博客
U
Unit 42
S
SegmentFault 最新的问题
Y
Y Combinator Blog
博客园_首页
Hugging Face - Blog
Hugging Face - Blog
J
Java Code Geeks
Schneier on Security
Schneier on Security
Know Your Adversary
Know Your Adversary
C
Check Point Blog
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Simon Willison's Weblog
Simon Willison's Weblog
V
Vulnerabilities – Threatpost
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
阮一峰的网络日志
阮一峰的网络日志
The Hacker News
The Hacker News
博客园 - 叶小钗
C
Cybersecurity and Infrastructure Security Agency CISA
Spread Privacy
Spread Privacy
L
LINUX DO - 热门话题
T
The Exploit Database - CXSecurity.com
P
Palo Alto Networks Blog
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Latest news
Latest news
L
Lohrmann on Cybersecurity
A
About on SuperTechFans
L
LangChain Blog
Stack Overflow Blog
Stack Overflow Blog
S
Securelist
A
Arctic Wolf
D
Darknet – Hacking Tools, Hacker News & Cyber Security
T
Threatpost
Scott Helme
Scott Helme
博客园 - 聂微东
博客园 - 【当耐特】
T
Tenable Blog
I
Intezer
D
DataBreaches.Net
B
Blog RSS Feed
Security Latest
Security Latest
C
Cisco Blogs
T
Tor Project blog
N
Netflix TechBlog - Medium

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Аллокации, которых нет в коде: охота на скрытый боксинг в .NET 10
Илья Бреговой · 2026-06-19 · via Все публикации подряд на Хабре

10 мин

36

Самая дорогая аллокация в вашем сервисе та, которой нет в исходниках. Вы написали 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   42
box        [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-анализаторов, которые ловят скрытый боксинг ещё на этапе компиляции, до того как он доедет до прода.

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