☰
Serilog 日志管道(Pipeline)吞吐基准测试深度解读:读懂 419ns 背后的一条日志完整旅程
2026/10/15 21:17:48 网站建设 项目流程
  • 后端
  • 可观测性

【免费下载链接】serilog

Simple .NET logging with fully-structured events

项目地址:https://gitcode.com/gh_mirrors/se/serilog
点击查看免费下载

本篇以仓库内 results/net4.5.2/PipelineBenchmark-report-github.md 基准报告为核心,结合 PipelineBenchmark.cs 基准源码与 Logger.cs 等管道实现源码,逐层拆解 Serilog 在 .NET Framework 4.5.2 下一次日志写入的完整调用链、吞吐量数据的正确读法,以及如何在本仓库中复现该基准。读完本文,你将能独立解读 BenchmarkDotNet 环境信息、定位管道各环节开销来源,并掌握用dotnet test过滤运行单项目性能基准的完整方法。

一、基准报告概览:先读懂 BenchmarkDotNet 的环境头

报告文件(results/net4.5.2/PipelineBenchmark-report-github.md)是一个标准的 BenchmarkDotNet GitHub 输出格式文件,正文仅一张方法表,但表头之前的ini代码块蕴含了判断数据可信度的全部关键信息:

字段报告值含义
BenchmarkDotNet.Corev0.9.9.0使用的 BenchmarkDotNet 版本,说明该报告生成于较早期(v0.9.x)
OSMicrosoft Windows NT 6.2.9200.0Windows 8(NT 6.2),64 位桌面环境
ProcessorIntel(R) Core(TM) i7-3720QM CPU 2.60GHz, ProcessorCount=82012 年发布的 Ivy Bridge 移动端四核八线程 CPU
Frequency / Resolution / Timer2533306 ticks / 394.7411 ns / TSC计时器为 TSC(时间戳计数器),分辨率约 395ns
CLRMS.NET 4.0.30319.42000, Arch=64-bit RELEASE [RyuJIT].NET Framework 4.5.2 运行时(CLR 版本 4.0),64 位 Release + RyuJIT
GCConcurrent Workstation工作站并发 GC,属默认配置
JitModulesclrjit-v4.6.1080.0JIT 模块版本
Type / ModePipelineBenchmark / Throughput被测类型与 BenchmarkDotNet 的 Throughput(吞吐量)模式

结果表只有一行:

Method | Median | StdDev | ------------- |------------ |---------- | EmitLogEvent | 419.2931 ns | 4.3815 ns |

即在.NET Framework 4.5.2 + i7-3720QM + NullSink(空接收器)这一特定组合下,单次日志写入(EmitLogEvent)的中位数耗时约为419.29 纳秒,标准差仅 4.38ns,数据稳定性良好。换算成吞吐量,粗略约为每秒 238 万次写入(10⁹ ÷ 419.29),但这只是该硬件与最小配置下的理论上限,真实应用中会因 Sink 输出、模板复杂度、对象解构而显著下降。

二、被测对象:PipelineBenchmark 究竟在测什么

基准源码位于 test/Serilog.PerformanceTests/PipelineBenchmark.cs,注释明确说明其意图是「测试经由日志管道的写入成本」(Tests the cost of writing through the logging pipeline):

[MemoryDiagnoser] public class PipelineBenchmark { ILogger _log = null!; Exception _exception = null!; [GlobalSetup] public void Setup() { _exception = new Exception("An Error"); _log = new LoggerConfiguration() .WriteTo.Sink(new NullSink()) .CreateLogger(); } [Benchmark] public void EmitLogEvent() { _log.Information(_exception, "Hello, {Name}!", "World"); } [Benchmark] public void IntProperties() { _log.Information(_exception, "Hello, {A} {B} {C}!", 1, 2, 3); } }

两个关键设计:

  1. 最小化外部干扰:NullSink(Support/NullSink.cs)的Emit(LogEvent)方法体为空,因此测得的是**「日志事件构建 + 管道分发」本身**的开销,不含任何格式化输出、IO 或网络写入成本——这保证了数字的纯净性,聚焦于管道核心路径。
  2. [MemoryDiagnoser]特性要求 BenchmarkDotNet 同时统计内存分配;当前 net4.5.2 报告未含分配列,但同仓库的 netcoreapp2.0 报告(results/netcoreapp2.0/PipelineBenchmark-report-github.md)记录了同一方法Mean 837.9 ns、Allocated 432 B的完整数据,可作跨运行时对照。注意两份报告运行于不同机器、不同运行时,不能直接横向比较绝对值,只能作为不同环境下的各自参考点。

