基本框架
muduo日志系统采用的是多生产者单消费者模型,基本思路是,前端线程(产生日志消息的线程)和后端线程(负责将缓冲区中的日志信息写入磁盘文件的线程)各自维护一块buffer,前端线程维护bufferA,后端线程维护bufferB。多个前端线程往bufferA中写日志,当bufferA被写满 或者 当设定的磁盘刷新时间间隔到达,唤醒后端线程,交换bufferA和bufferB,后端线程把bufferA中的日志消息写入磁盘文件,前端线程则可以往bufferB中写入日志消息,然后等待再次唤醒后端线程将bufferB中的日志消息写入磁盘文件,如此往复。如下图所示:
使用两块buffer的好处是,前端线程的buffer写满后,交换bufferA和bufferB后,后端线程将bufferA写入磁盘的过程不会阻塞前端线程往bufferB中写日志,因为bufferA和bufferB交换过程所处的临界区很小,后面会结合代码说明这一点。在具体的实现上,muduo采用了四块buffer,前端线程和后端线程各两块,此外,前后端还各自维护了一块buffer数组,后面结合代码进行说明。基本逻辑如下图所示。
(有多种可能的情况,这里只画了一种可能的情况,其它的可能情况,陈硕老师的《Linux多线程服务器编程》中写得非常详细)
结合代码分析
代码结构及其主要功能
muduo网络库中日志系统相关的代码文件主要如下:
├── AsyncLogging.cc
├── AsyncLogging.h
├── Condition.cc
├── Condition.h
├── CountDownLatch.cc
├── CountDownLatch.h
├── FileUtil.cc
├── FileUtil.h
├── LogFile.cc
├── LogFile.h
├── LogStream.cc
├── LogStream.h
├── Logging.cc
├── Logging.h
├── Mutex.h
├── Thread.cc
├── Thread.h
├── noncopyable.h
核心代码文件功能的简要说明:
AsyncLogging.{h, cc}
维护前端线程的buffer,提供了后端线程将日志消息写入磁盘的接口;FileUtil.{h, cc}
提供了具体的文件操作,即真正的将日子消息写入文件的操作;LogFile.{h, cc}
封装了FileUtil中的文件操作,提供了间隔刷新和日志文件回滚操作;LogStream.{h, cc}
实现了输出流,类似于 cout << ;Logging.{h, cc}
封装了 LogStream,提供了写日志的接口;- 其他的一些文件用来辅助实现日志系统;
日志消息从前端写入到后端写入磁盘文件的过程
-
日志系统启动(暂时先不考虑日志系统启动的过程),前端调用Logging中 Logger 类提供的写日志接口,如下所示;
说明:只会截取重要的代码片段,省略无关代码片段,非必要代码通过注释说明, 下同
#define LOG_TRACE if (muduo::Logger::logLevel() <= muduo::Logger::TRACE) \ muduo::Logger(__FILE__, __LINE__, muduo::Logger::TRACE, __func__).stream() #define LOG_DEBUG if (muduo::Logger::logLevel() <= muduo::Logger::DEBUG) \ muduo::Logger(__FILE__, __LINE__, muduo::Logger::DEBUG, __func__).stream() #define LOG_INFO if (muduo::Logger::logLevel() <= muduo::Logger::INFO) \ muduo::Logger(__FILE__, __LINE__).stream() // ...
前端程序只需要通过类似于
LOG_INFO << “this is a log message”;
的调用即可写一条日志,即Logger(…).stream() << “…”;
-
每写一条日志都会创建一个Logger实例,如第1步所示,调用stream()方法,获取封装的 LogStream 成员变量;
LogStream 类封装了一个 FixedBuffer 成员,重载了 operator<< 函数,将各种类型的消息写入 FixedBuffer 中。FixedBuffer 类维护了一块固定大小的 buffer,类似 iostream。
-
前端调用 LOG_XXX 的接口后,将日志写入 FixedBuffer 中,然后需要将 FixedBuffer 中的日志拷贝到由 AsyncLogging 类维护的前端的buffer中,然后执行上文图中所示的前端buffer和后端buffer的流转。
将FixedBuffer类中维护的buffer拷贝到 AsyncLogging 类维护的buffer中,实现的非常巧妙。具体而言,Logger类在析构函数中实现了拷贝操作,如下面的代码片段所示:
Logger::~Logger() { // ... // using Buffer = FixedBuffer; const LogStream::Buffer& buf(stream().buffer()); // 获取FixedBuffer中的buffer,注意,这里返回的引用,不会产生拷贝开销 output(buf.data(), buf.length()); // output 是一个回调函数,是Logger提供给前端程序的接口 }
上面output函数如下所示,
void output(const char* msg, int len) { // ... AsyncLogger_->append(msg, len); // AsyncLogger 为AsyncLogging的实例对象,append函数实现了日志消息写入前端buffer的操作 }
总结一下第1步到第3步的流程:
LOG_INFO << "hello world"; // 1. 调用 LOG_XXX 接口,创建 Logger 临时对象,并调用 stream() 成员方法,获取 Logger类中封装的 LogStream类对象; // LogStream类重载了 operator<< 方法,此外LogStream类中封装了 FixedBuffer 对象, // 重载的 operator<< 方法将日志消息写入 FixedBuffer 封装的固定大小的buffer中, // 2. LOG_INFO << "hello world"; 语句执行完毕,创建的临时对象被销毁,Logger对象销毁前,调用 Logger 的析构函数; // 在Logger的析构函数中,通过回调函数 void output(const char* message, int len); 将日志写入由 AsyncLogging 类维护的前端的buffer中; Logger::~Logger() { const LogStream::Buffer& buf(stream().buffer()); output(buf.data(), buf.length()); } // output 函数实际上是由用户程序注册,调用 Logger 中的static方法 typedef void (*OutputFunc)(const char* msg, int len); static void setOutput(OutputFunc); // 3. output 函数一般为: void output(const char* msg, int len) { AsyncLogger_->append(msg, len); } // AsyncLogger_ 为 AsyncLogging 实例对象 // 4. AsyncLogging::append(const char* msg, int len) 函数 void AsyncLogging::append(const char* logline, int len) { MutexLockGuard lock(mutex_); if (currentBuffer_->avail() > len) currentBuffer_->append(logline, len); else { // ... buffers_.push_back(currentBuffer_); // ... } }
**思考题1:**LogStream中的FixedBuffer是必须的吗?可以直接写入由 AsyncLogging 类维护的前端的buffer中吗?
**思考题2:**每写一条日志消息都要创建一个Logger对象,写完后又立即销毁,对日志系统的性能影响大吗?
-
日志消息写入由 AsyncLogging 类维护的前端的buffer中后,当前端buffer被写满 或者 当设定的磁盘刷新时间间隔到达,会唤醒后端线程,将前端buffer写入磁盘。在具体的实现上,muduo采用了四块buffer,前端线程和后端线程各两块,此外,前后端还各自维护了一块buffer数组,下面结合代码说明。
// 摘自 muduo 的 AsyncLogging.cc 中的源码 void AsyncLogging::append(const char* logline, int len) { MutexLockGuard lock(mutex_); // AsyncLogging通过一个mutex全局锁来实现前端buffer的访问 // 前端两块buffer,currentBuffer_ 和 nextBuffer_ if (currentBuffer_->avail() > len) // currentBuffer_ 未写满,继续写 currentBuffer_->append(logline, len); else { // currentBuffer_ 写满,需要唤醒后端线程进行写磁盘操作 buffers_.push_back(currentBuffer_); // 将currentBuffer_ 添加到前端维护的 buffers_ 数组中 currentBuffer_.reset(); // 这里的 currentBuffer_ 由智能指针实现 if (nextBuffer_) currentBuffer_ = std::move(nextBuffer_); // nextBuffer_ 不为空,将nextBuffer_移动赋值给currentBuffer_ else // 这里的else分支很少会被执行,因为大多数情况下,nextBuffer_ 不会被写满; // 这里需要结合后端的执行流来理解 currentBuffer_.reset(new Buffer); currentBuffer_->append(logline, len); cond_.notify(); // 唤醒后端线程 } }
这里先给出后端执行流的关键代码和必要注释,需要结合上面的前端执行流的代码对注释进行理解;
// 真正执行日志写磁盘的后端线程执行的函数 void AsyncLogging::threadFunc() { // ... LogFile output(basename_); // LogFile封装了实际的写文件操作 // 后端的两块buffer和一块buffer数组 // 一个细节:前端的buffer(在AsyncLogging的构造函数中初始化)和后端buffer在程序启动的时候全部填充为0, // 避免程序热身时 page fault 引发的性能不稳定 BufferPtr newBuffer1(new Buffer); BufferPtr newBuffer2(new Buffer); newBuffer1->bzero(); newBuffer2->bzero(); BufferVector buffersToWrite; buffersToWrite.reserve(16); while (running_) { // ... { MutexLockGuard lock(mutex_); if (buffers_.empty()) // unusual usage! { // 理解这一步对后端线程异步写日志很关键 // 若buffer_为空,说明前端线程的 currentBuffer_ 还未写满, // 结合 AsyncLogging::append 函数中的 if (currentBuffer_->avail() > len) 条件判断进行思考 // 通过条件变量 cond_ 阻塞该部分,最多等待 flushInterval_ 秒,该部分就会解除阻塞; cond_.waitForSeconds(flushInterval_); // 结合AsyncLogging::append函数中的 cond_.notify(); 来思考后端线程被唤醒的两个条件: // 1.前端buffer被写满 或者 2.当设定的磁盘刷新时间间隔到达 // 前端的 currentBuffer_ 为写满,buffer_ 就为空,因此进入if语句,cond_.waitForSeconds(flushInterval_); 进行阻塞; // 要么 currentBuffer_ 写满,前端进入else语句,cond_.notify(); 唤醒被阻塞的后端线程; // 要么 flushInterval_ 时间已过,解除阻塞,即使 currentBuffer_ 没被写满,也会被后端线程刷新到磁盘中; } // 前后端buffer的交换操作,此时已经离开了临界区,因此后端的写磁盘不会阻塞前端的写日志 buffers_.push_back(currentBuffer_); currentBuffer_.reset(); currentBuffer_ = std::move(newBuffer1); buffersToWrite.swap(buffers_); if (!nextBuffer_) { nextBuffer_ = std::move(newBuffer2); } } assert(!buffersToWrite.empty()); if (buffersToWrite.size() > 25) { // 一种保护措施,若buffersToWrite.size() > 25,说明在一个时间小片段中,产生了大量的日志消息,很可能是系统存在攻击或故障 // 25,应该是经验值? buffersToWrite.erase(buffersToWrite.begin() + 2, buffersToWrite.end()); } // 写入文件 for (size_t i = 0; i < buffersToWrite.size(); ++i) { output.append(buffersToWrite[i]->data(), buffersToWrite[i]->length()); } if (buffersToWrite.size() > 2) { // drop non-bzero-ed buffers, avoid trashing buffersToWrite.resize(2); } // 恢复后端线程的buffer if (!newBuffer1) { assert(!buffersToWrite.empty()); newBuffer1 = buffersToWrite.back(); buffersToWrite.pop_back(); newBuffer1->reset(); } if (!newBuffer2) { // 同上面的 newBuffer1 操作 } buffersToWrite.clear(); output.flush(); // 刷新至磁盘 } output.flush(); }
其他的一些细节
陈硕老师在书中对日志系统这部分已经阐述的非常详尽,一些细节就不再展开。不得不佩服陈硕老师的写作水平,通俗易懂且没有一丝拖沓。