Muduo日志库

本文是笔者对Muduo多线程网络库日志模块的学习总结,文中大量引用了陈硕所著的《Linux多线程服务端编程:使用muduo C++网络库》以及 luotuo44《muduo日志库学习》博客中的内容。此外,本文代码取自 analogous_love 的开源项目flamingo,笔者在此一并表示感谢。

 

日志是服务器编程中必不可少的一个功能模块,常用于服务器的故障诊断及追踪。

Muduo日志库分为前端和后端两个部分,前端负责生成日志消息,后端则负责将日志消息写入本地日志文件,前后端之间只有一个简单的回调函数作为接口:

void output(const char *msg, int len);

其中msg是一条完整的日志消息,包含时间戳、线程号、日志级别、日志内容和位置信息五个部分,具体格式如下:

时间戳线程号级别正文源文件:行号
20181112 10:48:43 125770ZINFOshow databases-DatabaseMysql.cpp:93

在多线程网络服务器程序中,各个线程的功能必然有所区分,那怎样将其他非日志线程产生的日志消息高效地传输到后端日志线程中呢。这就要求设计一个高效的日志库,它对外提供一个统一的接口(Muduo库中提供的对外接口为LOG_宏),这样其他非日志线程只需对这个接口进行简单的调用就能实现所需的日志功能了。

可见,这是一个典型的多生产者-单消费者的问题,对生产者(前端)而言,要尽量做到低延迟、低CPU开销、无阻塞;对消费者(后端)而言,要做到足够大的吞吐量,并尽量少的占用资源。

 

 

基本日志功能实现

日志库前端部分的调用关系为:Logger   Impl  >  LogStream  >  operator<<FixBuffer   >  g_output     AsyncLogging:: append

Muduo日志库采用C++风格,通过在Logging.h中定义一系列的宏(如LOG_TRACE,LOG_DEBUG)实现了基本的日志功能,如下所示。

#define LOG_TRACE if (Logger::logLevel() <= Logger::TRACE) \
	Logger(__FILE__, __LINE__, Logger::TRACE, __func__).stream()
#define LOG_DEBUG if (Logger::logLevel() <= Logger::DEBUG) \
	Logger(__FILE__, __LINE__, Logger::DEBUG, __func__).stream()
#define LOG_INFO if (Logger::logLevel() <= Logger::INFO) \
	Logger(__FILE__, __LINE__).stream()
#define LOG_WARN Logger(__FILE__, __LINE__, Logger::WARN).stream()
#define LOG_ERROR Logger(__FILE__, __LINE__, Logger::ERROR).stream()
#define LOG_FATAL Logger(__FILE__, __LINE__, Logger::FATAL).stream()
#define LOG_SYSERR Logger(__FILE__, __LINE__, false).stream()
#define LOG_SYSFATAL Logger(__FILE__, __LINE__, true).stream()
#define LOG_DEBUG_BIN(x,l) if (Logger::logLevel() <= Logger::DEBUG) \
	Logger(__FILE__, __LINE__, Logger::DEBUG, __func__).WriteLog((x), (l))

通过观察LOG_宏定义,我们发现:

LOG_ERROR << "chatserver initialization failed.";

等价于:

Logger(__FILE__, __LINE__, Logger::ERROR).stream() << "chatserver initialization failed.";

也就是说,LOG_宏会创建一个匿名Logger对象(其中包含一个Impl类型的成员变量,Impl对象构造时会添加时间戳信息),并调用stream()函数得到一个LogStream对象的引用,而LogStream重载了<<操作符,可以将日志信息写入LogStream的buffer中。这样LOG_语句执行结束时,匿名Logger对象被销毁,Logger的析构函数会在日志消息的末尾添加LOG_语句的位置信息(文件名和行号),最后调用output()函数将日志信息传输到后端,并由后端日志线程将日志消息写入本地日志文件。

Logger::~Logger()
{
	impl_.finish();		// 在日志末尾添加所在文件和行号信息
	const LogStream::Buffer& buf(stream().buffer());
	output(buf.data(), buf.length());		// 将日志信息传输到后端,由后端日志线程将日志消息写入日志文件

	if(impl_.level_ == FATAL) {
		g_flush();
		abort();
	}
}

