Asp.Net Core2.2 源码阅读系列——控制台日志源码解析
- 2019 年 10 月 3 日
- 筆記
为了让我们第一时间知道程序的运行状态,Asp.Net Core 添加了默认的日志输出服务。这看起来并没有什么问题,对于开发人员也相当友好,但如果不了解日志输出的细节,也有可能因为错误的日志级别配置导致性能问题,笔者的同事在一次进行性能测试的时候被输出日志误导,与其讨论分析了测试源码,排除业务代码因素,后来联想到应该是由于默认的日志输出导致(默认的日志级别 Microsoft 是 Inforamtion),随后将日志级别调高,性能立即飙升,问题解决。
虽然问题得到解决,但笔者脑中的对于到底为何日志输出会导致性能下降的疑问没有解决,一切查资料的方式,都不及先看源码来得直接,于是在github上拉取源码,经过详细的阅读分析,终于了解了技术细节,找到了高并发下,控制台日志输出导致性能低下的真正原因。
1.首先要弄清楚默认日志服务是如何添加的?
Asp.Net Core程序在启动时,IWebHostBuilder CreateDefaultBuilder(args) 方法中会为我们注册一些默认服务,这其中就包含默认的日志输出服务[GitHub源码地址]:
public static void Main(string[] args) { CreateWebHostBuilder(args).Build().Run(); } public static IWebHostBuilder CreateWebHostBuilder(string[] args) => WebHost.CreateDefaultBuilder(args) .UseStartup<Startup>(); //部分源码 public static IWebHostBuilder CreateDefaultBuilder(string[] args) { var builder = new WebHostBuilder(); ... builder.UseKestrel((builderContext, options) => { options.Configure(builderContext.Configuration.GetSection("Kestrel")); }) .ConfigureAppConfiguration((hostingContext, config) => { ... }) .ConfigureLogging((hostingContext, logging) => { logging.AddConfiguration(hostingContext.Configuration.GetSection("Logging")); logging.AddConsole(); //手动高亮 logging.AddDebug(); //手动高亮 logging.AddEventSourceLogger(); //手动高亮 }) .ConfigureServices((hostingContext, services) => { ... }) .UseIIS() .UseIISIntegration() .UseDefaultServiceProvider((context, options) => { options.ValidateScopes = context.HostingEnvironment.IsDevelopment(); }); return builder; }
PS:如果还想了解默认添加的其他服务详细细节,可以参看Hosting源码地址
2. 日志源码
目前 Asp.Net Core 已经将扩展插件统统挪到 [aspnet/Extensions] 仓库下,包含了所有 Asp.Net Core 所使用的扩展组件,如日志,配置等,如需查找 Microsoft.Extensions.* 命名空间下的源码,可以参考这个仓库。
打开目录 Extensions/src/Logging/ ,可以看到日志相关的组件均在这个文件夹下,这里简单说下主要包含的Project:
- 日志抽象层,主要负责Logger以及LoggerFactory接口定义和默认实现,为IOC提供扩展方法
- Microsoft.Extensions.Logging.Abstractions
- Microsoft.Extensions.Logging
- 日志配置
- Microsoft.Extensions.Logging.Configuration
- 日志具体实现
- Microsoft.Extensions.Logging.Console
- Microsoft.Extensions.Logging.Debug
- Microsoft.Extensions.Logging.EventLog
先来看下代码图:
上图可以看到,核心类主要有以下几个:
- ConsoleLoggerProvider 实现了ILoggerProvider接口,主要负责创建ConsoleLogger
- ConsoleLoggerSettings ConsoleLogger日志配置类
- ConsoleLogger 实现ILogger接口,日志输出最终的执行类
重要!篇幅原因,以下源码均做了精简,如有需要可以点击文件名连接直接查看github源文件。
先来看 ConsoleLoggerProvider.cs 源码:
public class ConsoleLoggerProvider : ILoggerProvider { private readonly ConcurrentDictionary<string, ConsoleLogger> _loggers = new ConcurrentDictionary<string, ConsoleLogger>();//手动高亮 private readonly Func<string, LogLevel, bool> _filter; private IConsoleLoggerSettings _settings; private readonly ConsoleLoggerProcessor _messageQueue = new ConsoleLoggerProcessor();//手动高亮 private static readonly Func<string, LogLevel, bool> falseFilter = (cat, level) => false; //通过IOptionMonitor<> 实现动态修改日志参数功能,比如日志级别 public ConsoleLoggerProvider(IOptionsMonitor<ConsoleLoggerOptions> options) { // Filter would be applied on LoggerFactory level _filter = trueFilter; _optionsReloadToken = options.OnChange(ReloadLoggerOptions); ReloadLoggerOptions(options.CurrentValue); } //3.0中将移除此构造函数 public ConsoleLoggerProvider(IConsoleLoggerSettings settings) { _settings = settings; if (_settings.ChangeToken != null) { _settings.ChangeToken.RegisterChangeCallback(OnConfigurationReload, null); } } //动态修改日志级别 private void ReloadLoggerOptions(ConsoleLoggerOptions options) { foreach (var logger in _loggers.Values) { logger.ScopeProvider = scopeProvider; } } //通过此方法动态修改日志级别 private void OnConfigurationReload(object state) { _settings = _settings.Reload(); foreach (var logger in _loggers.Values) { logger.Filter = GetFilter(logger.Name, _settings); } } //创建日志组件,注意,每个日志category name 创建一个日志实例, //所以可以根据不同的name设置不通的日志级别,达到细粒度控制 public ILogger CreateLogger(string name) { return _loggers.GetOrAdd(name, CreateLoggerImplementation); } private ConsoleLogger CreateLoggerImplementation(string name) { return new ConsoleLogger(name, GetFilter(name, _settings), null, _messageQueue) { }; } private Func<string, LogLevel, bool> GetFilter(string name, IConsoleLoggerSettings settings) { if (settings != null) { foreach (var prefix in GetKeyPrefixes(name)) { LogLevel level; if (settings.TryGetSwitch(prefix, out level)) { return (n, l) => l >= level; } } } return falseFilter; } //日志级别匹配方式,比如name为 "A.B.C",则依次匹配 "A.B.C","A.B", "A" private IEnumerable<string> GetKeyPrefixes(string name) { while (!string.IsNullOrEmpty(name)) { yield return name; var lastIndexOfDot = name.LastIndexOf('.'); if (lastIndexOfDot == -1) { yield return "Default"; break; } name = name.Substring(0, lastIndexOfDot); } } }
可以看见,ConsoleLoggerProvider 持有一个线程安全的字典_loggers,用以保证每个category name(也就是业务代码中构造函数中的 ILogger<T> 中的 nameof(T))有且仅有一个ILogger 实例,之所以这么做,是为了可以更加细粒度控制每个logger的日志输出细节,比如log level。同时,可以通过 IOperationMonitor<> 实现动态日志细节配置控制。
另外还有一个名为 _messageQueue 的实例在 ConsoleLogger 构造时传进去,从名字看来似乎对日志输出做了排队处理,我们稍后再看。
再来看 ConsoleLogger.cs 源码:
public class ConsoleLogger : ILogger { private readonly ConsoleLoggerProcessor _queueProcessor; private Func<string, LogLevel, bool> _filter; [ThreadStatic]//手动高亮 private static StringBuilder _logBuilder; static ConsoleLogger() { var logLevelString = GetLogLevelString(LogLevel.Information); } internal ConsoleLogger(string name, Func<string, LogLevel, bool> filter, IExternalScopeProvider scopeProvider, ConsoleLoggerProcessor loggerProcessor) { Name = name; Filter = filter ?? ((category, logLevel) => true); _queueProcessor = loggerProcessor; } public string Name { get; } //日志写入接口实现 public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter) { if (!IsEnabled(logLevel)) return; var message = formatter(state, exception); if (!string.IsNullOrEmpty(message) || exception != null) { WriteMessage(logLevel, Name, eventId.Id, message, exception); } } // 日志通过stringbuilder进行装配 public virtual void WriteMessage(LogLevel logLevel, string logName, int eventId, string message, Exception exception) { var logBuilder = _logBuilder; _logBuilder = null; if (logBuilder == null) { logBuilder = new StringBuilder(); } var logLevelString = GetLogLevelString(logLevel); // category and event id logBuilder.Append(_loglevelPadding); logBuilder.Append(logName); logBuilder.Append("["); logBuilder.Append(eventId); logBuilder.AppendLine("]"); if (!string.IsNullOrEmpty(message)) { // message logBuilder.Append(_messagePadding); var len = logBuilder.Length; logBuilder.AppendLine(message); logBuilder.Replace(Environment.NewLine, _newLineWithMessagePadding, len, message.Length); } if (exception != null) { logBuilder.AppendLine(exception.ToString()); } var hasLevel = !string.IsNullOrEmpty(logLevelString); // Queue log message _queueProcessor.EnqueueMessage(new LogMessageEntry() //装配完成日志入队 { Message = logBuilder.ToString(), MessageColor = DefaultConsoleColor, LevelString = hasLevel ? logLevelString : null, }); logBuilder.Clear(); if (logBuilder.Capacity > 1024) { logBuilder.Capacity = 1024; } _logBuilder = logBuilder; } public bool IsEnabled(LogLevel logLevel) { if (logLevel == LogLevel.None) { return false; } return Filter(Name, logLevel); } //日志最终记录字段和LogLevel中的枚举名称通过此方法映射 private static string GetLogLevelString(LogLevel logLevel) { switch (logLevel) { case LogLevel.Trace: return "trce"; case LogLevel.Debug: return "dbug"; case LogLevel.Information: return "info"; case LogLevel.Warning: return "warn"; case LogLevel.Error: return "fail"; case LogLevel.Critical: return "crit"; default: throw new ArgumentOutOfRangeException(nameof(logLevel)); } } }
此类是对ILogger接口的简单实现,可以看出,在调用Log
值得注意的是,笔者看到WriteMessage()方法中的 _logBuilder.Append() 日志内容时,没加任何锁,立即怀疑这不是会有线程安全问题么?然后抬头一看,_logBuilder的字段定义上加了 [ThreadStatic] 标签,相比于对这个方法加锁,对这个字段设置为线程静态字段才是完美的方案,不得不感叹微软程序员的严谨性!
最后看下 ConsoleLoggerProcessor.cs,寻找最终答案:
public class ConsoleLoggerProcessor : IDisposable { private const int _maxQueuedMessages = 1024; private readonly BlockingCollection<LogMessageEntry> _messageQueue = new BlockingCollection<LogMessageEntry>(_maxQueuedMessages); private readonly Thread _outputThread; public IConsole Console; public ConsoleLoggerProcessor() { // 开启消费阻塞队列线程 _outputThread = new Thread(ProcessLogQueue) { IsBackground = true, Name = "Console logger queue processing thread" }; _outputThread.Start(); } public virtual void EnqueueMessage(LogMessageEntry message) { if (!_messageQueue.IsAddingCompleted) { try { //入队操作 _messageQueue.Add(message); return; } catch (InvalidOperationException) { } } } //消费队列 private void ProcessLogQueue() { try { foreach (var message in _messageQueue.GetConsumingEnumerable()) { WriteMessage(message); } } catch { try { _messageQueue.CompleteAdding(); } catch { } } } }
以上代码解释了为何在并发情况下,控制台日志输出会导致性能降低的原因:
该类中有一个BlockingCollection<> 阻塞队列,最大长度1024,用于实现日志输出的生产消费模型,再看 EnqueueMessage()方法,如果阻塞队列中已经达到1024条日志,则所有生产者将被阻塞。也就是说:一旦日志生产速度远远大于队列消费速度,生产者将会集中等待队列消费后才能竞争入队后返回,引发了性能瓶颈
到此,终于弄清楚之前的性能测试为何会受日志控制台输出的影响,对底层代码的分析,会加深对此类问题的理解,不但对之后排查类似问题有帮助,也让我们对生产消费模型场景有了更深的理解。
后记
笔者此次对日志相关源码还做了更多深入的阅读,同时依照 Microsoft.Extesions.Logging 中的接口实现了自定义日志组件,用于在生产中,从底层对很多信息进行获取和记录,比如traceid,在这个翻阅的过程中,感受到通过阅读源码,可以更加直接的理解 Asp.Net Core 相关的设计思想,以及代码实现,对于理解整体框架有极大的帮助,笔者后续也会继续阅读其他相关源码。对于目前在使用.Net Core 的同学,希望你同我一样,对了解事务的本质保持好奇心,持之以恒!