找回密码
立即注册
搜索
发回帖 发新帖

6764

积分

0

好友

821

主题
发表于 11 小时前 | 查看: 3| 回复: 0

一、展开

在前面分析和学习过 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)中的定义:

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

几个要点:

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

#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 用线程局部存储管理。

开发者在实现类似的固定缓冲日志时,可以参考这种思路。正如前面所讲,“它山之石,可以攻玉”。在云栈社区的技术讨论中,这类底层机制的分析往往能带来更深入的理解。




上一篇:Kafka怎么保证只消费一次?手动提交+幂等+事务深度解析
下一篇:商汤开源SenseNova-Loop-9B-Preview:9B权重实现14.6B推理量的循环多模态模型
您需要登录后才可以回帖 登录 | 立即注册

关闭

站长推荐上一条 /1 下一条

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-11-4 14:58 , Processed in 0.099925 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表