转发原博:
大佬博客
上一篇文章讲到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。
void AsyncLogging::append(const char* logline, int len)
{
muduo::MutexLockGuard lock(mutex_);
if (currentBuffer_->avail() > len)
{
currentBuffer_->append(logline, len);
}
else // 生产者线程生产太快,用nextBuffer填充currentBuffer,则nextBuffer为nullptr
{
buffers_.push_back(std::move(currentBuffer_));
if (nextBuffer_)
{
currentBuffer_ = std::move(nextBuffer_);
}
else
{
currentBuffer_.reset(new Buffer); // Rarely happens
}
currentBuffer_->append(logline, len);
cond_.notify(); // 通知消费者线程
}
}
前台线程是生产者,前台线程有2快buffer,一块是currentbuffer另外一个是nextbuffer,具体情况看代码注释。
可以看到前台线程所做的工作比较简单,如果currentBuffer_够用,就把日志内容写入到currentBuffer_中,如果不够用(就认为其满了),就把currentBuffer_放到已满buffer数组中,等待消费者线程(即后台线程)来取。并且把currentBuffer_指向nextBuffer_的buffer。
可以看到前台线程把两个前台buffer用掉了,下面可以看看后台线程是怎么归还这个两个buffer的。
在AsyncLogging类中还有下面的声明:
typedef boost::ptr_vector<Buffer> BufferVector;
typedef BufferVector::auto_type BufferPtr;
底下是消费者线程的代码:
void AsyncLogging::threadFunc()
{
assert(running_ == true);
latch_.countDown();
LogFile output(basename_, rollSize_, false);
BufferPtr newBuffer1(new Buffer);
BufferPtr newBuffer2(new Buffer);
newBuffer1->bzero();
newBuffer2->bzero();
BufferVector buffersToWrite;
buffersToWrite.reserve(16);
while (running_)
{
assert(newBuffer1 && newBuffer1->length() == 0);
assert(newBuffer2 && newBuffer2->length() == 0);
assert(buffersToWrite.empty());
{
muduo::MutexLockGuard lock(mutex_);
if (buffers_.empty()) // unusual usage!
{
cond_.waitForSeconds(flushInterval_);
}
buffers_.push_back(std::move(currentBuffer_));
currentBuffer_ = std::move(newBuffer1);
buffersToWrite.swap(buffers_);
if (!nextBuffer_) // 其实就是前段线程写日志太快,所以用完了第一块buffer,那么将第二块buffer移为currentBuffer
{
nextBuffer_ = std::move(newBuffer2);
}
}
assert(!buffersToWrite.empty());
if (buffersToWrite.size() > 25) //日志太多,丢弃
{
char buf[256];
snprintf(buf, sizeof buf, "Dropped log messages at %s, %zd larger buffers\n",
Timestamp::now().toFormattedString().c_str(),
buffersToWrite.size()-2);
fputs(buf, stderr);
output.append(buf, static_cast<int>(strlen(buf)));
buffersToWrite.erase(buffersToWrite.begin()+2, buffersToWrite.end());
}
for (const auto& buffer : buffersToWrite)
{
// FIXME: use unbuffered stdio FILE ? or use ::writev ?
output.append(buffer->data(), buffer->length());
}
if (buffersToWrite.size() > 2)
{
// drop non-bzero-ed buffers, avoid trashing
buffersToWrite.resize(2);
}
if (!newBuffer1) //将其归还
{
assert(!buffersToWrite.empty());
newBuffer1 = std::move(buffersToWrite.back());
buffersToWrite.pop_back();
newBuffer1->reset();
}
if (!newBuffer2) // 将buffer归还
{
assert(!buffersToWrite.empty());
newBuffer2 = std::move(buffersToWrite.back());
buffersToWrite.pop_back();
newBuffer2->reset();
}
buffersToWrite.clear();
output.flush(); //fflush()会强迫将缓冲区内的数据写回参数stream 指定的文件中
}
output.flush();
}
首先我们得明白
cond_.waitForSeconds(flushInterval_);
pthread_cond_timedwait(&pcond_, mutex_.getPthreadMutex(), &abstime)
我们嘚知道第二个函数是怎么执行的,其实它实现了两个功能
第一:如果在3s内,生产者生产日志过快,调用了notify,则次函数不用等待3s,而是直接返回。
第二:如果3s内,没有被唤醒,则会唤醒,返回。
无论是哪种情况,都还会有一个currentBuffer_前台buffer正在使用。将这个currentBuffer_放到已满buffers_数组中。这样buffers_就有了待进行IO的buffer了。
将bufferToWrite和buffers_进行swap。这就完成了将写了日志记录的buffer从前台线程到后台线程的转变。后台线程慢慢进行IO即可。这个指针的交换很重要,完成了异步的过程,因为buffer给前端,bufferToWrite给后端去写日志
这个过程就完成了日志的异步输出。下面说一下后台线程怎么进行具体的IO的。这个过程将涉及到另外一个类 LogFile。
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_;
};
首先的知道一个系统调用函数,flush()
是flush日志内容到磁盘。
muduo里面的自动flush还是比较容易看懂的。在前面贴出的后台线程中的最后两条语句都是output.flush(); 其中,倒数第二句就是自动flush了。后台线程每次休眠的时间都是flushInterval_,即刷新间隔。无论后台线程是被前台线程唤醒还是超时醒来,其的醒来都是在flushInterval_时间内醒来。醒来后一路执行下去,总会执行到output.flush(); flush日志内容到磁盘。
自动Flush:
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点滚动日志文件。
最后别人问了一个问题?
你好, 我看了muduo的log库,有一点不明白: 如果程序中间crash, 最近flushInterval_(三秒)内的log丢失。应该咋找回来? 书上说每条log带有cookie, 在哪里体现了啊? void FixedBuffer<SIZE>::cookieStart() 这个是一个空函数啊… 如何理解啊?
答:这个函数的地址记录在coredump文件了。函数地址本身就是一个标志,用于日后查找的。