强调一下,这里将Logger设置为匿名对象是一个非常重要的技巧,因为匿名对象是一使用完就马上销毁,而对于栈上的具名对象则是先创建的后销毁。也就是说,如果使用具名对象,则后创建的Logger对象会先于先创建的Logger对象销毁,这就会使得日志内容反序(更准确的说是一个作用域中的日志反序)。使用匿名Logger对象的效果就是:LOG_*这行代码不仅仅会生成日志,还会马上输出(并不一定会立即写到本地日志文件中,具体原因见多线程异步日志部分)。

AsyncLogging::append留到下一部分中进行讲解。

到这里,基本的日志功能已经实现了(只实现日志消息的生成,但还没有将其传输到后端),但这还不是异步的。

 

多线程异步日志

与单线程程序不同,多线程程序对日志库提出了新的要求——线程安全,即多个线程可以并发的写日志而不会出现混乱。简单的线程安全并不难办到,用一个全局的mutex对日志的IO进行保护或是每个线程单独写一个日志文件就可以办到。但前者会造成多个线程争夺mutex,后者则可能使得业务线程阻塞在磁盘操作上。

其实可行的解决方案在文章开头(标红字段)就已经给出了,即用一个背景线程负责收集日志消息并写入本地日志文件,其他线程只负责生成日志消息并将其传输到日志线程,这称为“异步日志”。

在多线程服务程序中,异步日志(叫“非阻塞日志”似乎更准确些)是必须的,因为如果在网络IO或业务线程中直接往磁盘上写数据的话,写操作可能由于某种原因阻塞长达数秒之久。这可能使得请求方超时,或是耽误心跳消息的发送,在分布式系统中更可能造成多米诺骨牌效应,例如误报死锁引发failover(故障转移)。因此,在正常的实时业务处理流程中应该彻底避免磁盘IO。

 

下面接着讲解上面剩余的AsyncLogging::append部分,也就是前端如何将日志消息发送到后端。

Muduo日志库采用了双缓冲技术,即预先设置两个buffer(currentBuffer_和nextBuffer_),前端负责往currentBuffer_中写入日志消息,后端负责将其写入日志文件中。具体来说,当currentBuffer_写满时,先将currentBuffer_中的日志消息存入buffers_,再交换currentBuffer_和nextBuffer_(std::move),这样前端就可以继续往currentBuffer_中写入新的日志消息,最后再调用notify_all()通知后端日志线程将日志消息写入本地日志文件。

使用两个buffer的好处是在新建日志消息时不必等待磁盘文件操作,也避免了每条新的日志消息都触发后端日志线程。换句话说,前端不是将一条条日志消息分别发送给后端,而是将多条日志消息合成为一个大的buffer再发送给后端,相当于批处理,这样就减少了日志线程被触发的次数,降低了开销。

为了更加深入的理解前端是如何将日志消息发送到后端的,我们来看一下AsyncLogging类定义的数据结构:

typedef FixedBuffer<kLargeBuffer> Buffer;
typedef shared_ptr<Buffer> BufferPtr;
typedef vector<BufferPtr> BufferVector;

mutex mutex_;
condition_variable cond_;
BufferPtr currentBuffer_;     // 当前缓冲
BufferPtr nextBuffer_;        // 预备缓冲
BufferVector buffers_;        // 待写入文件的已填满的缓冲(供后端写入的buffer)

日志消息发送的具体代码如下所示:

void AsyncLogging::append(const char* logline, int len)
{
       std::unique_lock<std::mutex> lock(mutex_);
       if(currentBuffer_->avail() > len) {
              currentBuffer_->append(logline, len);
       } else {
              buffers_.push_back(currentBuffer_);
              if(nextBuffer_) {
                     currentBuffer_ = std::move(nextBuffer_);
              } else {
                     currentBuffer_.reset(new Buffer);     // Rarely happens
              }
              currentBuffer_->append(logline, len);
              cond_.notify_all();
       }
}

前端在生成一条日志消息的时候会调用AsyncLogging::append()。在这个函数中,如果currentBuffer_剩余的空间足够容纳这条日志消息,则会直接将其拷贝到currentBuffer_中,否则说明currentBuffer_已经写满,因此将currentBuffer_移入buffers_,并将nextBuffer_移用为currentBuffer_,再向currentBuffer_中写入日志信息。一种特殊的情况是前端日志信息写入速度过快,一下子把currentBuffer_和nextBuffer_都用光了,那么只好分配一块新的currentBuffer_作为当前缓存。

 

可以看到前端线程把两个buffer都用掉了,那后端线程是怎么归还这个两个buffer的呢?下面给出后端日志线程最关键的临界区中的代码:

