调了半宿,最后发现是日志把程序拖慢了。前阵子协助排查一个跑批小工具的性能问题,两万条记录的的业务逻辑一百多毫秒就跑完了,整个进程却耗了快六秒。把可疑点一个个排除,最后落在了一行看起来最无害的代码上:出错分支里那句std::cerr << ...。这件事又把我拉回到 C++ 里cerr 与 clog这对兄弟的老话题上。多数人对它们的印象停在“cerr 不带缓冲、clog 带缓冲”,应付提问够了,真到写代码时,什么时候该用哪个、这两个流为什么会被设计成两种形态、混着用会出什么乱子、多线程和重定向之后行为怎么变,往往说不清楚。
这篇东西就是把这些说清楚。它不讲教科书式的标准条文摘抄,而是从一次真实的性能事故出发,把 cerr 与 clog 的缓冲模型、绑定关系、重定向连带影响、多线程表现、崩溃现场的价值一条条拆开,配上能直接跑的验证代码和我自己踩过的坑。适合已经能写 C++、但对标准库 I/O 内部机制只停留在“知道有这么个东西”层面的读者,也适合正在给项目搭日志骨架、纠结该往哪个流里写的人。看完你至少能判断:这行日志写进 cerr 是帮忙还是添乱。
1. 三个标准流的分工,先把关系摆正
1.1 cout、cerr、clog 各自的身份与去向
<iostream>里定义了四个标准流对象:std::cin、std::cout、std::cerr、std::clog。前面两个是常规的输入和输出,后面两个都指向标准错误,也就是进程的 2 号文件描述符。这一点必须先钉死,因为它决定了后面所有的行为差异:cerr 和 clog 写出去的内容,最终落到同一个地方,只是走的路不同。
#include <iostream> int main() { std::cout << "普通输出\n"; // 通常去 fd 1 std::cerr << "错误输出\n"; // 去 fd 2 std::clog << "日志输出\n"; // 也去 fd 2 return 0; }编译运行之后,如果在终端里直接看,三行都在屏幕上,分不出谁是谁。但只要把标准输出重定向到文件,区别立刻显形:./demo > out.txt执行完,out.txt里只有第一行,后两行照样打在终端上。原因很简单,重定向默认只改 fd 1,fd 2 还连着终端。想连错误一起收走,得写./demo > out.txt 2>&1或者./demo 2> err.txt。
我见过有人把日志分成“正常日志写 cout、错误日志写 cerr”,然后在部署脚本里只重定向了 stdout,结果线上排障时错误日志全散在终端里,日志采集系统一条没收到。这不是 cerr 的锅,是重定向没配对。所以第一个实操要点:只要你的程序会往 cerr 或 clog 写东西,重定向就必须显式处理 fd 2。
从对象层面看,cerr 和 clog 都是std::ostream的实例,支持完全相同的operator<<重载、相同的格式化标志位、相同的width/precision/fill接口。你写std::cerr << std::hex << 255和写std::clog << std::hex << 255,格式化行为一模一样。它们的差别不在“能写什么”,而在“什么时候真正写出去”。
1.2 标准错误为什么非要留两个入口
第一次认真看文档的人多半会问:既然目的地都是 stderr,搞两个对象是不是设计冗余?我一开始也这么想,直到有次线上程序被信号干掉,才明白这不是冗余,是两个不同取向的取舍。
输出到控制台的场景,用户盯着屏幕看,每一条错误都希望立刻出现,卡顿一下都难受;输出重定向到文件的场景,可能一秒钟几万条,每条都立刻落到磁盘上,光是系统调用就能把 IO 打满。这两种诉求天然冲突,一个要“快出现”,一个要“少折腾”。标准委员会的处理方式很干脆:不替你选,给两个,你自己挑。
cerr走的是“立刻出现”这条路,它被设成单位缓冲(unitbuf),每次插入操作结束就刷新;clog走的是“攒着一起写”这条路,用常规的缓冲区,攒够了或者遇到特定时机才刷出去。这两个对象共用同一个底层目标,但内部策略完全相反。你可以理解为同一个仓库的两个发货窗口:一个是每来一件货立刻发走,一个是装满一车再发。
这里还有个容易被忽略的细节:因为两者共用 fd 2,所以在没有额外同步措施的情况下,它们的内容可能在时间顺序上打架。cerr 的内容已经出去了,clog 的内容还压在缓冲里,等 clog 最终刷出来,日志文件里就会看到“后面的报错跑到前面的信息前面”这种诡异现象。这个坑后面会专门拆。
注意:cerr 与 clog 不是“错误级别”的区分,别把它们当成 ERROR 和 INFO 的天然映射。它们的唯一区别是缓冲策略,跟日志级别没有任何关系。把“错误写 cerr、信息写 clog”当成规矩照搬,很容易在性能敏感路径上踩雷。
2. 缓冲策略才是两者真正的分水岭
2.1 cerr 的单位缓冲:每一次插入都意味着一趟系统调用
cerr在构造时就被设置了std::ios_base::unitbuf标志。这个标志的含义是:每一个输出操作完成后立即调用flush()。注意是“每一个输出操作”,不是“每一行”。
std::cerr << "value=" << 42 << ", ok=" << true << '\n';这行代码里有四次插入操作(字符串、整数、字符串、布尔),加上最后的换行又是一次。在 unitbuf 生效的前提下,理想情况下会触发五次刷新动作。每次刷新都会推动底层缓冲把数据交给系统调用写出去。行内的字符串拼接本来只是内存拷贝,到了 cerr 这里就变成了实打实的 IO 开销。
我在本机做过一组粗略对比,两万次循环,每次输出约二十字节,重定向到文件:
| 写法 | 循环次数 | 大致耗时 | 说明 |
|---|---|---|---|
std::cerr << "err " << i << '\n' | 20000 | 数秒量级 | 每次插入都可能触发刷新 |
std::clog << "log " << i << '\n' | 20000 | 数十毫秒量级 | 攒在缓冲里批量落地 |
| 关闭 unitbuf 后的 cerr | 20000 | 与 clog 接近 | 只差 tie 带来的额外刷新 |
数字只能看量级,机器不同、文件系统不同、是否落盘都会影响结果。但差距的方向是稳定的:cerr 在重定向到文件时,性能会掉一个到两个数量级。这一点很多人没意识到,因为平时在终端里跑,终端本身刷新就慢,看不出差别;一旦重定向到文件或者接进日志管道,瓶颈立刻暴露。
那能不能关掉?可以。两种写法等价:
std::cerr.unsetf(std::ios_base::unitbuf); // 或者 std::cerr << std::nounitbuf;关掉之后 cerr 就退化成一个普通的带缓冲流,行为接近 clog,区别只剩后面要讲的 tie 绑定。这个操作是全局的,改一次影响整个进程后续所有对 cerr 的写入,所以要么在程序启动阶段统一改,要么就别乱动。我一般不建议在业务代码里随手关,因为这会破坏“错误立刻可见”这个前提——而那个前提恰恰是当初选 cerr 的理由。
2.2 clog 的缓冲与刷新时机
clog没有 unitbuf 标志,它用底层流缓冲的策略。缓冲区什么时候刷出去,取决于几个时机:缓冲区满了、程序正常退出时静态对象析构、显式调用std::clog.flush()、遇到std::endl或std::flush这类操纵符、以及流对象被 tie 到别的流而那个流需要读入时。
std::clog << "这条可能还压在缓冲里\n"; std::clog.flush(); // 手动推一把 std::clog << "这条也是" << std::endl; // endl = 换行 + flush这里有个非常实际的问题:重定向到文件时,clog 的内容可能长时间不出现在文件里。写了个守护进程往 clog 打日志,tail -f看文件半天没动静,以为程序卡死了,其实是缓冲没满。我早期的做法是每行都加std::endl,后来发现这等于把 clog 硬生生用成了 cerr,白白浪费了缓冲带来的性能优势。
正确思路是分场景。调试期想实时看,加std::endl或者定期 flush;线上稳定运行、追求吞吐,就让缓冲自然工作,靠“缓冲区满自动刷”和“进程退出时刷”两条兜底。中间态的做法是定时刷新,比如每写 N 条或者每隔若干毫秒调一次 flush,兼顾延迟和吞吐。
还有个跨实现的细节值得提一句。在默认的同步模式(sync_with_stdio(true))下,标准流对象背后接的是 C 的 stdio 缓冲,而 C 标准对 stderr 的缓冲要求是“不一定要全缓冲”,实践中多数实现把 stderr 设成无缓冲或行缓冲。这意味着在这种配置下,clog 的“缓冲”其实是被底层 C 流的行为盖住的,你未必能观察到明显的批量写入效果。而一旦调用std::ios_base::sync_with_stdio(false),C++ 流换成自己的文件缓冲实现,clog 才真正开始攒数据。
这就引出一条实测建议:如果你对 clog 的刷新时机有强依赖,别靠猜,写个几十行的验证程序,把输出重定向到文件后用strace或者简单的tail -f观察实际落盘节奏。不同工具链、不同标准库实现、是否关闭同步,结果可能不一样。
2.3 一行日志到底刷了几次:把开销算明白
为了把这件事说透,我把一次典型的日志写入拆开算。假设写的是:
std::cerr << "[ERROR] 连接超时, code=" << code << '\n';在 unitbuf 生效时,这条语句的开销包括:四次插入操作各自的格式化与缓冲写入、每次插入后可能触发的 flush、flush 内部推动的系统调用。如果流还 tie 到了 cout(cerr 默认如此,下一节详说),那么每次插入前的 sentry 构造还可能顺带刷新 cout。
而如果换成先拼字符串再一次性输出:
std::string line = "[ERROR] 连接超时, code=" + std::to_string(code) + "\n"; std::cerr << line; // 只有一次插入插入次数从四次降到一次,flush 次数也相应减少。这个改写在高频错误路径上收益非常明显。代价是多一次字符串构造,但相比系统调用,这点内存操作基本可以忽略。
同样的思路可以推到更远:把格式化交给std::format或者第三方格式化库,拼出完整一行再写;不要在流上做多次<<拼接。我现在的习惯是,凡是要进日志的内容,先在局部拼成std::string,最后一次性交给目标流。这样既减少刷新次数,也顺便解决了多线程下多次插入被打断导致的半行交错问题。
注意:
std::endl是“插入换行符 + 刷新”,而'\n'只是插入换行符。在 unitbuf 的 cerr 上,这两个写法差别不大,因为插入本身就会触发刷新;但在 clog 或关闭了 unitbuf 的流上,std::endl会把缓冲优势全部抵消。循环里写std::endl是性能问题的常见来源。
3. tie 绑定关系与输出顺序的连环坑
3.1 cerr 为什么默认拴着 cout
标准里对 cerr 有一条规定:它的tie()返回&cout。这个绑定关系的意思是,在对 cerr 进行任何输出操作之前,先刷新 cout。为什么要这么设计?想象一个交互式程序,先往 cout 打一句提示“请输入路径:”,然后用 cerr 报告“路径无效”。如果 cout 的内容还压在缓冲里,用户看到的顺序就变成了先出现错误、再出现提示,非常荒谬。tie 机制就是用来保证这种顺序正确性的。
clog没有这条强制要求,多数实现里它的tie()是空指针,也就是不绑定任何流。
这个差异带来的实际影响有两个方向。好的一面是顺序正确,坏的一面是性能。每写一次 cerr,都等于顺带把 cout 刷一遍。如果你的程序 cout 输出量很大,比如往 stdout 打数据流,同时又频繁往 cerr 报告警告,那么每一条警告都会迫使 cout 提前落盘,把原本可以批量写入的 stdout 打得七零八落。
std::cerr.tie(nullptr); // 解除绑定,禁用隐式刷新这行代码我在两类场景里会加:一是确定自己不需要 cout 与 cerr 之间的严格顺序保证;二是 cout 写入量大且对吞吐敏感。加了之后,前面那个“错误跑到提示前面”的现象就可能出现,这是需要自己承担的代价。
这里补一句:std::ios::sync_with_stdio(false)会改变标准流与 C 流的同步关系,但不会改变 cerr 对 cout 的 tie 绑定。这两件事是独立的,经常被混为一谈。
3.2 顺序错乱的复现与三条修复路径
光讲理论不直观,直接上能复现的例子:
#include <iostream> int main() { for (int i = 0; i < 3; ++i) { std::cout << "步骤 " << i << " 完成\n"; std::cerr << "警告 " << i << "\n"; } return 0; }默认情况下,在终端里看到的是严格交替的,因为 cerr 的 tie 会逼着 cout 先刷。现在把 cerr 对 cout 的绑定解掉:
std::cerr.tie(nullptr);再配上一段足够多的 cout 输出,重定向到文件后cat出来,就会看到三行“步骤”挤在一起,三行“警告”挤在一起,交替关系彻底丢失。原因很直白:cout 攒着没刷,cerr 随着 tie 解除也不再推动它,两者各走各的。
修复路径有三条,各有取舍:
- 保留 tie,接受性能损失。适合输出量不大的命令行工具和对顺序敏感的场景。
- 解除 tie,但让两者都及时刷新。把 cout 也设成单位缓冲,或者每条后面手动 flush。顺序保住了,性能也没了,属于最差组合,一般不推荐。
- 统一出口。所有内容走同一个流,或者走自己封装的日志器,由日志器决定顺序和缓冲。这在工程上最稳,也是我现在的默认选择。
还有一个变体坑:cerr 和 clog 混用时的时间顺序错乱。比如某个分支写 cerr(立刻出),紧接着正常路径写 clog(压着),最后日志文件里看到的是“正常”出现在“错误”之前。这类问题在排查时极具迷惑性,因为代码逻辑完全正确,只是输出时序被打乱了。解决办法只有两个:要么两者都用无缓冲,要么两者都通过同一个出口输出,别让它们各自为政。
| 场景 | 推荐做法 | 理由 |
|---|---|---|
| 命令行工具,交互式提示 + 错误 | 保留 cerr 的默认 tie | 顺序正确优先,输出量小 |
| 高吞吐数据处理,stdout 打数据流 | cerr.tie(nullptr) | 避免每条警告都冲刷 stdout |
| 需要日志时序严格可比 | 统一走一个日志出口 | 消除两套缓冲模型造成的时序错位 |
| 既要性能又要顺序 | 只在关键节点保留 tie,其余用独立日志器 | 折中,需要设计 |
4. 当简易日志用:重定向、多线程与崩溃现场
4.1 重定向 stderr 的几种方式与连带影响
重定向 stderr 在 Linux 上至少有三种做法,影响面各不相同。
第一种是 shell 层,./app 2> err.log。这是最干净的,进程内部完全不知道发生了什么,所有对 fd 2 的写入自动落到文件。代价是:cerr 依然保持 unitbuf,每条错误照样触发刷新,只是刷向的对象从终端变成了文件。前面说的性能问题在这种情况下最严重,因为终端的刷新本来就有延迟,掩盖了开销,文件反而更敏感。
第二种是在程序内部用freopen:
#include <cstdio> int main() { if (!std::freopen("err.log", "w", stderr)) { return 1; } std::cerr << "这条会写进 err.log\n"; return 0; }这会把 fd 2 直接指向文件,cerr 和 clog 都跟着走,因为它们都基于 stderr。副作用是原来的终端连接被切断,后续想恢复很麻烦。
第三种是用dup2精细控制,保留原 fd 的副本,用完了能还原。这种做法在需要临时捕获错误的测试框架里很常见。
不管走哪条路,有两件事必须清楚。一是重定向不会改变流的缓冲策略,cerr 还是每条刷,clog 还是攒着,性能特征不变。二是**setvbuf(stderr, ...)会影响所有共享 stderr 的流**,包括 C 的fprintf(stderr, ...)和 C++ 的 cerr、clog。想让 cerr 变快有个野路子是给 stderr 设个大缓冲,但这么做会破坏所有依赖“错误立刻可见”的代码,包括第三方库,我劝你别在共享库环境里干这事。
还有一个特殊情况:在没有控制台的宿主环境里,写 stderr 可能直接失败,但每次调用的开销照样存在。如果你发现某段代码在本地很快、在无终端的服务环境里特别慢,可以查一下是不是错误路径上打了太多日志。
4.2 多线程下写 cerr/clog 的真实行为
C++ 标准保证:对已同步的标准 iostream 对象的格式化与非格式化输入输出操作,多线程并发访问不会产生数据竞争。注意这个保证的前提——已同步,也就是没有调用sync_with_stdio(false)。所以在默认配置下,多个线程同时写 cerr 不会把程序写崩,也不会产生未定义行为。
但“不崩”和“正确”是两码事。单独一次operator<<是受保护的,整体来看一条日志不是。下面这段代码在多线程下会输出什么?
// 线程 A std::cerr << "线程A: 开始处理\n"; // 线程 B std::cerr << "线程B: 开始处理\n";这两条各自是一次插入,大概率不会互相穿插。但如果写成:
std::cerr << "线程" << id << ": 开始处理\n";那就是三次插入,别的线程完全可能插进中间,输出成线程线程A: 开始处理B:这种残句。这种日志在生产环境里出现过一次就够你查半天。
我的处理方式很朴素:先在局部拼成完整字符串,再一次性写入。这样把多次插入压成一次,交错概率降到接近零。严格来说单次插入的原子性也只是实现层面的保障,要绝对安全还是得加锁。
#include <mutex> #include <sstream> std::mutex log_mtx; void log_error(const std::string& msg) { std::ostringstream oss; oss << "[ERR] " << msg << '\n'; std::lock_guard<std::mutex> lk(log_mtx); std::cerr << oss.str(); }另外,开启sync_with_stdio(false)之后,标准流与 C 流的同步关系被解除,前面那条多线程保证是否还成立就变得微妙了。这种情况下我会一率加自己的互斥锁,不去依赖标准层的保证。如果性能还不够,就换个思路:写内存队列,由专门的线程负责输出,业务线程只入队。
4.3 一套可以直接拿去用的轻量日志封装
结合前面所有结论,这里给一套最小可用的封装。它的设计意图是:错误走无缓冲通道保证不丢,普通信息走缓冲通道保吞吐,每行先拼完整再写,用互斥锁兜住整行原子性,启动时解除 cerr 对 cout 的隐式绑定。
#include <iostream> #include <mutex> #include <string> class Logger { public: enum class Level { Info, Warn, Error }; static Logger& instance() { static Logger inst; return inst; } void write(Level lv, const std::string& msg) { const char* tag = (lv == Level::Error) ? "[ERR ]" : (lv == Level::Warn) ? "[WARN]" : "[INFO]"; std::string line; line.reserve(msg.size() + 8); line.append(tag).append(" ").append(msg).append("\n"); std::lock_guard<std::mutex> lk(mtx_); if (lv == Level::Error) { std::cerr << line; // 不缓冲,进程崩溃也不丢 } else { std::clog << line; // 有缓冲,吞吐优先 } } private: Logger() { std::cerr.tie(nullptr); // 去掉隐式刷新 cout 的开销 } std::mutex mtx_; }; int main() { Logger::instance().write(Logger::Level::Info, "启动完成"); Logger::instance().write(Logger::Level::Error, "端口被占用"); return 0; }这套东西不追求功能完备,但解决了几件最基础的事:不会因为多次插入导致半行交错,错误日志不会因为崩溃丢在缓冲里,普通日志不会每行都打一次系统调用。真要做生产日志,还得加时间戳、线程 id、文件轮转、异步落盘,但骨架就在这里,往上叠就行。
有一点要提醒:std::cerr.tie(nullptr)是全局生效的,如果程序里还有别的地方依赖 cout 与 cerr 的顺序保证,就会出问题。所以这类全局开关最好集中在初始化阶段做,别散落在各个模块里。
5. 常见问题速查与排查清单
5.1 症状到根因的对照表
下面这张表是从我自己和同行那里攒出来的,症状在左,根因和验证方法在右。遇到相关现象时对着看一眼,能省不少时间。
| 症状 | 大概率原因 | 快速验证 |
|---|---|---|
| 错误日志和正常日志的时间顺序对不上 | cerr 无缓冲、clog 有缓冲,或者 tie 被改动 | 把两处都改成同一出口再跑一遍 |
| 日志文件里长时间没有新内容 | clog 缓冲没满,或 cout 没刷 | 手动 flush 或用std::endl试一次 |
| CPU 占用高,集中在写日志的那几行 | cerr 的 unitbuf 导致频繁落盘 | 临时关了 unitbuf 对比耗时 |
| 多线程日志出现半行残句 | 多次<<插入被其他线程打断 | 改成先拼字符串一次性写 |
| 程序崩溃后最后几条日志消失了 | 内容压在 clog 缓冲里,没有机会刷 | 关键路径改走无缓冲的 cerr |
| 重定向后终端还在打错误 | 只重定向了 fd 1,没管 fd 2 | 加2>&1或单独2> |
| 本地很快、部署后很慢 | 无终端环境,stderr 写入代价仍在 | 统计错误路径的日志条数 |
这张表最有用的地方在第三行和第五行。前者决定性能,后者决定出事后你手里有没有证据。
5.2 几个我踩过的坑与处理手法
第一个坑:在信号处理函数里写 cerr。程序收到崩溃信号,想在处理器里打一行调用栈。这在实践中很常见,但严格讲并不可靠——标准库的流操作不是异步信号安全的,处理器里调用std::cerr有可能死锁或再次崩溃。更稳的做法是在信号处理器里只用底层写调用把预先准备好的字符串丢出去,把格式化和流操作留给正常流程。我现在的习惯是崩溃路径上用最原始的手段,不做任何格式化。
第二个坑:以为 cerr 一定是同步的。有一次在嵌入式环境里跑,发现 cerr 的输出并没有立刻出现,排查下来是底层被换成了一套行缓冲的实现。教训是:cerr 的“立刻刷新”是标准库层面的语义,落到物理设备上未必立刻可见,中间还有驱动、终端、日志采集管道好几层。要验证真实时机,得在目标环境上实测,别拿开发机上的表现当结论。
第三个坑:把 cerr 和 clog 混着写同一个逻辑链路。一段流程里错误分支写 cerr、成功分支写 clog,日志排查时永远理不清先后。后来定了规矩:同一个模块的日志只走一个出口,级别信息由封装内部决定走哪个流,业务代码不碰 std 的错误流。
第四个坑:在循环体里用std::endl。早期写日志图省事,每行都加endl,程序跑起来 CPU 不高但 IO 等待特别长。换成'\n'加定时 flush 之后,吞吐提升很明显。这个小改动成本极低,收益却不小。
第五个坑:忘了 cerr 的 tie 会拖累 cout。一个往 stdout 大量输出结果的工具,加了错误日志之后整体变慢,最后定位到就是 cerr 每条都逼 cout 刷一次。加了tie(nullptr)并把顺序相关的输出统一改成 flush 到同一处,问题就没了。
注意:
sync_with_stdio(false)不是万能加速键。它解除 C 流同步后,混合使用printf和std::cout的顺序就没保证了;同时标准层面对多线程写入的保证也打了折扣。开启之前先确认代码里没有混用 C 与 C++ 的 I/O 接口。
6. 选型结论与继续深挖的方向
6.1 什么场景用哪个:一张决策表
绕了一大圈,最后总得给个能直接照着做的东西。我自己的判断逻辑整理成下面这张表,基本覆盖日常遇到的场景。
| 场景 | 选择 | 关键动作 |
|---|---|---|
| 命令行工具的错误提示 | cerr | 保持默认参数,别动 tie |
| 交互式程序,提示和错误要严格交替 | cerr | 保留对 cout 的 tie |
| 高吞吐批处理,错误量大 | cerr 但减少插入次数 | 先拼串再写,必要时衡量关 unitbuf 的可行性 |
| 后台服务的普通运行日志 | clog 或自定义日志器 | 接受缓冲,配定时 flush |
| 崩溃现场需要留证据 | cerr 或底层裸写 | 不依赖缓冲,不依赖格式化 |
| 多线程高频日志 | 自定义封装 + 互斥锁 | 整行输出,必要时异步落盘 |
| 库代码里不该乱打日志 | 干脆不打,抛异常交上层 | 库不该替应用决定输出策略 |
最后一行值得多说一句。我见过不少库在内部直接用 cerr 打调试信息,接入方完全无法关掉,日志管道被塞满。库代码里要么不输出,要么把输出策略做成可配置的回调,别直接绑定到标准流上。
性能方面再补一条经验值:如果你的错误日志量占到了总输出的一半以上,那基本可以断定设计有问题。错误路径本来就该是少数派,真到了这个比例,说明要么在拿日志当业务日志用,要么错误处理本身有冗余。我排查性能问题时看到高频 cerr,第一反应不是去关缓冲,而是回头看看为什么错误这么多。
6.2 还能往哪些方向继续挖
这个话题往下还有不少可挖的点,我列几个自己后续打算继续折腾的方向,供你参考着判断要不要深入。
一是异步日志的落地。把日志入队和实际输出解耦,业务线程只做字符串拼装和入队,IO 交给后台线程。好处是彻底摆脱 IO 对业务的影响,坏处是崩溃时队列里的内容会丢,需要额外的应急刷盘设计。现在的成熟日志库基本都是这个套路,自己写一遍能理解得很透。
二是格式化的成本。operator<<的链式调用本身有虚函数和 sentry 构造的开销,在超高频率下这部分也不可忽视。新一代格式化接口在编译期就能确定很多信息,实测比流式写法快不少。
三是标准流对象被替换的情形。某些运行环境会在启动时重定向标准流,或者用自定义的 streambuf 接管rdbuf()。这种情况下日志实现的简单做法可能会暴露性能问题,比如每行都 flush 打在网络文件系统上会很难看。如果代码要在这种环境里跑,日志出口最好可注入。
四是跨平台的缓冲差异。不同操作系统、不同工具链对 stderr 的默认缓冲设定并不一致,同一份代码在不同平台上的日志时序可能不同。需要严格时序保证的场景,不能指望默认行为,必须在应用层做统一。
这些东西说起来都有点远,但真正落到手上写第一行日志的时候,其实就三个问题:写进哪个流、什么时候让它出去、多线程来了怎么办。把这三件事想清楚,剩下的都是加法。我现在的默认起手式很简单:错误走 cerr、信息走 clog、每行先拼完整、进程启动时解除 cerr 对 cout 的绑定,等真遇到瓶颈了再按前面那些方向一点点换。