尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C++与C#日志函数实现详解:从多线程安全到异步队列

C++与C#日志函数实现详解:从多线程安全到异步队列 写日志这个需求看着简单但真要在C和C#里各写一个趁手的日志函数坑还是不少的。特别是刚在两个语言栈之间切换的朋友很容易写出“能跑但不敢用”的代码——要么多线程下日志乱成一团要么程序崩溃时日志还躺在缓冲区里没落盘要么换了个框架才发现文件路径处理逻辑全变了。这篇文章我就把两种语言下的日志函数实现都拆开来讲从最简单的能写到进阶的防崩、防乱、防性能损耗每一步都会解释为什么这么做附上我实际踩过的坑和验证过的写法。1. 写日志函数前先想清楚你到底要什么1.1 核心需求解析一个日志函数要解决什么问题日志函数本质上做三件事把时间、级别、消息内容格式化写入目标输出端并且在出现异常时不影响主流程。听起来简单但一旦加上“多线程安全”“文件轮转”“性能可控”这些真实世界的约束事情就变复杂了。很多刚从学校出来或者从脚本语言转过来的朋友第一版日志代码往往长这样void Log(string msg) { ofstream f(log.txt, ios::app); f msg endl; }这个写法在小工具里完全没问题但如果放到服务端程序里马上会遇到三个痛点一是每次写日志都打开关闭文件磁盘IO开销大二是多线程同时写时会互相覆盖三是没有日志级别排查问题时没办法只过滤出错误信息。C#里也有类似的初始版本用File.AppendAllText看似方便但同样逃不掉频繁打开文件的性能损耗。所以这篇文章里我给的方案分两档入门档单线程、低频写入代码简单直观适合新手学习、小工具、课程设计。进阶档多线程安全、带日志级别、带性能考量适合实际项目、上位机软件、服务端程序。两档都会给出完整可跑的代码和逐行解释。1.2 两种语言场景差异分析C和C#在日志函数上最大的差异来自运行机制C是原生编译语言直接操作系统API没有垃圾回收也没有统一的字符串编码标准。这意味着文件操作要用标准库或者系统API中文编码问题要自己处理多线程同步要用std::mutex或Windows临界区。C#跑在.NET运行时上有完整的类库支持System.IO、System.Threading都很成熟写起来省力很多。但要注意.NET Framework和.NET Core/5在部分API行为上有细微差异比如路径拼接、文件共享方式。另一个容易被忽略的差异是使用场景。我接触过的项目里C日志函数常见于嵌入式工具、插件DLL、游戏服务端、算法模块C#日志函数常见于上位机软件、桌面客户端、Windows服务。C环境往往要求依赖最少、性能最高C#环境通常可以大方地用Null安全、LINQ这些现代语法开发效率优先。这个差异直接决定了我在后面选用的实现方案。2. C版本从零手写一个日志函数2.1 核心设计思路宏、可变参数和线程安全C写日志函数业界最常见的做法是用宏包一层函数调用核心原因有两个。第一C没有办法像Python那样直接拿到调用者的函数名和行号但预处理器宏可以利用__FILE__、__LINE__、__FUNCTION__这三个预定义宏能实现“自动记录日志来源”的效果。第二宏可以吃掉可变参数这样日志函数的调用方式可以跟printf一样灵活。我见过有些人为了优雅用模板和std::source_locationC20来实现但说句实在话在很多生产环境里标准还停留在C11/14/17宏反而是兼容性最好、最不容易出问题的方案。这篇我按C11标准写绝大多数老项目都能直接用。线程安全方面日志写入必须加锁。这里有个思维误区很多人只给文件写入加锁但格式化字符串和获取时间戳其实是先于写入的如果这些操作不加锁多个线程同时调用时时间戳和消息内容可能错位。我的做法是整个格式化写入过程放在同一个锁作用域里。至于性能临界区确实会串行化但日志相对于业务逻辑本来就是低频操作这个代价完全可以接受。2.2 完整代码实现支持级别、时间戳、线程ID和崩溃安全写入先给出我平时项目里用的一个通用版本这个版本支持日志级别、时间戳、线程ID、文件名行号并且做了崩溃安全处理。// log.h #ifndef LOG_H #define LOG_H #include cstdio #include cstdarg #include ctime #include chrono #include thread #include mutex #include string // 日志级别 enum LogLevel { LOG_DEBUG 0, LOG_INFO, LOG_WARN, LOG_ERROR, LOG_FATAL }; // 内部实现类 class LoggerImpl { public: static LoggerImpl instance() { static LoggerImpl inst; return inst; } void setLevel(LogLevel level) { m_level level; } void setOutput(FILE* fp) { std::lock_guardstd::mutex lock(m_mutex); if (m_fp m_fp ! stdout m_fp ! stderr) { fclose(m_fp); } m_fp fp ? fp : stdout; } void log(LogLevel level, const char* file, int line, const char* func, const char* fmt, ...) { if (level m_level) return; std::lock_guardstd::mutex lock(m_mutex); // 时间戳 auto now std::chrono::system_clock::now(); auto tt std::chrono::system_clock::to_time_t(now); auto ms std::chrono::duration_caststd::chrono::milliseconds(now.time_since_epoch()) % 1000; std::tm tm_buf; #ifdef _WIN32 localtime_s(tm_buf, tt); #else localtime_r(tt, tm_buf); #endif char time_buf[32]; snprintf(time_buf, sizeof(time_buf), %04d-%02d-%02d %02d:%02d:%02d.%03d, tm_buf.tm_year 1900, tm_buf.tm_mon 1, tm_buf.tm_mday, tm_buf.tm_hour, tm_buf.tm_min, tm_buf.tm_sec, (int)ms.count()); // 线程ID std::ostringstream oss; oss std::this_thread::get_id(); std::string thread_id oss.str(); // 级别字符串 static const char* level_str[] { DEBUG, INFO, WARN, ERROR, FATAL }; // 拼接头部 fprintf(m_fp, [%s][%s][TID:%s][%s:%d][%s] , time_buf, level_str[level], thread_id.c_str(), file, line, func); // 正文可变参数 va_list args; va_start(args, fmt); vfprintf(m_fp, fmt, args); va_end(args); fprintf(m_fp, \n); fflush(m_fp); // 立即落盘崩溃不丢日志 } private: LoggerImpl() : m_level(LOG_DEBUG), m_fp(stdout) {} ~LoggerImpl() { if (m_fp m_fp ! stdout m_fp ! stderr) { fclose(m_fp); } } LoggerImpl(const LoggerImpl) delete; LoggerImpl operator(const LoggerImpl) delete; LogLevel m_level; FILE* m_fp; std::mutex m_mutex; }; // 宏定义自动填入文件名、行号、函数名 #define LOG_DEBUG(...) LoggerImpl::instance().log(LOG_DEBUG, __FILE__, __LINE__, __FUNCTION__, __VA_ARGS__) #define LOG_INFO(...) LoggerImpl::instance().log(LOG_INFO, __FILE__, __LINE__, __FUNCTION__, __VA_ARGS__) #define LOG_WARN(...) LoggerImpl::instance().log(LOG_WARN, __FILE__, __LINE__, __FUNCTION__, __VA_ARGS__) #define LOG_ERROR(...) LoggerImpl::instance().log(LOG_ERROR, __FILE__, __LINE__, __FUNCTION__, __VA_ARGS__) #define LOG_FATAL(...) LoggerImpl::instance().log(LOG_FATAL, __FILE__, __LINE__, __FUNCTION__, __VA_ARGS__) #endif // LOG_H2.3 关键细节解释为什么加锁、为什么fflush、编码陷阱上面这个实现里有几个关键点逐个说一下这些都是会直接影响程序稳定性或者排查效率的细节。第一std::mutex配合std::lock_guard保护整个写入过程。如果只保护fprintf两个线程同时进来各自拼接时间戳和线程ID打印出来的日志会出现类似[线程A的时间戳][线程B的消息]这种错乱。把锁的范围扩大到“取时间戳格式化写入”全流程日志顺序才严格一致。实测高并发写入时临界区会让写日志这个操作耗时从微秒级涨到几十微秒级但这是保证正确性必须付出的代价。第二fflush(m_fp)强制刷新缓冲区。这是“崩溃不丢日志”的关键。默认情况下fprintf写入的是C标准IO缓冲区如果程序直接崩溃或者被TerminateProcess缓冲区里的日志会丢。加上fflush后每行日志都立即落盘代价是写入速度下降但换来了可靠性。如果是高性能日志库会做成批量刷新但对于日志函数这个级别我的建议是宁可慢一点也要保证每条都落盘。第三中文编码问题。这是C日志最常见的坑。上面代码里fprintf输出窄字符如果你的程序是Windows下的Unicode程序字符串是wchar_t*直接fprintf会变成乱码或者问号。我惯用的处理方式有两种一种是项目里所有内部日志都用std::string从wstring转换时统一走WideCharToMultiByte转成UTF-8另一种是直接用fwprintf配合%ls格式符。个人建议统一用UTF-8存日志文件因为后续用日志分析工具、在Git上做diff都会更友好。转换函数网上有很多不展开但务必明确一点C日志函数必须明确约定编码格式并在团队里统一否则日志分析脚本一定会跑挂。2.4 简易版快速参考当项目只需要几十行日志时如果只是临时验证一个算法、给课程设计加点输出引入上面的完整实现反而显得重。这时候一个极简版就够用#include cstdio #include ctime #define LOG(fmt, ...) do { \ time_t t time(nullptr); \ char buf[32]; \ strftime(buf, sizeof(buf), %H:%M:%S, localtime(t)); \ printf([%s][%s:%d] fmt \n, buf, __FILE__, __LINE__, ##__VA_ARGS__); \ } while(0)使用方式int main() { LOG(hello %s, count%d, world, 42); return 0; }注意几点##__VA_ARGS__是GCC和MSVC都支持的扩展写法允许可变参数为空时不报错。如果只用MSVC可以不加##直接__VA_ARGS__。用do { ... } while(0)包裹宏是为了让宏在if后面使用时不出现悬空else的经典问题。这个版本没有加锁单线程调试完全够用如果真要放到多线程里临时用可以把printf换成fprintf(stderr, ...)并在外面包一层临界区但不建议还是用完整版更省心。3. C#版本利用语言优势写出更省心的日志函数3.1 与C的设计差异为什么C#版本可以做得更轻松C#这个语言的特性决定了日志函数可以设计得更省心。首先强大的字符串插值和$语法让格式化不再依赖可变参数其次System.IO.File、StreamWriter、lock关键字这些基础设施非常完善再加上.NET的垃圾回收帮你管理文件句柄等资源代码自然短一截。但省心不代表没坑。我实际在项目里遇到的C#日志相关的问题集中在两点文件占用。用StreamWriter写日志时如果没处理好Dispose文件会一直被占用导致其他程序无法读取或删除。性能。如果高频写入比如每秒几百条还同步逐条写盘磁盘IO会成为瓶颈。所以C#版本的思路是面向简单场景时用静态类锁StreamWriter面向稍复杂场景时把写入操作丢到后台线程甚至直接用BlockingCollection生产消费队列。3.2 基础版本实现静态类、锁与StreamWriter先给一个最实用的基础版本适合绝大多数桌面应用、上位机软件。using System; using System.IO; using System.Text; using System.Threading; public static class Logger { private static readonly object _lock new object(); private static StreamWriter _writer; private static string _logDir logs; static Logger() { Initialize(); } private static void Initialize() { try { Directory.CreateDirectory(_logDir); string filePath Path.Combine(_logDir, $log_{DateTime.Now:yyyyMMdd}.txt); _writer new StreamWriter(filePath, append: true, encoding: Encoding.UTF8) { AutoFlush true }; } catch (Exception ex) { // 日志系统自身初始化失败fallback到控制台避免程序直接崩 Console.WriteLine($Logger init failed: {ex.Message}); } } public static void Debug(string msg) Write(DEBUG, msg); public static void Info(string msg) Write(INFO, msg); public static void Warn(string msg) Write(WARN, msg); public static void Error(string msg) Write(ERROR, msg); public static void Error(string msg, Exception ex) Write(ERROR, ${msg} | Exception: {ex}); private static void Write(string level, string msg) { lock (_lock) { try { string line $[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}][{level}][TID:{Thread.CurrentThread.ManagedThreadId}] {msg}; _writer?.WriteLine(line); } catch (Exception ex) { // 写入出错不能影响主流程 Console.WriteLine($Logger write failed: {ex.Message}); } } } // 提供一个轮转机制按天换文件 public static void ReopenIfDayChanged() { string expectedPath Path.Combine(_logDir, $log_{DateTime.Now:yyyyMMdd}.txt); lock (_lock) { if (_writer ! null ((FileStream)_writer.BaseStream).Name ! expectedPath) { _writer.Dispose(); _writer new StreamWriter(expectedPath, append: true, encoding: Encoding.UTF8) { AutoFlush true }; } } } }调用示例Logger.Info(用户 {0} 执行了操作耗时 {1}ms, userId, elapsedMs); // 如果想要格式化占位符可以自行扩展这个版本有几个设计取舍AutoFlush true对应C版的fflush保证日志即时落盘。代价是性能但胜在可靠。用static readonly object _lock而不是lock(this)或者lock(typeof(Logger))前者是规范做法避免外部代码意外锁住当前对象造成死锁。日志初始化放在静态构造函数里保证第一次调用任何一个日志方法时_writer一定已经就绪。如果初始化失败_writer为null后续Write里的空条件运算符?.会跳过写入程序不会崩。3.3 进阶版本异步日志队列应用高频写入时同步写文件会明显拖慢业务线程。进阶版本的做法是业务线程只负责把日志消息丢给队列由后台线程批量写入文件。这样日志操作的耗时从“磁盘IO”变成“内存队列Enqueue”性能提升非常显著。基础实现思路using System; using System.Collections.Concurrent; using System.IO; using System.Text; using System.Threading; public sealed class AsyncLogger : IDisposable { private readonly BlockingCollectionstring _queue new BlockingCollectionstring(boundedCapacity: 1024); private readonly Thread _workerThread; private readonly StreamWriter _writer; private bool _disposed false; public AsyncLogger(string filePath) { Directory.CreateDirectory(Path.GetDirectoryName(filePath)); _writer new StreamWriter(filePath, append: true, encoding: Encoding.UTF8) { AutoFlush true }; _workerThread new Thread(ProcessQueue) { IsBackground true }; _workerThread.Start(); } public void Log(string level, string msg) { string line $[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}][{level}][TID:{Thread.CurrentThread.ManagedThreadId}] {msg}; try { _queue.Add(line); } catch (InvalidOperationException) { // 队列已关闭丢弃这条日志或者 fallback 到同步写 } } private void ProcessQueue() { foreach (var line in _queue.GetConsumingEnumerable()) { _writer.WriteLine(line); } } public void Dispose() { if (_disposed) return; _disposed true; _queue.CompleteAdding(); _workerThread.Join(TimeSpan.FromSeconds(2)); _writer.Dispose(); } }使用BlockingCollectionstring的好处是天生线程安全支持生产消费模式还有boundedCapacity做背压。当队列满了以后_queue.Add会阻塞防止内存无限制增长导致OOM。后台线程用foreachGetConsumingEnumerable消费队列关闭后线程自动退出。但是这里必须说清楚异步日志有丢日志的风险。如果程序是正常退出调用Dispose会把队列里的日志全部写完再关闭可如果是崩溃或者被强制结束BlockingCollection里积压的未写日志会全丢。所以异步日志适用于“日志量大、丢失可容忍、性能优先”的场景如果是“必须每条都写下来”的场合还是用同步版本更稳。这个取舍大家在项目里要根据实际情况判断。3.4 日期的处理与文件轮转日志文件按天拆分是实际项目里最普遍的需求。C#里处理起来不难但有几个细节值得注意。一是文件名里的日期要在打开文件时固定。如果写成log_{DateTime.Now:yyyyMMdd}.txt然后在写入时每次都去拼路径凌晨跨天那一刻会出现“半天写在昨天的文件半天写在今天的文件”的情况。稳妥的做法是在Logger内部缓存当前文件路径写入时检查DateTime.Now.Date ! _currentDate一旦发现跨天就关掉旧文件、打开新文件。上面基础版本里我给的ReopenIfDayChanged就是干这个的调用方可以每个小时、每分钟或者在某个业务节点主动调一次。二是文件命名最好带上具体时间到分钟避免同一天内重复打开同一个文件造成文件锁冲突。如果确有多个进程同时写日志比如两个上位机实例跑在同一台机器上文件名的命名规范就要加上进程ID或者启动时间戳否则日志会互相覆盖。4. 两版对比与选型建议什么时候用哪个4.1 完整对比表维度C 版本C# 基础版C# 异步版依赖仅标准库.NET Framework / .NET 6同左线程安全mutex 全流程加锁lock 全流程加锁BlockingCollection 无锁 Enqueue日志丢失风险低fflush 即时落盘低AutoFlush 即时落盘中队列积压时进程崩溃会丢写入耗时微秒级~几十微秒微秒级~几十微秒微秒级Enqueue不阻塞编码需手动统一 UTF-8/UCS-2Encoding.UTF8 默认同左跨平台代码兼容 Windows/Linux需条件编译框架决定.NET Core 3.1 全平台同左适用场景性能敏感、嵌入系统、无托管环境桌面软件、上位机、中小型服务端高频日志、高吞吐服务4.2 我的选型经验我个人在实际项目里的经验可以总结成几句话如果项目是C写的不允许引入第三方库又需要多线程下日志不乱直接用2.2的完整版宏方案。这套写法我验证过Windows和Linux两个平台兼容性没有问题跨平台的差异只有时间函数localtime_s和localtime_r和将来%zu等格式符的细节整体不用大改。如果项目允许引入第三方那直接用spdlogC或者三方库也可以但那时候就不需要“自己写一个日志函数”了。如果项目是C#写的先默认用3.2的基础静态类版本。这个版本足够稳定代码量少随时可以看懂、修改、扩展。只有当性能测试证明写入日志成了瓶颈才去升级成异步队列版本。过早优化是常见的坑很多上位机项目其实一天日志量还不到10MB同步写根本没压力。如果项目需要做文件轮转还得注意Windows文件锁的问题。C#的基础版用StreamWriter打开文件后文件默认是以无共享模式打开的其他进程比如你开个记事本去看日志会读不了这种问题在Debug的时候特别烦人。可以在构造FileStream时指定FileShare.ReadWrite允许其他进程同时读取这个细节在C版本里其实也一样因为Windows上fopen默认就限制了共享模式。5. 实操常见问题与排查技巧实录下面这些问题都是我这边团队实际踩过的有些问题比较隐蔽单独列出排查思路。5.1 已排查清单速查表现象可能原因解决方案C日志中文全部是问号宽字符直接传给fprintf统一转UTF-8或使用fwprintfC日志跨线程串行写入时时间戳错乱锁的范围不够时间戳在锁外取整个格式化写入放同一锁作用域C#日志文件被误认为被占用StreamWriter默认独占文件在FileStream上指定FileShare.ReadWriteC#异步日志丢最后几条进程结束时未Dispose队列程序退出前调用Dispose并Join工作线程日志写入导致业务卡顿同步写文件且日志量大升级异步队列或引入批量缓冲按天轮转日志时跨天文件内容错乱文件名里的日期在写入时才拼接在Logger内缓存当前文件路径跨天检测日志函数抛异常导致程序退出文件路径不存在、权限不足写入代码包try/catch且catch里只输出控制台多个进程写同一个日志文件互相覆盖文件名冲突文件名带进程ID/时间戳/线程ID日志文件无限增长撑满磁盘没有轮转/清理机制按大小或天数轮转结合清理策略5.2 藏得比较深的坑语言级差异导致的意外行为C的__FILE__路径在不同平台下表现不同。Windows下__FILE__展开为绝对路径比如D:\MyProject\src\log.cppLinux下可能只是文件名也可能带上构建目录的相对路径。如果日志要发给外部团队分析这个不一致会影响脚本解析。建议构建时统一传入相对于项目根目录的路径比如CMake里用-D__FILENAME__替换或者干脆在宏里手动只保留文件名部分。C#的Exception.ToString()很可能包含换行。如果日志消息里直接拼接异常会把一行日志撑成好几行后续基于行的日志分析脚本就会错乱。我的习惯是先把异常消息里的\r、\n替换成空格再写入日志。这个方法虽然简单但在实际排查问题的时候能省大量花在日志清洗上的时间。C的std::thread::get_id()返回的不是PID也不是一个整数。如果日志里要记录线程IDstd::ostringstream std::this_thread::get_id()的结果在Windows上往往是16进制对象指针在Linux上是十进制的整数值。两套系统下日志格式不一致而且看着像是在记录地址不直观。更实用的做法是在Windows上用GetCurrentThreadId()返回DWORD在Linux上用syscall(SYS_gettid)这样日志里出现的是人们熟悉的、类似37652这样的数字。5.3 排查方法论日志本身出了问题怎么定位日志系统本身又出问题的时候是最痛苦的因为排查手段被自己断了。分享一套我常用的排查路径第一步先确认日志是否写入了预期的文件路径。权限问题最常见特别是Windows上UAC提权后程序的工作目录变了相对路径可能就失效了。代码里尽量用Path.GetFullPath或者显式配置绝对路径。第二步确认日志级别过滤是否把消息吞掉了。很多人排查半天日志没有输出最后发现是日志级别设成了ERRORDEBUG和INFO的消息全被过滤了。第三步确认没有多重路径写入冲突。假设一个项目里既有自己写的日志函数又引入了第三方库第三方库可能自己也会写日志两边写同一个文件会互相穿插。日志乱序有时候不是线程安全的问题而是两套写入路径在打架。第四步检查进程是否还在运行。异步日志会存在“日志还堆积在队列中但业务线程已经崩了”的情况这其实不是日志函数写错了而是消费线程还没完成落盘。所以退出前一定要给消费线程留足处理时间。6. 日志函数之外性能优化与扩展方向日志函数写完之后实际项目中往往还需要往两个方向扩展性能优化和功能增强。性能优化方面除了异步队列还可以做日志级别编译器裁剪。C里可以在编译期通过宏判断如果当前编译版本禁止输出DEBUG日志预处理器直接把LOG_DEBUG(...)展开为空语句这样连格式化参数都不会执行零开销。C#里可以在编译常量#if DEBUG里直接包一层release模式下去掉多余日志调用。功能增强方面比较常见的扩展有结构化日志不只是输出一条字符串而是输出一个可解析的JSON或者XML每行日志包含timestamp、level、message、context字段方便接入日志平台。信号量指标记录日志条数、错误数、平均写入耗时用于监控。日志自动清理超过N天的日志自动删除避免磁盘被签满。这些扩展方向都是在基础日志函数稳定后再慢慢加的能力。第一版能跑、不乱、不丢就已经解决了80%的问题。我个人在这些年做项目时的体会是日志函数虽然小但它是整个系统的“眼睛”。平时没有异常时意识不到它的存在一旦真出了问题你才知道一双清晰、稳定的眼睛有多值钱。与其在线上环境里靠砍日志量来救火不如从一开始就把日志函数写扎实该加锁加锁、该落盘落盘、该轮转轮转。前面给的那套C宏方案和C#静态类方案我至今还在不同的项目里用踏实、好改、不折腾你也完全可以放心照着抄然后根据业务场景慢慢往里面加东西。
返回列表