muduo中的日志是指诊断日志,即通常用于故障诊断和追踪的日志,便于服务器发生故障时的线索追踪,是网络库中很重要的一个部分。
在总结异步日志之前,首先应该清楚什么是异步日志?与同步日志又有什么区别?
同步日志与异步日志
同步日志:网络IO线程或业务线程直接向磁盘文件中写日志信息,只有等一条日志消息写完之后才能执行后续的程序。同步日志容易阻塞在磁盘IO上,效率较低且影响服务器性能,应尽量避免在服务器中多次使用磁盘IO。
异步日志:网络IO线程或业务线程产生日志消息时,用一个缓冲区储存起来,等到合适的时机,用一个后台线程统一将日志消息写入磁盘文件中。异步日志避免了在网络IO线程或业务线程中阻塞在磁盘IO中,因此也称为非阻塞日志。
看了同步日志和异步日志的概念之后,使用异步日志的原因也很清晰了。异步日志避免了网络IO线程和业务线程中的磁盘IO,只在后台线程中使用了一次磁盘IO,大大提升了服务器的响应性能和日志信息的处理效率。
muduo日志库的实现思想
muduo日志库分为前端和后端两个部分,前端负责将生成的日志消息储存到缓冲区中,后端负责将缓冲区的日志消息写入到磁盘文件中。
为了实现前端、后端的异步操作,同时避免前端每次生成日志消息都唤醒后端线程,从而提高日志处理效率,muduo日志库采用的是双缓冲技术,其实现思想为:准备两块缓冲区(记为buffer A、buffer B),前端负责往buffer A中写日志消息,后端负责将buffer B中的日志消息写入磁盘文件。当buffer A写满之后,后端线程中会交换buffer A和buffer B,让前端往buffer B中写入日志消息,后端将buffer A中的日志消息写到磁盘文件中,如此往复。同时,为了及时将生成的日志消息写入文件,便于管理者分析日志消息,即使buffer A未满,日志库也会每3秒执行交换写入操作。
总结,muduo日志库的优点为:避免了前端每生成一条日志消息就传送给后端,而是将多条日志消息拼成一个大buffer传送给后端线程,相当于批量处理,减少了后端线程的唤醒频率,降低了服务器开销。
muduo日志库关键代码分析
muduo实现异步日志的类是AsyncLogging.h,声明的相关变量如下:
typedef muduo::FixedBuffer<LargeBuffer> Buffer; //大小为4MB的缓冲区
typedef std::unique_ptr<Buffer> BufferPtr; //缓冲区指针
typedef std::vector<BufferPtr> BufferVector;
muduo::MutexLock mutex_; //互斥锁,用于保证前端、后端的线程安全
muduo::Condition cond_; //条件变量
BufferPtr currentBuffer_; //当前缓冲指针
BufferPtr nextBuffer_; //预备缓冲指针
BufferVector buffers_; //储存已填满的缓冲,并移交给后端
前端的关键实现代码如下:
void AsyncLogging::append(const char* logline, int len)//向当前缓冲区中添加日志消息,如果当前缓冲区放不下了,那么就把当前缓冲区放到前端缓冲区队列中
{
muduo::MutexLockGuard lock(mutex_);//用锁来保持同步
if (currentBuffer_->avail() > len)//如果当前缓冲区还能放下当前日志消息
{
currentBuffer_->append(logline, len);//把日志消息添加到当前缓冲区中
}
else//如果放不下,就把当前缓冲区移动到前端缓冲区队列中,然后用预备缓冲区来填充当前缓冲区
{ //将当前缓冲区放到前端缓冲区队列中后就要唤醒后端处理线程
buffers_.push_back(std::move(currentBuffer_));
if (nextBuffer_)//如果预备缓冲区还未使用,就用来填充当前缓冲区
{
currentBuffer_ = std::move(nextBuffer_);
}
else//如果预备缓冲区无法使用,就重新分配一个新的缓冲区(如果日志写的速度很快,但是IO速度很慢,那么前端日志缓冲区就会积 //累,但是后端还没有来得及处理,此时预备缓冲区也还没有归还,就会产生这种情况
{
currentBuffer_.reset(new Buffer); // 很少发生
}
currentBuffer_->append(logline, len);//向新的当前缓冲区中写入日志消息
cond_.notify(); //唤醒后台线程
}
前端实现的具体过程可描述为:网络IO线程或业务线程生成一条日志消息时,若当前缓冲currentBuffer_中剩余的空间大于日志消息的字节长度,就会把日志消息拷贝到当前缓冲中;否则,说明当前缓冲区已经写满,就把currentBuffer_移动到buffers_中,此时currentBuffer_=NULL,此后判断预备缓冲nextBuffer_是否为NULL。若currentBuffer_尚未使用,就把预备缓冲nextBuffer_移用为当前缓冲,然后继续向当前缓冲中追加日志消息并唤醒后端线程将缓冲中的日志消息写到磁盘文件中;若currentBuffer_=NULL,说明nextBuffer_已经被使用了,这时就需要重新分配一块缓冲区作为当前缓冲currentBuffer_,然后继续向当前缓冲中追加日志消息并唤醒后端线程将缓冲中的日志消息写到磁盘文件中(这种情况较少,只有在日志消息生成速度太快后端来不及写入日志消息时发生)。
其中,buffers_为双缓冲技术中的buffer A,具体实现时buffers_根据日志消息的生成速度可调节大小,实现比较灵活。
后端的关键实现代码为:
void AsyncLogging::threadFunc()
{
LogFile output(basename_, rollSize_, false); //指定输出的日志文件
BufferPtr newBuffer1(new Buffer);//用来填充移动后的currentBuffer_
BufferPtr newBuffer2(new Buffer);//用来填充使用后的nextBuffer_
newBuffer1->bzero(); //缓冲区清零
newBuffer2->bzero(); //缓冲区清零
BufferVector buffersToWrite;//后端缓冲区队列,初始大小为16
buffersToWrite.reserve(16);
while (running_)
{
{
muduo::MutexLockGuard lock(mutex_);
if (buffers_.empty()) // 如果前端缓冲区队列为空,就休眠flushInterval_的时间
{
cond_.waitForSeconds(flushInterval_);
}
buffers_.push_back(std::move(currentBuffer_));
currentBuffer_ = std::move(newBuffer1); //当前缓冲区获取新的内存
buffersToWrite.swap(buffers_); //前端缓冲区队列与后端缓冲区队列交换
if (!nextBuffer_) //如果预备缓冲区为空,那么就使用newBuffer2作为预备缓冲区,保证始终有一个空闲的缓冲区用于预备
{
nextBuffer_ = std::move(newBuffer2);
}
}
//如果最终后端缓冲区的缓冲区太多就只保留前三个
if (buffersToWrite.size() > 25)
{
char buf[256];//buf作为缓冲区太多时的错误提示字符串
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)));//将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)//如果newBuffer1为空 (刚才用来替代当前缓冲了)
{
newBuffer1 = std::move(buffersToWrite.back()); //把后端缓冲区的最后一个作为newBuffer1
buffersToWrite.pop_back(); //最后一个元素的拥有权已经转移到了newBuffer1中,因此删除最后一个元素
newBuffer1->reset(); //重置newBuffer1为空闲状态
}
if (!newBuffer2)//如果newBuffer2为空
{
newBuffer2 = std::move(buffersToWrite.back());
buffersToWrite.pop_back();
newBuffer2->reset();
}
buffersToWrite.clear();//清空后端缓冲区队列
output.flush();//清空文件缓冲区
}
output.flush();
}
后端实现的具体过程为:后端线程会准备两块临时缓冲buffer,然后执行一个循环。循环中首先判断buffers_是否为空,若为空就休眠flushInterval_秒再唤醒后端线程,休眠过程中,若前端中buffers_不为空,后端线程也会被唤醒。后端线程唤醒后,将前端的当前缓冲currentBuffer_中的数据追加到buffers_,并立即将空闲的newBuffer1移为当前缓冲。紧接着,交换buffers_与buffersToWrite的资源,并用newBuffer2替换nextBuffer_(保证前端始终有一个可调配的预备buffer)。以上步骤涉及到前端与后端的数据交互,为保证数据同步,需要加锁,使其处于临界区。
临界区的数据交互完成之后,就可以将buffersToWrite中的日志数据写入磁盘文件。写日志消息时,若buffersToWrite过大,只保留前三个缓冲区的日志数据,从而避免日志堆积。
日志数据写入完成以后,重置buffersToWrite的大小为2,并重新用于填充newBuffer1和newBuffer2,从而实现了现有资源的有效利用,避免了重新开辟内存资源,有利于提高服务器性能。
最后,清空后端缓冲队列buffersToWrite,并flush缓冲区,从而促使缓冲区的日志数据全部写入到磁盘文件中。
其中,buffersToWrite为双缓冲技术中的buffer B。
总结
muduo异步日志库的实现,有很多细节都体现了高性能服务器开发过程中如何提高服务器性能,读者可以从中多多学习。比如:
一、互斥锁所持有临界区很小,临界区内只完成前端、后端日志数据的交互,然后前端继续向前端缓冲区写日志消息,后端也立即向文件中写数据。
二、后端日志数据写完之后,会利用buffersToWrite中现有的内存空间来重新填充newBuffer1和newBuffer2,而不是重新开辟一块内存,这实际上也有利于节省内存开销,尽量减小日志消息给服务器性能造成的影响。