一、展开
在前面分析和学习了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)的定义:
c
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()的重写,实现了自定义的功能。具体的内容:
-
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<< 永远不会写到这两个位置。
-
overflow():成功但丢弃
sputc() 的标准行为:
如果pptr() < epptr():*pptr()++ = ch,返回ch
否则:返回overflow(ch)
overflow() 直接返回ch,有两个后果:
- 字符没有写入缓冲区,超出部分被丢弃。
- 返回值不是traits_type::eof(),所以ostream不会设置badbit。调用方看起来写成功了,pcount() 却不再增长。这是有意的静默截断。
-
没有重写的虚函数
- 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):
c
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):
c
#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(),这些日志不会经过上面的缓冲。
六 缓冲区的存储与上限
-
LogMessageData定义
c//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',数组最后一个字节是余量
-
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的堆内存。
-
前缀占用同一块缓冲
前缀在同一块的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():
c
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用线程局部存储管理。
开发者在实现类似的固定缓冲日志时,可以参考这种思想。正如前面所讲"它山之石,可以攻玉"。