前言
在上一篇文章中提到高并发的内容,感兴趣的朋友可以阅读C# 工控环境下多线程的高并发处理(附源码)
一、为什么不用现成的日志框架?
提到 .NET 日志组件,大家首先想到的是:
-
Log4Net
-
Enterprise Library Logging
-
ServiceStack.Logging
组件功能强大,但都有一个共同痛点:必须在 app.config 或 web.config 中配置大量 XML,甚至 Log4Net 还要把 SQL 写进配置文件。
对于"作者只是想简单写个日志"的场景,这种重量级配置显得过于繁琐。
因此,作者曾长期使用一个极简方法——仅用 5~6 行核心代码实现基础日志写入,自带异常容错,轻量高效。
File.AppendAllText(logPath, $"{DateTime.Now} {message}\n");
但它在多线程并发写入时会崩溃。
二、多线程下的日志冲突问题
当 100 个线程同时调用日志写入,文件被多个线程争抢,极易引发 IOException 或 文件被占用异常。
常见解决方案(但有瓶颈):
使用
lock或Monitor.Enter/Exit加锁虽然线程安全,但严重阻塞性能,所有日志必须串行写入。
这不是我们想要的"高性能"。
三、重新设计 异步队列 + 批量持久化
设计思路
所有线程只负责"投递"日志到线程安全队列;
单个后台任务从队列中批量读取并写入文件;
自动分块:当日志文件超过 1MB(可配置),自动创建新分卷(如 app(0).log, app(1).log...)。
核心思想:生产者-消费者模型 + 异步批处理
四、三种实现方案对比
方案一:Task + 状态判断(存在竞态风险)
-
动态创建 Task,判断
IsCompleted后重启。 -
问题:高并发下易出现
Task状态不一致,抛出异常(如"任务已启动")。 -
不推荐用于生产环境。
方案二:长运行 Task + 信号量触发(推荐)
-
启动一个
LongRunning的后台 Task; -
使用
ManualResetEvent控制写入时机; -
每次有新日志就
Set()信号,唤醒写入线程; -
写完后
Reset(),避免空转。
优点
-
无锁、低延迟、高吞吐;
-
避免频繁创建/销毁 Task;
-
线程安全由
ConcurrentQueue保障。
static ManualResetEvent pause = new ManualResetEvent(false);
// 后台任务
writeTask = Task.Factory.StartNew(() => {
while (true) {
pause.WaitOne(); // 等待日志到来
pause.Reset();
BatchWriteLogs(); // 批量写入
}
}, TaskCreationOptions.LongRunning);
方案三:定时器轮询(简单但低效)
-
使用
System.Timers.Timer每秒检查队列; -
实现简单,但存在 延迟 和 空轮询开销;
-
需手动控制
Enabled = false/true防止重入。
适合低频日志场景,不推荐高并发系统。
五、关键代码
1、线程安全队列
static ConcurrentQueue<Tuple<string, string>> logQueue =
new ConcurrentQueue<Tuple<string, string>>();
无需手动加锁,天然支持多线程 Enqueue/Dequeue。
2、智能日志分卷
-
按日期生成文件名:
MyApp20251111(0).log -
单文件超 1MB 自动新建
(1).log、(2).log... -
使用正则提取序号,确保顺序正确。
3、批量合并写入
-
将同一路径的日志合并为一次 I/O 操作;
-
减少磁盘写入次数,提升性能。
完整推荐实现(信号量改进版)
namespace LogTest
{
publicclassIOExtention
{
static ConcurrentQueue<Tuple<string, string>> logQueue = new ConcurrentQueue<Tuple<string, string>>();
static Task writeTask = default(Task);
static ManualResetEvent pause = new ManualResetEvent(false);
//Mutex mmm = new Mutex();
static IOExtention()
{
writeTask = new Task((object obj) =>
{
while (true)
{
pause.WaitOne();
pause.Reset();
List<string[]> temp = new List<string[]>();
foreach (var logItem in logQueue)
{
string logPath = logItem.Item1;
string logMergeContent = String.Concat(logItem.Item2, Environment.NewLine, "-----------------------------------------------------------", Environment.NewLine);
string[] logArr = temp.FirstOrDefault(d => d[0].Equals(logPath));
if (logArr != null)
{
logArr[1] = string.Concat(logArr[1], logMergeContent);
}
else
{
logArr = newstring[] { logPath, logMergeContent };
temp.Add(logArr);
}
Tuple<string, string> val = default(Tuple<string, string>);
logQueue.TryDequeue(out val);
}
foreach (string[] item in temp)
{
WriteText(item[0], item[1]);
}
}
}
, null
, TaskCreationOptions.LongRunning);
writeTask.Start();
}
public static void WriteLog(String preFile, String infoData)
{
WriteLog(string.Empty, preFile, infoData);
}
public static void WriteLog(String customDirectory, String preFile, String infoData)
{
string logPath = GetLogPath(customDirectory, preFile);
string logContent = String.Concat(DateTime.Now, " ", infoData);
logQueue.Enqueue(new Tuple<string, string>(logPath, logContent));
pause.Set();
}
private static string GetLogPath(String customDirectory, String preFile)
{
string newFilePath = string.Empty;
String logDir = string.IsNullOrEmpty(customDirectory) ? Path.Combine(Environment.CurrentDirectory, "logs") : customDirectory;
if (!Directory.Exists(logDir))
{
Directory.CreateDirectory(logDir);
}
string extension = ".log";
string fileNameNotExt = String.Concat(preFile, DateTime.Now.ToString("yyyyMMdd"));
String fileName = String.Concat(fileNameNotExt, extension);
string fileNamePattern = string.Concat(fileNameNotExt, "(*)", extension);
List<string> filePaths = Directory.GetFiles(logDir, fileNamePattern, SearchOption.TopDirectoryOnly).ToList();
if (filePaths.Count > 0)
{
int fileMaxLen = filePaths.Max(d => d.Length);
string lastFilePath = filePaths.Where(d => d.Length == fileMaxLen).OrderByDescending(d => d).FirstOrDefault();
if (new FileInfo(lastFilePath).Length > 1 * 1024 * 1024 * 1024)
{
string no = new Regex(@"(?is)(?<=\()(.*)(?=\))").Match(Path.GetFileName(lastFilePath)).Value;
int tempno = 0;
bool parse = int.TryParse(no, out tempno);
string formatno = String.Format("({0})", parse ? (tempno + 1) : tempno);
string newFileName = String.Concat(fileNameNotExt, formatno, extension);
newFilePath = Path.Combine(logDir, newFileName);
}
else
{
newFilePath = lastFilePath;
}
}
else
{
string newFileName = String.Concat(fileNameNotExt, String.Format("({0})", 0), extension);
newFilePath = Path.Combine(logDir, newFileName);
}
return newFilePath;
}
private static void WriteText(string logPath, string logContent)
{
try
{
if (!File.Exists(logPath))
{
File.CreateText(logPath).Close();
}
StreamWriter sw = File.AppendText(logPath);
sw.Write(logContent);
sw.Close();
}
catch (Exception ex)
{
}
finally
{
}
}
}
}
总结
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 信号量 + 长任务 |
|
|
|
|
|
|
|
|
最终建议:对于需要轻量、无配置、高性能的日志场景,自研基于 ConcurrentQueue + 后台批处理 的组件是不错的选择。
思考
-
是否可加入内存缓冲上限防止 OOM?
-
是否支持日志级别(Info/Debug/Error)?
-
是否可对接远程日志服务(如 ELK)?
欢迎在评论区留言交流!
关键词
#日志组件、#多线程日志、#ConcurrentQueue、#批量写入、#文件分块、.#高性能日志、#无配置日志、#Task异步日志、#AutoResetEvent、#ManualResetEvent、System.Timers.Timer、#多线程日志组件、#文件分块写入、#线程安全队列、#工业自动化、#批量日志处理、#断点续传、#信号量同步
作者:Sam Xiao