三、源码级拆解:一条日志在 419ns 里走了哪些路

从_log.Information(_exception, "Hello, {Name}!", "World")到NullSink.Emit(),事件要依次经过Logger.Write→MessageTemplateProcessor.Process→LogEvent构造 →PostLevelCheckEmit→_sink.Emit五个阶段。以下每步均可对照 Logger.cs 核实。

3.1 入口:Write 重载与 IsEnabled 快速短路

Logger.Information(...)扩展最终汇入 Logger.Write(LogEventLevel, Exception?, string, params object?[]) 重载:

public void Write(LogEventLevel level, Exception? exception, string messageTemplate, params object?[]? propertyValues) { if (!IsEnabled(level)) return; if (messageTemplate == null!) return; ... var logTimestamp = DateTimeOffset.Now; _messageTemplateProcessor.Process(messageTemplate, propertiesSpan, out var parsedTemplate, out var boundProperties); var currentActivity = Activity.Current; var logEvent = new LogEvent(logTimestamp, level, exception, parsedTemplate, boundProperties, ...); PostLevelCheckEmit(logEvent); SelfMetrics.PipelineEventEmitted.Add(1); }

而 IsEnabled 的实现是:

public bool IsEnabled(LogEventLevel level) { if (level < _minimumLevel) return false; return _levelSwitch == null || level >= _levelSwitch.MinimumLevel; }

源码注释特别强调:简单场景下等级检查必须是一个极快(可驻留 CPU 缓存)的读取,因此Logger把最小等级单独存放在_minimumLevel字段中,而不是每次都解引用LoggingLevelSwitch——这是 419ns 得以成立的第一个关键优化。

3.2 模板解析:MessageTemplateCache 缓存命中

管道通过 MessageTemplateProcessor.cs 完成解析与属性绑定:

parsedTemplate = _parser.Parse(messageTemplate); properties = _propertyBinder.ConstructProperties(parsedTemplate, messageTemplateParameters);

_parser是 MessageTemplateCache.cs 包装的MessageTemplateParser。缓存采用Hashtable + ByReferenceStringComparer(而非Dictionary<,>),注释给出了明确理由:Hashtable 在「单写多读」场景下线程安全,而Dictionary<,>读操作需加锁。缓存还有两条自我保护规则:

  • MaxCacheItems = 1000:达到上限后整体Clear(),防止错误用法(动态拼接消息文本而非使用模板)导致 OOM;
  • MaxCachedTemplateLength = 1024:超长模板绕过缓存直接走内部解析器。

基准中"Hello, {Name}!"在预热阶段即已缓存,因此热路径上这一步是近似无成本的字典查找。

3.3 属性绑定:PropertyBinder 按位置构造属性

PropertyBinder.cs 中ConstructProperties先判断模板是位置型还是命名型属性:"Hello, {Name}!"属位置型(PositionalProperties非空),进入ConstructPositionalProperties,按模板中属性 token 的位置,用ConstructProperty将值"World"交给PropertyValueConverter转成ScalarValue。"World"是内置标量类型(string),走 PropertyValueConverter.cs 中BuiltInScalarTypes直接判定的快速路径,无反射、无解构。

3.4 事件构造与分发:Enrich → Sink.Emit

LogEvent构造后进入 PostLevelCheckEmit:

void PostLevelCheckEmit(LogEvent logEvent) { try { _enricher.Enrich(logEvent, _messageTemplateProcessor); } catch (Exception ex) { SelfLog.WriteLine("Exception {0} caught while enriching {1} with {2}.", ex, logEvent, _enricher); } _sink.Emit(logEvent); }

Enricher 异常被捕获并写入 SelfLog,保证日志管道自身不因增强器异常而崩溃;随后_sink.Emit落到NullSink空实现,本阶段成本几乎为零。最后SelfMetrics.PipelineEventEmitted.Add(1)向 .NET 的System.Diagnostics.Metrics计数器serilog.pipeline.event_emitted(SelfMetrics.cs)累加 1,这是除日志功能外的固定计量开销。

综合来看,419ns 主要分配于:等级检查 + 模板缓存查找 + 属性值转换 + LogEvent 构造 + 计数累加,且在整个路径上受益于「模板复用 + 标量快速路径 + 空 Sink」三项设计。

四、同仓库关联基准:把管道数字放进坐标系

