InterpolatedStringHandler: избавляемся от лишних аллокаций в логах

от автора

Классический ILogger.LogInformation($"User {userId}") выглядит безобидно, но на деле компилятор:

  1. Формирует итоговую строку через string.Format-like логику.

  2. Боксит userId, DateTime, struct-ы и прочее добро.

  3. Линкует всё в object[] ради структурированных логов.

Аллокационная цена вопроса — порядка 80 Б на сообщение (плюс трансферы в LOH, если вы особо многословны).

В .NET 8 Microsoft даже вынесла отдельный раздел «high-performance logging» и честно сказала: «Да, обычные extension-методы логов боксят и аллоцируют»

С выходом C# 10 компилятор научился разбирать $"строка" не напрямую в string, а в handler: структуру, которая получает куски литералов и плейсхолдеры. Базовый – DefaultInterpolatedStringHandler. Он уже экономичнее, потому что:

  • сразу знает literalLength и formattedCount,

  • арендует буфер через ArrayPool<char>.Shared,

  • не делает лишних копий.

Но главная фича — можно написать собственный handler.

Пишем LogInterpolatedStringHandler

[InterpolatedStringHandler] public readonly struct LogInterpolatedStringHandler {     private readonly bool _enabled;     private readonly StringBuilder? _sb;      public LogInterpolatedStringHandler(         int literalLength,         int formattedCount,         ILogger logger,         LogLevel level,         out bool isEnabled)     {         _enabled = isEnabled = logger.IsEnabled(level);         _sb = _enabled ? new(literalLength) : null;     }      public void AppendLiteral(string s)     {         if (_enabled) _sb!.Append(s);     }      public void AppendFormatted<T>(T value)     {         if (_enabled) _sb!.Append(value);     }      public override string ToString() => _sb?.ToString() ?? string.Empty; }

out bool isEnabled — если лог-уровень не подходит, компилятор еще на этапе разбора строки даст early-exit, и Append* даже не вызовутся. Структура readonly struct: минимально возможный размер + отсутствие heap-allocов самого хендлера.

Встраиваем в ILogger

public static class LoggerInterpolatedExtensions {     public static void LogInfo(this ILogger logger,         [InterpolatedStringHandlerArgument("", "level")]         LogInterpolatedStringHandler message,         LogLevel level = LogLevel.Information)         => logger.Log(level, new EventId(), message.ToString(), null, (_, __) => _); }

Теперь можно лаконично:

logger.LogInfo($"User {userId} processed {items.Count} items in {elapsedMs} ms");

Если текущий минимальный уровень — Warning, весь $"…" даже не начнет собираться.

Бенчмарки

[MemoryDiagnoser] public class LogBench {     private readonly ILogger _log = new NullLogger(); // пустышка      [Benchmark(Baseline = true)]     public void Baseline_StringInterpol() =>         _log.LogInformation($"User {_id} logged at {DateTime.Now}");      [Benchmark]     public void InterpolatedHandler() =>         _log.LogInfo($"User {_id} logged at {DateTime.Now}"); }

Method

Mean (ns)

Alloc (B)

Baseline_StringInterpol

82.4

88

InterpolatedHandler

9.5

0

Ускорение х8, аллокации 0. На реальном сервисе (5 К RPS, ~3 лога/запрос) мы получили −120 MiB аллоцированной памяти в минуту и опустили Gen2-сборки до нуля.

LoggerMessageAttribute

Так же .NET 6 есть source-generator LoggerMessage. Он тоже zero-alloc, но:

  • требуются статические partial-методы для каждой фразы;

  • менять текст логов — пересобирать код;

  • нет гибкости в рантайме.

InterpolatedStringHandler дает ту же производительность, но с привычным $"…", а рабочий код остается декларативным.

Взвращаем семантику

В базовом варианте наш LogInterpolatedStringHandler складывает всё в StringBuilder, и на выходе остается обычная строка. Для Kibana или Seq-дашбордов этого мало — нужны property values с именами. Решается одной строчкой: забираем исходный «токен» выражения через CallerArgumentExpression и кладем его в пару ключ-значение.

public void AppendFormatted<T>(         T value,         [CallerArgumentExpression("value")] string? name = null) {     if (!_enabled) return;     _sb!.Append($"{{{name}:{value}}}");   // или пишем во вложенный Dictionary }

Теперь logger.LogInfo($"UserId {userId} processed {count}") приедет в ELK как поля UserId=42, count=17. Минимум макросов, ноль аллокаций — а семантика сохранена.

Кастомное форматирование

Хотите красивый вывод чисел или временных меток? Добавляем перегрузку с IFormatProvider:

public void AppendFormatted<T>(         T value,         string? format,         IFormatProvider? provider = null,         [CallerArgumentExpression("value")] string? name = null) {     if (!_enabled) return;     _sb!.AppendFormat(provider, $"{{0:{format}}}", value); }

Итоги квартала: $"{revenue,12:N0}" придут уже с разделителями тысяч. Даты: $"{timestamp:yyyy-MM-ddTHH:mm:ss.fff}" — и никакого ToString(...) в коде.

Да, к флоу добавились несколько IL-инструкций, но профилировщик показывает < 1 нс на вызов, аллокаций всё так же 0 Б. Win-win.

Возможные проблемы

Как обойти

Захват this при вызове не-static методов

Делайте хендлер readonly struct и передавайте всё через параметры

Логи с Exception — нужен stackTrace

Примите Exception? ex отдельным аргументом у расширения и сохраните прежний pipe

Обновление Microsoft.Extensions.Logging

Хендлер работает начиная с C# 10. На проектах C# 9 потребуется LangVersion=preview

Итог

InterpolatedStringHandler — это не очередной синтаксический сахар, а хорошая техника для zero-allocation logging в .NET 8+. Два коротких файла-расширения, и вы экономите сотни мегабайт ОЗУ на каждой прод-ноде, убираете stop-the-world паузы, а самое главное — сохраняете привычный, читаемый $"..."-синтаксис.


Когда архитектурные решения или проблемы с асинхронностью становятся преградой для роста, важно знать, как их эффективно решать. Эти открытые уроки помогут вам разобраться с ключевыми моментами и избежать распространенных ошибок:

Пройдите вступительное тестирование курса «C# Developer. Professional» и получите спеццену и доступ к записям уроков.


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