基于 .NET + Vue 3 的线路图绘制系统实战(含源码)
WinForm 下基于策略与工厂模式的 PLC 数据采集与监控系统
.NET 8 + Avalonia 跨平台简易校园信息管理系统的开发实战
C# + WPF + SuperSocket 开发面向工业自动化的 MES 系统
告别服务宕机,C# 看门狗守护你的 WinForm 与 Windows 服务
.NET 一款高效跨平台的自动更新工具(差异更新+热修复+自动升级)
面向工厂自动化的智能语音播报方案(基于.NET Windows服务)
工业自动化UI太难做?WPF 这套工业级控件方案真香(附源码)
工业自动化 WPF + Halcon 的模块化机器视觉解决方案
开源福利!八款 WPF + HandyControl 工业管理系统源码全公开
WinForm + Win32 API 自定义无边框窗口实战(工业软件必备)
基于 HslCommunication 的多端同步PLC远程监控系统
WinForm 数据采集实战:从串口通信到MES对接的轻量化解决方案
一个拒绝过度设计的 .NET 快速开发框架:开箱即用,专注"干活"
WinForm + SunnyUI 与 MQTTnet 实现智能可视化的火警联动大屏系统
.NET 9 + WPF + Halcon 构建工业视觉流程框架:从架构设计到落地实践
WinForm 高分屏适配难题?一款强大的控件自适应缩放工具
觉得有收获?不妨分享让更多人受益
关注「DotNet技术匠」,共同提升技术实力

