本文是笔者通过学习Muduo多线程网络程序开发库日志模块所做出的总结,文中大量引用了陈硕所著的《Linux多线程服务端编程:使用muduo C++网络库》以及luotuo44《muduo日志库学习》博客中的内容。此外,本文代码取自analogous_love的开源项目flamingo,笔者在此一并表示感谢。
由于笔者接触多线程开发时间较短,文中所书难免有误,还请多多包涵~
日志是服务器端编程中必不可少的一个功能模块,通常被用于服务器的故障诊断和追踪或性能分析。
Muduo日志库分为前端和后端两个部分,前端用于生成日志消息并传送到后端,后端则负责将日志消息写入本地日志文件。日志系统的前后端之间只有一个简单的回调函数作为接口:
void asyncOutput(const char *msg, int len);
其中msg是一条完整的日志消息,包含时间戳、线程id、日志级别、日志内容和位置信息五个部分(flamingo中去掉了线程id),具体格式如下:
时间戳 | 线程号 | 级别 | 正文 | 源文件:行号 |
20181112 10:48:43 | 125770Z | INFO | show databases | -DatabaseMysql.cpp:93 |
在多线程网络服务器程序中,各个线程的功能必然有所区分,那怎样将其他非日志线程产生的日志消息高效地传输到后端日志线程中呢。这就要求设计一个高效的日志库,它对外提供一个统一的接口(Muduo库中提供的对外接口为LOG宏),这样其他非日志线程只需对这个接口进行简单的调用就能实现所需的日志功能。
这是一个典型的多生产者-单消费者的问题,对生产者(前端)而言,要尽量做到低延迟、低CPU开销、无阻塞;对消费者(后端)而言,要做到足够大的吞吐量,并尽量占用较少的资源。
日志模块前端部分的调用时序为:Logger => Impl => LogStream => operator<
Muduo日志库使用C++ stream<<的风格,通过在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类型的成员变量),并调用stream()函数得到一个LogStream对象的引用,而LogStream重载了<<操作符,可以将日志信息存入LogStream的buffer中。这样LOG_语句执行结束时,匿名Logger对象被销毁,在Logger的析构函数中,会在日志消息的末尾添加LOG_语句的位置信息(文件名和行号),最后调用g_output()函数将日志信息传输到后端,由后端日志线程将日志消息写入日志文件。
Logger::~Logger()
{
impl_.finish(); // 在日志末尾添加所在文件和行号信息
const LogStream::Buffer& buf(stream().buffer());
g_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 Buffer;
typedef shared_ptr BufferPtr;
typedef vector BufferVector;
mutex mutex_;
condition_variable cond_;
BufferPtr currentBuffer_; // 当前缓冲
BufferPtr nextBuffer_; // 预备缓冲
BufferVector buffers_; // 待写入文件的已填满的缓冲(供后端写入的buffer)
日志消息发送的具体代码如下所示:
void AsyncLogging::append(const char* logline, int len)
{
std::unique_lock 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 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的归还。
至此,一个完整的多线程异步日志便完成了。
以本地文件作为日志的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_,且时间相隔不小于一天时,日志文件才会滚动。
日志库学习(一)
日志库学习(二)
服务器编程心得(五)—— 如何编写高性能日志