上一篇文章讲到muduo日志库的基础部分,现在来讲muduo日志库的异步日志工作流程。除了异步,muduo日志库还具有自动把数据从FILE结构体缓冲区flush到硬盘功能和定期roll(回滚)日志文件的功能。
异步日志由LogFile{.h, .cc}、AsyncLogging{.h, .cc}中定义的类来配合工作的。主要是有两个类 LogFile 和AsyncLogging。在使用异步日志前,要更改Logger的默认输出函数,使得Logger的默认输出函数向AsyncLogging输入日志内容(具体的修改可参考上一篇文章)。
LogFile类和AsyncLogging类各有自己的buffer(在下文中,分别记为file_buffer和async_buffer)。
当用户使用LOG_*写入日志内容时,将会把日志内容写入到async_buffer中,当async_buffer写满了,就会把async_buffer中的内容写入到file_buffer中。在LogFile类中,会自动将file_buffer的内容flush到硬盘。
这是数据流的大致流向,读者先有一个大致的方向。具体是怎么异步的,下面会慢慢细说。
AsyncLogging类有一个线程成员变量,AsyncLogging类的代码由两个线程执行。一个是使用LOG_*进行写日志的线程(更确切地说是使用了LOG_*的所有线程),另外一个就是AsyncLogging类的线程成员变量了,分别把这两个线程称为前台和后台线程,他们也分别是同步问题中的生产者和消费者。一般情况下,前台线程和后台线程各有两个buffer。前面说的async_buffer是前台和后台buffer的统称。
先贴上前台线程代码。代码中用到了currentBuffer_和nextBuffer_两个前台buffer。还有一个buffers_智能指针数组(其类型为boost::ptr_vector<Buffer>),其存放已经满了的前台buffer。
//所有LOG_*最终都会调用append函数。
voidAsyncLogging::append(const char* logline, int len)
{
//可能有多个线程同时调用LOG_*写入日志,所以需要同步。
muduo::MutexLockGuard lock(mutex_);
if ( currentBuffer_->avail() > len) //buffer还够大,可以装下新一条日志内容。
{
currentBuffer_->append(logline, len);
}
else
{
//currentBuffer_已经满了,要将之存放到buffers_中。
buffers_.push_back(currentBuffer_.release());
if (nextBuffer_)
{
currentBuffer_ =boost::ptr_container::move(nextBuffer_);
}
else
{
currentBuffer_.reset(new Buffer); //Rarely happens。先不考虑这种情况。
}
currentBuffer_->append(logline,len);
cond_.notify(); //通知后台线程,已经有一个满了的buffer了。
}
}
可以看到前台线程所做的工作比较简单,如果currentBuffer_够用,就把日志内容写入到currentBuffer_中,如果不够用(就认为其满了),就把currentBuffer_放到已满buffer数组中,等待消费者线程(即后台线程)来取。并且把currentBuffer_指向nextBuffer_的buffer。
可以看到前台线程把两个前台buffer用掉了,下面可以看看后台线程是怎么归还这个两个buffer的。
后台线程代码由于比较长,这里将删除一些和本文无关的代码。
在AsyncLogging类中还有下面的声明:
typedef boost::ptr_vector BufferVector;
typedef BufferVector::auto_type BufferPtr;
voidAsyncLogging::threadFunc()
{
LogFile output(basename_, rollSize_, false);//定义一个直接进行IO的类。
BufferPtr newBuffer1(new Buffer); //这两个是后台线程的buffer
BufferPtr newBuffer2(new Buffer);
BufferVector buffersToWrite; //用来和前台线程的buffers_进行swap.
while (running_)
{
{
muduo::MutexLockGuard lock(mutex_);
if (buffers_.empty()) // unusual usage!
{
cond_.waitForSeconds(flushInterval_);//睡眠的时间是日志库flush时间。
}
//无论cond是因何而醒来,都要将currentBuffer_放到buffers_中。
//如果是因为时间到而醒,那么currentBuffer_还没满,此时也要将之写入LogFile中。
//如果已经有一个前台buffer满了,那么在前台线程中就已经把一个前台buffer放到buffers_中
//了。此时,还是需要把currentBuffer_放到buffers_中(注意,前后放置是不同的buffer,
//因为在前台线程中,currentBuffer_已经被换成nextBuffer_指向的buffer了)
buffers_.push_back(currentBuffer_.release());
currentBuffer_ =boost::ptr_container::move(newBuffer1); /*---归还一个buffer---*/
buffersToWrite.swap(buffers_); //交换
if (!nextBuffer_)
{ //the nextBuffer_ is still free. notuse for swap with currentBuffer_
nextBuffer_ = boost::ptr_container::move(newBuffer2); /*-----假如需要,归还第二个----*/
}
}
//将已经满了的buffer内容写入到LogFile中。由LogFile进行IO操作。
for (size_t i = 0; i reset();
}
//前台buffer是由newBuffer1 2 归还的。现在把buffersToWrite的buffer归还给后台buffer
if (!newBuffer2)
{
newBuffer2 = buffersToWrite.pop_back();
newBuffer2->reset();
}
buffersToWrite.clear();
output.flush(); //flush to drive. less than3 mins a time
}
output.flush();
}
从代码可以看到后台线程的主要如下:
先用线程同步中的条件变量进行睡眠,睡眠时间为日志库的flush时间。所以,当条件变量的条件满足(即前台线程把一个已经满了的buffer放到了buffers_中),或者超时。无论是哪种情况,都还会有一个currentBuffer_前台buffer正在使用。将这个currentBuffer_放到已满buffers_数组中。这样buffers_就有了待进行IO的buffer了。
将bufferToWrite和buffers_进行swap。这就完成了将写了日志记录的buffer从前台线程到后台线程的转变。后台线程慢慢进行IO即可。
这个过程就完成了日志的异步输出。下面说一下后台线程怎么进行具体的IO的。这个过程将涉及到另外一个类 LogFile。
LogFile类有一个私有的嵌套类File,所有的IO都是这个File类来完成。LogFile直接把日志内容作为参数调用File类提供的一个append函数。LogFile更多是进行文件roll。
下面给出File类的具体内容。
class LogFile::File :boost::noncopyable
{
public:
explicit File(const string& filename)
: fp_(::fopen(filename.data(),"ae")),
writtenBytes_(0)
{
//修改FILE结构体默认提供的缓冲区。主要原因可能是因为默认提供的buffer比较小。
::setbuffer(fp_, buffer_, sizeofbuffer_);
}
~File()
{
::fclose(fp_);
}
void append(const char* logline, const size_tlen)
{
//这里的代码是 循环调用write成员函数,直到把logline的所有数据都写入去。
}
writtenBytes_ += len;
}
void flush()
{
::fflush(fp_);
}
size_t writtenBytes() const { returnwrittenBytes_; }
private:
size_t write(const char* logline, size_t len)
{
#undef fwrite_unlocked
//为了快速,使用unlocked(无锁)的fwrite函数。平时我们使用的C语言IO函数,都是线程安全的,
//为了做到线程安全,会在函数的内部加锁。这会拖慢速度。而对于这个类。可以保证,从
//始到终只有一个线程能访问,所以无需进行加锁操作。
return ::fwrite_unlocked(logline, 1, len,fp_);
}
FILE* fp_;
char buffer_[64*1024];
size_t writtenBytes_;
};
muduo里面的自动flush还是比较容易看懂的。在前面贴出的后台线程中的最后两条语句都是output.flush(); 其中,倒数第二句就是自动flush了。后台线程每次休眠的时间都是flushInterval_,即刷新间隔。无论后台线程是被前台线程唤醒还是超时醒来,其的醒来都是在flushInterval_时间内醒来。醒来后一路执行下去,总会执行到output.flush(); flush日志内容到磁盘。
或许有人会疑问:后台线程醒来后,一路执行下去会进入File类的append函数,进行磁盘IO,这是阻塞型的IO,这就有可能在flushInterval_时间内不能执行到output.flush()了。其实,进入append函数进行磁盘IO,不正是我们所说的,想要的flush吗?
在muduo的配套书籍《Linux多线程服务端编程 使用muduo C++网络库》中(p.110)说到,rolling的条件通常有两个:文件大小(例如每写满1GB就换下一个文件)和时间(例如每天零点新建一个日志文件,不论前一个文件有没有写满)。
在muduo中,确实会根据文件大小和时间来主动滚动文件。不过,时间并不是每天的零点。具体是什么时候,是不确定的,其是根据日志的疏密情况来判断的。但还是会在凌晨的第一个小时里滚动文件。
还是贴代码吧。要注意阅读注释部分。
voidLogFile::append_unlocked(const char* logline, int len)
{
file_->append(logline, len); //调用File类的append函数,进行IO
//写入的数据已经超过日志滚动大小了。这里是根据文件大小进行滚动
if(file_->writtenBytes() > rollSize_)
{
rollFile();
}
else //这里根据时间来判断是否到了滚动的时候
{
//在下面的代码中,可以看到并不是根据时间,而是根据count,即次数。
//当count_大于 kCheckTimeRoll_(其值是一个常量1024),时,就认为
//其到了滚动时间了。
if(count_ > kCheckTimeRoll_)
{
count_= 0; //注意,这里已经将count_清零了。
time_tnow = ::time(NULL);
//kRollPerSeconds_ 为常量60*60*24即一天的秒数。
//thisPeriod_的计算结果为now当天的零时,即now向下取一天的整数
time_tthisPeriod_ = (now / kRollPerSeconds_) * kRollPerSeconds_;
//startOfPeriod_是上次滚动那天的零时。
//这一天不是上一次滚动文件的那一天 才会滚动文件。
if(thisPeriod_ != startOfPeriod_)
{
rollFile();
}
else if(now - lastFlush_ > flushInterval_)
{
lastFlush_ = now;
file_->flush(); //刷新文件
}
}
else
{
//计数。每当LogFile的append函数被调用一次,count_才有可能加一。
++count_;
}
}
}
这里可以看到,当LogFile的append函数被调用一次,count_才有可能加一。而append会在什么时候被调用呢?
回到AsyncLogging的threadFunc函数,可以看到,当后台线程从条件变量那里醒来一次就会调用一次LogFile的append函数。
后台线程有两种情况会醒来,一是至少一个前台buffer满了;二是超时。
如果是日志写得比较多,快。那么很容易导致前台buffer满,进而后台线程醒来调用LogFile的append函数。不过在这种情况下,日志文件容易因为超过滚动大小而被滚动。此时,count_也是会被计数的。不过,并不可能导致滚动日志。因为日志写得多而快,很容易导致一天中因文件大小而滚动了很多次日志文件。这会使得条件thisPeriod_ != startOfPeriod_不能被满足,进而不能因时间滚动日志文件。
还有一种情况是后台线程因为超时醒来。这种情况下(日志写得比较少,慢),每次醒来调用LogFile的append函数都会导致count_被加一。默认情况下3秒钟醒来一次,那么要到达kCheckTimeRoll_(即1024)次,需要1024*3秒,大约是51分钟。由于条件 thisPeriod_ != startOfPeriod 的存在,同一天里的计数不算,并且计数清零(一开始的count_=0)。最终的结果是使得一天里最后一个小时的计数才会有效,所以在每天凌晨的第一个小时的某个时刻会进行滚动日志文件。经陈硕提醒,代码中的::time(NULL)函数返回的不是本地时间,而是UTC时间。所以东八区的中国,会在早上8点滚动日志文件。