一、展开
在前面分析和学习过 streambuf 的基础应用与自定义方式后,接下来自然要看看它在实际工程中如何落地。谷歌的 glog 就是一个理想的观察对象——通过分析其源码,可以与之前的学习内容相互对照,更扎实地掌握相关接口的重载与实际使用方式。
本文将以谷歌 glog v0.6.0(2022)为基础,结合 0.7 版本中对 std::streambuf 的日志缓冲实现进行分析。实际上,这块代码从 0.4 到 0.7 并未发生实质性变化,其具体实现机制也保持了一致。
二、glog 中的相关模块和文件
实际用过 glog 日志的开发者都知道,glog 通过一系列宏来实现日志输出。源码实现中,每条宏都会对应一个 LogMessage 临时对象。它通过 std::ostream 把内容格式化到一块固定大小的缓冲区中,析构时再一次性写入文件。在这当中,std::streambuf 用来管理和维护这块内存的写指针。
| 模块 |
文件 |
作用 |
| 流缓冲 |
src/glog/logging.h(由 src/glog/logging.h.in 生成) |
base_logging::LogStreamBuf : std::streambuf,以及嵌套类 LogMessage::LogStream : std::ostream |
| 缓冲存储与刷新 |
src/logging.cc |
kMaxLogMessageLen、LogMessageData、缓冲区的分配、前缀写入、Flush() / SendToLog() |
| 日志 level |
src/glog/log_severity.h |
GLOG_INFO 等,与缓冲无关 |
| 实现 |
src/raw_logging.cc |
RAW_LOG 不使用 streambuf,通过 vsnprintf 格式化到缓冲区后再 write |
热路径上的对象调用和依赖关系:
LOG(INFO) << x
-> LogMessage 临时对象(logging.cc 中构造 / 析构)
-> data_ 指向 LogMessageData(线程局部存储、堆或 FATAL 专用静态对象)
-> message_text_[30001] 固定缓冲
-> LogStream : std::ostream 格式化(operator<<、num_put)
-> LogStreamBuf : std::streambuf 只维护 put area
-> ~LogMessage() -> Flush() -> SendToLog() -> fwrite / write / LogSink
LogStream 中包含 LogStreamBuf 成员,缓冲类本身没有单独的 .cc 文件。
三、glog 重写 streambuf 说明
glog 不使用标准库现成的 std::stringbuf(std::ostringstream 的底层缓冲),主要原因在于:
- 打日志不分配堆内存:缓冲区是
LogMessageData 的成员数组。LogMessageData 通常放在线程局部存储区中,常见情况下一条日志不需要任何堆分配。而 std::stringbuf 会随写入内容增长而反复分配内存。
- 超长截断:不会重新分配内存,单条日志有硬上限,防止过长的日志耗尽内存。
- 写出时零拷贝:
Flush() 直接使用 pbase() 指向的原始内存写文件,而 stringbuf::str() 会先拷贝成一个 std::string。
- 处理已废弃的
std::ostrstream:源码注释写的是 "Legacy public ostrstream method"。glog 只需要 ostrstream 的两个能力——使用外部提供的固定缓冲区,以及用 pcount() 获取已写入的长度。
RAW_LOG 仅用于 glog 内部、信号处理函数等不能加锁或分配内存的场合,它使用 vsnprintf 格式化到 3000 字节大小的缓冲区后再写出。因此 LogStreamBuf 只是普通日志的实现,不是所有日志的唯一实现方式。
四、LogStreamBuf 说明
看一下相关头文件(logging.h)中的定义:
namespace base_logging {
class GLOG_EXPORT LogStreamBuf : public std::streambuf {
public:
// REQUIREMENTS: "len" must be >= 2 to account for the '\n' and '\0'.
LogStreamBuf(char *buf, int len) {
setp(buf, buf + len - 2);
}
// This effectively ignores overflow.
int_type overflow(int_type ch) {
return ch;
}
// Legacy public ostrstream method.
size_t pcount() const {
return static_cast<size_t>(pptr() - pbase());
}
char* pbase() const {
return std::streambuf::pbase();
}
};
} // namespace base_logging
通过 overflow() 的重写,实现了自定义的功能。具体来看:
1. put area 与 setp()
std::streambuf 的输出区(put area)由三个指针描述:
pbase():缓冲区起点
pptr():当前写入位置
epptr():可写区的末尾(不含)
setp(buf, buf + len - 2) 把 pbase() 和 pptr() 设为 buf,把 epptr() 设为 buf + len - 2,可写字节数为 len - 2。末尾故意留出两个字节,给 Flush() 追加 '\n' 和 '\0' 使用,这样正常的 operator<< 永远不会写到这两个位置。
2. overflow():成功但丢弃
sputc() 的标准行为是:如果 pptr() < epptr(),则 *pptr()++ = ch 并返回 ch;否则返回 overflow(ch)。
这里 overflow() 直接返回 ch,带来两个后果:
- 字符没有写入缓冲区,超出部分被丢弃。
- 返回值不是
traits_type::eof(),所以 ostream 不会设置 badbit。调用方看起来写成功了,pcount() 却不再增长。这是有意的静默截断。
3. 没有重写的虚函数
xsputn():批处理(<< std::string、write())使用基类的默认实现,即先将可处理部分写入 put area,余下部分逐字符调用 overflow()。由于 overflow() 总是返回成功,基类会把剩余内容逐字符处理完。截断对单字符和批量写入都有效,但超长消息被丢弃的部分也要付出逐字符的虚函数调用开销。
seekoff() / seekpos():调用基类实现,返回 -1,所以 tellp() 恒为 -1,无法用来获取长度。所以 glog 提供 pcount() 来获取长度。
sync() / underflow():此缓冲区只写、不可定位、不可读。std::flush 不会触发真正的写出,真正的写出发生在 LogMessage 析构时。
五、LogStream:ostream 的缓冲区绑定
LogMessage::LogStream 在同一个头文件中定义(v0.6.0):
class GLOG_EXPORT LogStream : public std::ostream {
public:
LogStream(char *buf, int len, int64 ctr)
: std::ostream(NULL),
streambuf_(buf, len),
ctr_(ctr),
self_(this) {
rdbuf(&streambuf_);
}
int64 ctr() const { return ctr_; }
void set_ctr(int64 ctr) { ctr_ = ctr; }
LogStream* self() const { return self_; }
// Legacy std::streambuf methods.
size_t pcount() const { return streambuf_.pcount(); }
char* pbase() const { return streambuf_.pbase(); }
char* str() const { return pbase(); }
private:
LogStream(const LogStream&);
LogStream& operator=(const LogStream&);
base_logging::LogStreamBuf streambuf_;
int64 ctr_; // Counter hack (for the LOG_EVERY_X() macro)
LogStream *self_; // Consistency check hack
};
几个要点:
-
先传空指针,再绑定:基类 std::ostream 在成员 streambuf_ 之前构造。如果在初始化列表里把 &streambuf_ 传给基类,基类拿到的是一个尚未构造的对象。所以先传 NULL,等 streambuf_ 构造完成后,在构造函数体里调用 rdbuf(&streambuf_) 绑定。rdbuf() 同时会清除传入空指针时设置的 badbit。
-
streambuf_ 是值成员:缓冲对象与流对象同生共死,地址在 LogStream 的整个生命周期内保持不变,不需要额外的分配和释放。
-
ctr_ 与缓冲无关:它是 LOG_EVERY_N、LOG_FIRST_N 等宏把计数值传进流对象的通道。
-
self_ 用于一致性检查:源码注释称之为 "Consistency check hack",用于确认拿到的流对象确实是这个 LogStream。
-
禁止拷贝:拷贝构造和赋值声明为私有且不实现(C++11 之前的写法),因为拷贝后的 streambuf_ 仍会指向原来的缓冲区。
LOG 宏的展开(v0.6.0):
#define LOG(severity) COMPACT_GOOGLE_LOG_ ## severity.stream()
#define COMPACT_GOOGLE_LOG_INFO google::LogMessage(__FILE__, __LINE__)
// 即 google::LogMessage(__FILE__, __LINE__).stream()
临时 LogMessage 对象一直存活到整条语句结束。stream() 的返回类型是 std::ostream&(实际引用的是内部的 LogStream),于是 << a << b 都写进同一个 put area。语句结束时 LogMessage 析构,并在析构函数中调用 Flush()。
如果定义了 GOOGLE_STRIP_LOG,低于指定级别的 COMPACT_GOOGLE_LOG_xxx 会被替换为 google::NullStream(),这些日志不会经过上面的缓冲。
六、缓冲区的存储与上限
1. LogMessageData 定义
// src/logging.cc:
const size_t LogMessage::kMaxLogMessageLen = 30000;
struct LogMessage::LogMessageData {
LogMessageData();
int preserved_errno_; // 进入日志调用时的 errno
char message_text_[LogMessage::kMaxLogMessageLen + 1]; // 30001
LogStream stream_;
char severity_;
int line_;
void (LogMessage::*send_method_)();
union { LogSink* sink_;
std::vector<std::string>* outvec_;
std::string* message_; };
size_t num_prefix_chars_;
size_t num_chars_to_log_;
size_t num_chars_to_syslog_;
const char* basename_;
const char* fullname_;
bool has_been_flushed_;
bool first_fatal_;
};
LogMessage::LogMessageData::LogMessageData()
: stream_(message_text_, LogMessage::kMaxLogMessageLen, 0) {}
大小计算如下:
- 数组长度为
kMaxLogMessageLen + 1,即 30001
- 传递给
LogStream 的 len 为 30000
setp(buf, buf + len - 2) 之后,put area 大小为 29998
- 下标 29998、29999 留给
'\n' 和 '\0',数组最后一个字节是余量
2. LogMessageData 位置
LogMessage 对象保存两个指针 data_、allocated_,约 30KB 的 LogMessageData 对象不在调用者的栈上。通过 LogMessage::Init() 可以知道位置:
| 情况 |
存储位置 |
是否有堆分配 |
| 标准日志,本线程当前没有其他日志在构造 |
线程局部的 thread_msg_data,用 placement new 构造 |
否 |
嵌套日志(例如 << 的参数在求值时又打了日志) |
new LogMessageData() |
是 |
| 编译时不支持 thread_local |
new LogMessageData() |
是 |
| FATAL 日志(且会退出进程) |
静态对象 fatal_msg_data_exclusive 或 fatal_msg_data_shared |
否 |
析构时,如果用的是线程局部存储,则调用析构函数将其标记为可用;否则 delete allocated_。在 v0.6.0 的 libglog.so 也能看出这一点:它的 TLS 段大小为 30457 字节,正好可以装下一个 LogMessageData。
所以“热路径无堆分配”只在常见情况下成立:每个线程第一层的日志用的是线程局部缓冲,嵌套日志仍会分配约 30KB 的堆内存。
3. 前缀占用同一块缓冲
前缀写在同一块 put area 的头部,格式如下(FLAGS_log_year_in_prefix 默认为 true,日期年份为 4 位;线程 ID 占 5 位):
I20260322 14:08:01.123456 12345 logging_example.cc:42]
Init() 利用同一个 stream() 写完级别、时间、线程 ID、文件名和行号,然后记录前缀长度:
data_->num_prefix_chars_ = data_->stream_.pcount();
写前缀前后会用 copyfmt() 保存并恢复流的格式状态,所以前缀里的 setfill('0')、setw() 不会影响用户的 <<。FLAGS_log_prefix 为 false,或行号为 LogMessage::kNoLogPrefix 时不写前缀;也可以通过 InitGoogleLogging() 传入自定义前缀回调。
用户的 << 接在前缀后面,前缀越长,正文可用的字节越少。超出上限时,只截掉正文尾部而不截掉前缀。
七、Flush 实现
在 glog 中调用刷新会自动进行补换行、补 NUL、写出等动作。LogMessage 析构时调用 Flush():
void LogMessage::Flush() {
// 已经刷新,或低于 --minloglevel 的日志,直接返回
if (data_->has_been_flushed_ || data_->severity_ < FLAGS_minloglevel) {
return;
}
data_->num_chars_to_log_ = data_->stream_.pcount();
data_->num_chars_to_syslog_ =
data_->num_chars_to_log_ - data_->num_prefix_chars_;
// 末尾非换行,在 put area 之后的预留并补一个换行
bool append_newline =
(data_->message_text_[data_->num_chars_to_log_ - 1] != '\n');
char original_final_char = '\0';
if (append_newline) {
original_final_char = data_->message_text_[data_->num_chars_to_log_];
data_->message_text_[data_->num_chars_to_log_++] = '\n';
}
data_->message_text_[data_->num_chars_to_log_] = '\0';
// 全局日志锁,调用 SendToLog / SendToSink 等发送方法
{
MutexLock l(&log_mutex);
(this->*(data_->send_method_))();
++num_messages_[static_cast<int>(data_->severity_)];
}
LogDestination::WaitForSinks(data_);
// 恢复被换行覆盖的字符
if (append_newline) {
data_->message_text_[data_->num_chars_to_log_ - 1] = original_final_char;
}
// 把 errno 恢复为进入日志调用时的值
if (data_->preserved_errno_ != 0) {
errno = data_->preserved_errno_;
}
data_->has_been_flushed_ = true;
}
上面的代码说明了几个关键点:
- 预留两字节的用途:
pcount() 最大为 29998,所以 message_text_[pcount()] 处的 '\n' 和后面的 '\0' 都处于 put area 之外、但仍在数组之内。用户已经写入换行时,不再追加。
- 按长度写出,不判断 NULL:写文件和 stderr 用的长度是
num_chars_to_log_(包含前缀)。'\0' 是给按 C 字符串使用的路径准备的,例如 syslog 和部分 sink。syslog 从 message_text_ + num_prefix_chars_ 开始,不带 glog 的前缀。
- 格式化不锁:日志的
LogMessageData 只属于当前线程,往 streambuf 写入不需要锁。只有调用发送时才获取全局的 log_mutex,保证多线程写同一个文件时日志不混乱。
- 格式化后进行级别过滤:低于
FLAGS_minloglevel 的日志在 Flush() 开头才被丢弃。对性能敏感的场合,应当用 VLOG、LOG_IF 或编译期的 GOOGLE_STRIP_LOG 提前过滤。
errno 说明
Init() 时把 errno 保存到 preserved_errno_,其作用为:
- PLOG 使用当前入口的 errno:
ErrnoLogMessage 析构时通过 preserved_errno() 生成 ": No such file or directory [2]" 后缀。即使 << 的参数在求值时改变了 errno,输出的仍是调用前的错误。
- 日志调用不改变当前的 errno:格式化和写文件都可能改变 errno,
Flush() 最后会把它恢复。所以“先打日志、再处理 errno”的代码不会出问题。
另外,上面的第二条只在 preserved_errno_ 不为 0 时恢复:进入日志调用前 errno 为 0 的话,日志调用之后它可能变为非 0。
八、一次 << 的整体流程
看下面这段代码:
LOG(INFO) << "data=" << 100;
以这个最简单的例子来说明:
- 构造
LogMessage,LogMessageData 从线程局部存储中获取。LogStreamBuf 的构造函数调用 setp(),将 pptr() 放在 message_text_ 的头部。
Init() 保存 errno,把前缀写进缓冲区,pptr() 前移,记录 num_prefix_chars_。
operator<<(const char*) 调用 rdbuf()->sputn(),基类 xsputn() 在 pptr() < epptr() 时直接拷贝并前移 pptr()。
operator<<(int) 经过 num_put::put(),最后写入十进制字符。
- 如果正文把
pptr() 推到 epptr(),后续字符进入 overflow() 被丢弃,good() 仍为 true。
- 整条日志结束,
~LogMessage() 调用 Flush():取得 pcount(),必要时补 '\n',写入 '\0',持锁按字节数把 message_text_ 交给文件、stderr 或 LogSink,然后恢复 errno。
栈递归和 FATAL 日志
--log_backtrace_at=文件名:行号:在指定位置打日志时,Init() 会把当前栈回溯以 " (stacktrace:\n…)" 的形式写入同一块缓冲区,但会受 29998 字节的限制,栈太深时被截掉的是递归回溯的尾部。
- FATAL 日志:消息本身使用静态的
fatal_msg_data_exclusive / fatal_msg_data_shared。其后的 "Check failure stack trace" 由失败处理函数直接写入 stderr,不经过这块缓冲区,即不受 30000 字节的限制。
处理约束
- 静默截断:
overflow() 返回 ch 而不是 eof,上层应用无法通过 fail() 发现日志被截断。这是性能与安全上限之间的取舍。
- 截断内容的开销:截断后被丢弃的每个字节都要经过一次
overflow() 虚函数调用。消息越超长,浪费越多。
- 上限限制单位为字节:多字节字符可能从中间被截断,之后照样追加
'\n' 和 '\0',日志末尾可能出现半个字符。
- 不可当作普通流混用:不能定位(
tellp() 恒为 -1),不能读,空间不足时不扩容。str() 只是返回 pbase(),不是 std::string 拷贝;Flush() 补 NUL 之前,它不一定是合法的 C 字符串。
- 嵌套日志含堆分配:只有每个线程的第一层日志使用线程局部缓冲,嵌套的日志每条需要在堆上分配一个约 30KB 的
LogMessageData。
- 空消息边界:关闭前缀(
FLAGS_log_prefix = false)后打一条空日志时,num_chars_to_log_ 为 0,Flush() 会读取 message_text_[-1]。实际读取的是相邻成员 preserved_errno_ 的最后一个字节,虽不崩溃,但严格来说属于越界访问。
- 与 RAW_LOG 是两套实现:分析 glog 的流缓冲源码时,不要把
raw_logging.cc 算进去,那里没有 streambuf,它们是两类。
九、总结
通过对 glog 中 std::streambuf 的应用可以发现,其真正的重写部分并不多。LogStreamBuf 只做管理——长度、外部数组设定和截断;而格式化交给 std::ostream;缓冲区的存储则由 LogMessage 用线程局部存储管理。
开发者在实现类似的固定缓冲日志时,可以参考这种思路。正如前面所讲,“它山之石,可以攻玉”。在云栈社区的技术讨论中,这类底层机制的分析往往能带来更深入的理解。