Классический ILogger.LogInformation($"User {userId}")
выглядит безобидно, но на деле компилятор:
Формирует итоговую строку через
string.Format
-like логику.Боксит
userId
,DateTime
, struct-ы и прочее добро.Линкует всё в
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.
Возможные проблемы
Как обойти |
|
---|---|
Захват |
Делайте хендлер |
Логи с Exception — нужен |
Примите |
Обновление Microsoft.Extensions.Logging |
Хендлер работает начиная с C# 10. На проектах C# 9 потребуется |
Итог
InterpolatedStringHandler
— это не очередной синтаксический сахар, а хорошая техника для zero-allocation logging в .NET 8+. Два коротких файла-расширения, и вы экономите сотни мегабайт ОЗУ на каждой прод-ноде, убираете stop-the-world паузы, а самое главное — сохраняете привычный, читаемый $"..."
-синтаксис.
Когда архитектурные решения или проблемы с асинхронностью становятся преградой для роста, важно знать, как их эффективно решать. Эти открытые уроки помогут вам разобраться с ключевыми моментами и избежать распространенных ошибок:
24 июня в 20:00. Два подхода DDD: Rich Model vs Anemic Model
разберем, когда и как использовать анемичную или богатую модель для проектирования гибких и поддерживаемых систем.17 июля в 20:00. Асинхронность в C#: За гранью await. Паттерны, Ошибки и Оптимизация
разберм сложные сценарии асинхронного программирования и методы устранения ошибок и повышения производительности асинхронного кода.
Пройдите вступительное тестирование курса "C# Developer. Professional" и получите спеццену и доступ к записям уроков.