跟我学c++中级篇—streambuf在glog中的应用分析

一、展开

在前面分析和学习了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的底层缓冲),主要原因在于:

  1. 打日志不分配堆内存:缓冲区是LogMessageData的成员数组。LogMessageData通常放在线程局部存储区中,常见情况下一条日志不需要任何堆分配。std::stringbuf则会随写入内容增长而反复分配内存
  2. 超长截断:不会重新分配内存,单条日志有硬上限,防止过长的日志耗尽内存
  3. 写出时零拷贝:Flush()直接使用pbase()指向的原始内存写文件,而stringbuf::str()会先拷贝成一个std::string
  4. 处理已废弃的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()的重写,实现了自定义的功能。具体的内容:

  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):

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
};

要点:

  1. 先传空指针,再绑定:基类std::ostream在成员streambuf_ 之前构造。如果在初始化列表里把 &streambuf_ 传给基类,基类拿到的是一个尚未构造的对象。所以先传NULL,等streambuf_ 构造完成后,在构造函数体里调用rdbuf(&streambuf_) 绑定。rdbuf() 同时会清除传入空指针时设置的badbit。
  2. streambuf_ 是值成员:缓冲对象与流对象同生共死,地址在LogStream的整个生命周期内保持不变,不需要额外的分配和释放。
  3. ctr_ 与缓冲无关:它是LOG_EVERY_N、LOG_FIRST_N等宏把计数值传进流对象的通道。
  4. self_ 用于一致性检查:源码注释称之为 "Consistency check hack",用于确认拿到的流对象确实是这个LogStream。
  5. 禁止拷贝:拷贝构造和赋值声明为私有且不实现(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(),这些日志不会经过上面的缓冲。

六 缓冲区的存储与上限

  1. 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',数组最后一个字节是余量
  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():

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;
}
  1. 上面的代码说明:

    • 预留两字节用于: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提前过滤
  2. 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。
  1. 栈递归和FATAL日志
    • --log_backtrace_at=文件名:行号:在指定位置打日志时,Init()会把当前栈回溯以 " (stacktrace:\n...) "的形式写入同一块缓冲区,但会受29998字节的限制,栈太深时被截掉的是递归回溯的尾部
    • FATAL日志:消息本身使用静态的fatal_msg_data_exclusive / fatal_msg_data_shared。其后的 "Check failure stack trace" 由失败处理函数直接写入stderr,不经过这块缓冲区即不受30000字节的限制
  2. 处理约束
    • 静默截断: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用线程局部存储管理。

开发者在实现类似的固定缓冲日志时,可以参考这种思想。正如前面所讲"它山之石,可以攻玉"。

相关推荐
吾与谁归in1 小时前
C#脚本工具,支持智能提示自动补全(WPF + Roslyn + AvalonEdit)
开发语言·c#·wpf
码匠许师傅1 小时前
【C++三方组件】OpenCV:计算机视觉的事实标准
c++
昂好的day1 小时前
WPF 从零入门(上):WPF 是什么、XAML 语法、布局与样式全解
开发语言·c#·wpf
飞浪纪元[FWC–FE]1 小时前
Qt 构建排错实录:从 MSB 1009 到生成器选型
开发语言·数据库·qt
fly-iot1 小时前
【FDE知识库】(1):AI 编程时代开发语言选型分析,云使用go,企业用java,算法python,底层c
开发语言·人工智能·golang
云边有个稻草人1 小时前
Prometheus Pushgateway教程:离线任务指标上报、Shell与Python示例、远程访问
开发语言·python·prometheus
暴走约伯1 小时前
【VFX工具链源码拆解 01】总体架构:大型项目特效流程的程序化落地
开发语言·经验分享·3d·unity·架构·游戏引擎
C^h10 小时前
c++ string
开发语言·c++
raindayinrain10 小时前
c++高性能--原子操作,无锁编程,操作顺序
开发语言·c++·性能优化