单独一个 419ns 缺少参照,仓库 results 目录下保留了大量同批次的报告,可交叉理解管道的成本构成:

  • LevelControlBenchmark-report-github.md:同一台 i7-3720QM 机器上,等级开关闭合时(Off)仅 4.10ns、开关切换约 5.0ns,而等级开启后约 14ns——说明IsEnabled的快速短路确实让「被过滤掉的日志」几乎零成本,是管道之外的第一道也是最便宜的一道闸门;
  • MessageTemplateParsingBenchmark-report-github.md:空模板解析约 183ns,默认控制台输出模板({Timestamp:yyyy-MM-dd HH:mm:ss} [{Level}] {Message}{NewLine}{Exception})解析约 2.6μs——这正是模板缓存存在的意义:解析是一次性的,命中缓存后不进入管道热路径;
  • MessageTemplateRenderingBenchmark-report-github.md:无属性的模板渲染仅 5ns,带多属性模板约 423ns、分配 96B——渲染成本由属性数量主导;
  • AllocationsBenchmark-report-github.md:对比标量、字典、序列、匿名对象等不同值形态的写入与格式化分配。

将这些数据并置可知:Serilog 管道的设计核心是把「昂贵但一次性的解析」与「便宜但高频的写入」彻底分离,命中缓存的模板 + 标量值 + 空 Sink 正是测得 419ns 的边界条件。

五、实战复现:在本仓库跑出你自己的 Pipeline 数据

仓库提供了两条复现路径,均基于 BenchmarkDotNet:

5.1 一键全量(推荐先读脚本)

仓库根目录的 RunPerfTests.ps1 会先执行Build.ps1构建,再对每个*.PerformanceTests项目执行dotnet test -c Release:

./RunPerfTests.ps1

注意:它会运行全部性能基准(含 Harness 中所有 Fact),耗时较长,适合完整回归。

5.2 仅运行 Pipeline 基准

基准通过 xUnit 承载:Harness.cs 中的Pipeline()测试即BenchmarkRunner.Run<PipelineBenchmark>(),因此可精确过滤:

dotnet test -c Release -f net8.0 --filter "FullyQualifiedName=Serilog.PerformanceTests.Harness.Pipeline"
  • 目标框架以当前 Serilog.PerformanceTests.csproj 为准(net10.0;net9.0;net8.0),仓库中的历史报告(net4.5.2、netcoreapp2.0 等)对应当时的多目标框架,已随 SDK 演进不再保留于项目文件,复现时请以现有目标框架为准;
  • 一定要用-c Release,否则 JIT 未优化、数据无参考意义;
  • BenchmarkDotNet 版本为 0.15.2(csproj 中 PackageReference),输出格式与 v0.9.9 的历史报告略有差异,但Median/Mean/Allocated等核心列仍在;
  • 运行完毕后 BenchmarkDotNet 会在输出目录生成*-report-github.md等格式文件,与仓库 results 目录下的历史报告格式同源,可直接对比阅读。

5.3 按 BenchmarkDotNet 惯例读结果

Throughput 模式下,结果表的Median(或Mean)是该操作在迭代中的典型耗时;StdDev描述抖动;若报告含Gen 0/Allocated列(如 netcoreapp2.0 报告的 432 B),则代表每次操作的平均托管分配——分配越多,GC 压力越大,是比纯耗时更值得长期跟踪的指标。

六、结论与适用边界

  • 419ns 是一个「最小管道」数字:它的前提是模板已缓存、值是 string 标量、Sink 为空、等级开启且无自定 Enricher/Filter。任何真实 Sink(控制台、文件、批量队列)都会把成本提升数个量级,不应将本数字推广为「Serilog 每事件真实成本」。
  • 数据具有环境时效性:报告生成于 Windows 8 + .NET Framework 4.5.2 + 2012 年移动端 CPU,反映的是历史运行时状态;现代 .NET 的管道实现(Span 内联属性数组、PropertiesInlineArray等,见 Logger.cs)已进一步减少分配,请在当代硬件与当前 SDK 上重新测量再作结论。
  • 管道源码是最终依据:以上所有调用链与优化点均可在 Logger.cs、MessageTemplateProcessor.cs、MessageTemplateCache.cs、PropertyBinder.cs 中逐行验证。

如果你正在评估 Serilog 在高频路径上的开销、或在排查自身应用「日志拖慢请求」的问题,建议按本文路径复现基准,并用LevelControl、MessageTemplateRendering、Allocations等相邻报告(均在 results 目录)定位瓶颈究竟在等级检查、渲染还是 Sink 输出,再针对性地优化。

  • 后端
  • 可观测性

【免费下载链接】serilog

Simple .NET logging with fully-structured events

项目地址:https://gitcode.com/gh_mirrors/se/serilog
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询