void AsyncLogging::threadFunc()
{
	// ...
	LogFile output(basename_, rollSize_, false);	// 直接进行IO的日志文件

	BufferPtr newBuffer1(new Buffer);		// 后台线程buffer
	BufferPtr newBuffer2(new Buffer);
	BufferVector buffersToWrite;		// 用来和前台线程的buffers_进行swap

	while(running_) {
		assert(newBuffer1 && newBuffer1->length() == 0);
		assert(newBuffer2 && newBuffer2->length() == 0);
		assert(buffersToWrite.empty());

		{
			std::unique_lock<mutex> lock(mutex_);
			if(buffers_.empty()) {		// unusual usage! 非常规的condition variable用法
				cond_.wait_for(lock, chrono::seconds(flushInterval_));    //睡眠的时间是日志库flush时间
			}
			buffers_.push_back(currentBuffer_);
			/* 
            无论cond是因何种原因醒来,都要将currentBuffer_放到buffers_中。如果是因为时间
            到而醒,那么就算currentBuffer_还没写满,此时也要将它写入LogFile中。如果前台
            currentBuffer_已经满了,那么在前台线程中就已经把一个前台currentBuffer_放到
            buffers_中了。此时,还是需要把currentBuffer_放到buffers_中(注意,前后放置是不同            
            的buffer,因为在前台线程中,currentBuffer_已经被换成nextBuffer_指向的buffer了) 
            */

			currentBuffer_ = std::move(newBuffer1);	 // 归还currentBuffer_
			buffersToWrite.swap(buffers_);
			if(!nextBuffer_) {		// 如果需要,归还nextBuffer_
				nextBuffer_ = std::move(newBuffer2);
			}
		}

		assert(!buffersToWrite.empty());
        
        // ...

		// 将已经满了的buffer写入到LogFile中。由LogFile进行IO操作。
		for(size_t i = 0; i < buffersToWrite.size(); ++i) {
			// FIXME: use unbuffered stdio FILE ? or use ::writev ?
			output.append(buffersToWrite[i]->data(), buffersToWrite[i]->length());
		}

		// ...

		buffersToWrite.clear();
		output.flush();
	}
	
	output.flush();
}

后端日志线程首先准备两块空闲的buffer,以备在临界区内交换。在临界区内,等待条件触发,这里的条件有两个,其一是超时,其二是前端写满了currentBuffer_。注意这里是非常规的condition variable用法,它没有使用while循环,且等待时间有上限。

当条件满足时,先将currentBuffer_移入buffers_,并立即将空闲的newBuffer1移为currentBuffer_。注意这段代码位于临界区内,因此不会有任何的race condition。接下来交换bufferToWrite和buffers_,这就完成了将记录了日志消息的buffer从前端到后端的传输,后端日志线程慢慢进行IO即可。临界区最后干的一件事就是将空闲的newBuffer2移为nextBuffer_,这样就实现了前端两个buffer的归还。

至此,一个完整的多线程异步日志库便完成了。

 

日志文件rolling

以本地文件作为日志的destination,那么日志文件的滚动是必须的。Muduo库中日志文件滚动的条件有两个:其一是文件大小(每写满1GB新建一个日志文件),其二是时间(每隔一天新建一个日志文件)。

void LogFile::append_unlocked(const char* logline, int len)
{
	file_->append(logline, len);

	if(file_->writtenBytes() > rollSize_) {		// 日志文件写满1GB则创建新的日志文件
		rollFile();
	} else {
		++count_;
		if(count_ >= checkEveryN_) {
			count_ = 0;
			time_t now = ::time(NULL);
			time_t thisPeriod_ = now / kRollPerSeconds_ * kRollPerSeconds_;
			if(thisPeriod_ != startOfPeriod_) {	// 不同日期建立不同的日志文件,不论之前的日志文件是否写满
				rollFile();
			} else if(now - lastFlush_ > flushInterval_) {
				lastFlush_ = now;
				file_->flush();
			}
		}
	}
}

然而,从以上代码可以看出,日志文件也不是严格按时间滚动的,实际应用的时候还添加了一个计数用的count_,当count_不小于checkEveryN_,且时间相隔不小于一天时,日志文件才会滚动。

 

日志库学习(一)

日志库学习(二)

服务器编程心得(五)—— 如何编写高性能日志

 

 

 

 

 

 

 

 

 

  • 7
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值