目录
什么是异步日志
异步日志的实现
前端与后端
前端与后端的交互
资源回收
后端与日志文件
滚动日志
自动flush缓冲区
开启异步日志功能
总结
在前文中分析了日志消息的存储和输出,不过并没有涉及到异步日志,下面就来分析一下异步日志是如何实现的。
在默认的情况下,日志消息都是直接打印到终端屏幕上,但是实际应用中,日志消息都应该写到本地文件,方便记录以及查询。
最简单的方式就是每产生一条日志消息,都将其写到相应的文件中,然而这种方式效率低下,如果很多线程在某一段时间内需要输出大量日志,那么显然日志输出的效率是很低的。之所以效率低,就是因为每条日志消息都需要通过write这类的函数写出到本地磁盘,这就导致频繁调用IO函数,而磁盘操作本身就比较费时,这样一来后面的代码就只能阻塞住,直到前一条日志写出成功。
为了优化上述问题,一个比较好的办法就是:当日志消息积累到一定量的时候再写到磁盘中,这样就可以显著减少IO操作的次数,从而提高效率。
换句话说,当日志消息需要输出时,并不会立即将其写出到磁盘上,而是先把日志消息存储,直到达到”写出时机“才会将存储的日志消息写出到磁盘,这样一来,当日志消息生成时,只需要将其进行存储而不需要写出,后续代码也不会被阻塞,相对于前面的那种阻塞式日志,这种就是非阻塞式日志。
muduo的异步日志核心思想正是如此。当需要输出日志的时候,会先将日志存下来,日志消息存储达到某个阈值时将这些日志消息全部写到磁盘。需要考虑的是,如果日志消息产生比较慢,可能很长一段时间都达不到这个阈值,那就相当于这些日志消息一直无法写出到磁盘,因此,还应当设置一个超时值如3s,每过3s不管日志消息存储量是否达到阈值,都会将已经存储的日志消息写出到磁盘中。即日志写出到磁盘的两个时机:1、日志消息存储量达到写出阈值;2、每过3秒自动将存储的日志消息全部写出。
这种非阻塞式日志也是异步的,因为产生日志的线程只负责产生日志,并不需要去管它产生的这条日志何时写出,写往何处...
muduo中通过AsyncLogging类来实现异步日志。
异步日志分为前端和后端两部分,前端负责存储生成的日志消息,而后端则负责将日志消息写出到磁盘,因此整个异步日志的过程可以看做如下所示:
先来看看前端和后端分别指的是什么。
class AsyncLogging : noncopyable
{
public:
AsyncLogging(const string& basename,
off_t rollSize,
int flushInterval = 3);
~AsyncLogging()
{
if (running_)
{
stop();
}
}
void append(const char* logline, int len);
void start()
{
running_ = true;
thread_.start();
latch_.wait(); //等待,直到异步日志线程启动,才能继续往下执行
}
void stop() NO_THREAD_SAFETY_ANALYSIS
{
running_ = false;
cond_.notify();
thread_.join();
}
private:
void threadFunc();
typedef muduo::detail::FixedBuffer Buffer;
typedef std::vector> BufferVector;
typedef BufferVector::value_type BufferPtr;
const int flushInterval_; //前端缓冲区定期向后端写入的时间(冲刷间隔)
std::atomic running_; //标识线程函数是否正在运行
const string basename_; //
const off_t rollSize_;
muduo::Thread thread_;
muduo::CountDownLatch latch_;
muduo::MutexLock mutex_;
muduo::Condition cond_ GUARDED_BY(mutex_); //条件变量,主要用于前端缓冲区队列中没有数据时的休眠和唤醒
BufferPtr currentBuffer_ GUARDED_BY(mutex_); //当前缓冲区 4M大小
BufferPtr nextBuffer_ GUARDED_BY(mutex_); //预备缓冲区,主要是在调用append向当前缓冲添加日志消息时,如果当前缓冲放不下,当前缓冲就会被移动到前端缓冲队列中国,此时预备缓冲区用来作为新的当前缓冲
BufferVector buffers_ GUARDED_BY(mutex_);//前端缓冲区队列
};
注意到这里typedef了一个新类型为Buffer类型,根据其定义可知,它就是前文所说的FixedBuffer缓冲区类型,而这个缓冲区大小由kLargeBuffer指定,大小为4M,因此,Buffer就是大小为4M的缓冲区类型。
这里定义了currentBuffer_和nextBuffer_,这两个缓冲区就是上面所说的”前端“,用来暂时存储生成的日志消息,只不过nextBuffer_用作预备缓冲区,当currentBuffer_不够用时用nextBuffer_来补充currentBuffer_。
然后就是buffers_,这是一个vector,它用来存储”准备写到后端“的缓冲区,举个例子,如果currentBuffer_写满了,那么就会把写满的currentBuffer_放到buffers_中。
如上所述,”前端“会将日志消息全部存到currentBuffer_中,如果放不下了,就会把currentBuffer_放到buffers_中以备”后端“读取。可想而知,异步日志的”后端“,就主要负责去和buffers_进行交互,将buffers中的缓冲区中的内容全部写出到磁盘,因此,就需要开启另一个线程,来执行”后端“的任务,下文将其称为”后端线程“。
后端线程由thread_成员封装,在构造函数中指定其线程函数为threadFunc,如下所示:
AsyncLogging::AsyncLogging(const string& basename,
off_t rollSize,
int flushInterval)
:
thread_(std::bind(&AsyncLogging::threadFunc, this), "Logging"),
...
{
...
}
现在来看一下前端和后端之间是如何交互的。
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); // Rarely happens
}
currentBuffer_->append(logline, len);//向新的当前缓冲区中写入日志消息
cond_.notify();
}
}
void AsyncLogging::threadFunc() //写日志线程,将缓冲区队列中的数据调用LogFile的append
{
assert(running_ == true);
latch_.countDown(); //计数变量latch减1
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_)
{
assert(newBuffer1 && newBuffer1->length() == 0);
assert(newBuffer2 && newBuffer2->length() == 0);
assert(buffersToWrite.empty());
{
muduo::MutexLockGuard lock(mutex_);
if (buffers_.empty()) // unusual usage! 如果前端缓冲区队列为空,就休眠flushInterval_的时间
{
cond_.waitForSeconds(flushInterval_);//如果前端缓冲区队列中有数据了就会被唤醒
}
buffers_.push_back(std::move(currentBuffer_));
currentBuffer_ = std::move(newBuffer1); //当前缓冲区获取新的内存
buffersToWrite.swap(buffers_); //前端缓冲区队列与后端缓冲区队列交换
if (!nextBuffer_) //如果预备缓冲区为空,那么就使用newBuffer2作为预备缓冲区,保证始终有一个空闲的缓冲区用于预备
{
nextBuffer_ = std::move(newBuffer2);
}
}
assert(!buffersToWrite.empty());
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(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为空 (刚才用来替代当前缓冲了)
{
assert(!buffersToWrite.empty());
newBuffer1 = std::move(buffersToWrite.back()); //把后端缓冲区的最后一个作为newBuffer1
buffersToWrite.pop_back(); //最后一个元素的拥有权已经转移到了newBuffer1中,因此弹出最后一个
newBuffer1->reset(); //重置newBuffer1为空闲状态(注意,这里调用的是Buffer类的reset函数而不是unique_ptr的reset函数)
}
if (!newBuffer2)//如果newBuffer2为空
{
assert(!buffersToWrite.empty());
newBuffer2 = std::move(buffersToWrite.back());
buffersToWrite.pop_back();
newBuffer2->reset();
}
buffersToWrite.clear();//清空后端缓冲区队列
output.flush();//清空文件缓冲区
}
output.flush();
}
对于前端,只需要调用append函数即可,如果currentBuffer_足以放下当前日志消息就调用缓冲区的append函数放入消息,如果放不下,就会将currentBuffer_放入buffer_中,注意,这里使用的是移动,移动后currentBuffer_为NULL,此时如果预备缓冲区nextBuffer_尚未使用,那么就会将nextBuffer_的拥有权转移给currentBuffer_,转移后nextBuffer_为NULL,意为已被使用;而如果预备缓冲区本身就为NULL,这种情况会出现在非常频繁调用append函数,导致连续多次填满currentBuffer_的时候,此时nextBuffer_已无法为currentBuffer_提供预备空间,因此只能为currentBuffer_重新分配新的空间。(实际上这种情况很少发生,因为默认的每条日志消息的大小最大为4K,而currentBuffer_的大小为4M,除非连续写入8M以上的日志消息,而后端来不及处理这些消息,才会发生这种情况)。当前端向buffers_中移入缓冲区后,就会唤醒条件变量。
接着来看看后端,通过threadFunc函数可知,后端线程会循环去检查buffers_,如果buffers为空,那么后端线程就会休眠最多为flushInterval指定的秒数(默认为3秒),如果在此期间buffers中有了数据,后端线程就会被唤醒,否则就一直休眠直到超时,不管是哪种唤醒,都会将currentBuffer移入buffers中,这是因为后端线程每次操作都是准备将所有日志消息进行输出,而currentBuffer中大多数情况下都存有日志消息,因此即使其未满也会被放入buffers中,然后用newBuffer1来补充currentBuffer。
接下来就需要注意buffersToWrite这个vector,和buffers是相同的类型,buffersToWrite就是后端缓冲区队列,负责将前端buffers中的数据拿过来,然后把这些数据写出到磁盘。因此,当currentBuffer被移入buffersToWrite后,就会立刻调用swap函数交换buffersToWrite和buffers,这一部交换了这两个vector中的内存指针,相当于二者交换了各自的内容,buffers变成了空的,而前面所有存有日志消息的缓冲区,则全部到了buffersToWrite中。
然后,如果此时预备缓冲区为空,说明已经被使用过,就会用newBuffer2来补充它,至此,互斥锁释放。这里互斥锁的释放位置是个值得思考的地方,考虑到并发效率,互斥锁持有的临界区大小不应太大(不应简单的去锁住每一轮循环),在buffersToWrite获得了buffers的数据之后,其它线程就可以正常的调用append来添加日志消息了,因为此时buffers重置为空,并且buffersToWrite是局部变量,二者互不影响。
接着就是很自然的步骤了:将buffersToWrite中所有的缓冲区内容写到本地磁盘中,这一点后面再分析。
在写出结束后,buffersToWrite中缓冲区的内容就已经没价值了,不过废物依然可以回收:由于前面newBuffer1和newBuffer2都有可能被使用过而为空,因此可以将buffersToWrite中的元素用来填充newBuffer1和newBuffer2。
实际上,在正常情况下(指的是日志消息产生速度不会连续爆掉两块currentBuffer),currentBuffer、nextBuffer、newBuffer1和newBuffer2是不需要二次分配空间的,因为它们之间通过buffers和buffersToWrite恰好可以构成一个资源使用环:前端将currentBuffer移入buffers后用nextBuffer填补currentBuffer,后端线程将新的currentBuffer再次移入buffers,然后用newBuffer1和newBuffer2去填充currentBuffer和nextBuffer,最后又从buffersToWrite中获取元素来填充newBuffer1和newBuffer2,可见,资源的消耗端在currentBuffer和nextBuffer,而资源的补充端在newBuffer1和newBuffer2,如果这个过程是平衡的,那么这4个缓冲区都无需再分配新的空间,然后,这一点并不能得到保证,如果预备缓冲区数量越多,越能保证这一点,不过带来的就是空间上的消耗了。
后端线程将缓冲区内容写出到日志文件通过调用LogFile类的append函数实现,但是muduo中与磁盘文件交互最紧密的并不是LogFile类,而是AppendFile类,该类含有一个文件指针指向外部文件,其最主要的函数就是append函数,定义如下:
class AppendFile : noncopyable
{
public:
explicit AppendFile(StringArg filename);
~AppendFile();
void append(const char* logline, size_t len);
void flush();
off_t writtenBytes() const { return writtenBytes_; }
private:
size_t write(const char* logline, size_t len);
FILE* fp_;
char buffer_[64*1024];//缓冲区大小为64K,默认的是4K
off_t writtenBytes_;//标识当前文件一共写入了多少字节的数据,如果超过了rollsize,LogFile就会进行rollFile,创建新的日志文件,而这个文件就不会再写入了
};
void FileUtil::AppendFile::append(const char* logline, const size_t len)
{
size_t n = write(logline, len); //写出日志消息
size_t remain = len - n; //计算未写出的部分
while (remain > 0)//循环直到全部写出
{
size_t x = write(logline + n, remain); //实际调用fwrite_unlock
if (x == 0)
{
int err = ferror(fp_);
if (err)
{
fprintf(stderr, "AppendFile::append() failed %s\n", strerror_tl(err)); //stderr不带缓冲,会立刻输出
}
break;
}
n += x;
remain = len - n; // remain -= x
}
writtenBytes_ += len;
}
可见,AppendFile类的append函数进行了IO操作,writtenBytes会记录下写出到fp_对应的文件的字节数。
LogFile类中通过unique_ptr包装了一个AppendFile类实例file_,在后端线程写出时所调用的LogFile类的append函数中,就会通过该实例调用AppendFile类的append函数来将后端缓冲区中的内容全部写出到日志文件中,如下所示:
void LogFile::append_unlocked(const char* logline, int len)
{
file_->append(logline, len);//将缓冲区内容写出到日志文件中
if (file_->writtenBytes() > rollSize_)//如果写出的字节数大于了rollsize,就通过rollFile新建一个文件
{
rollFile();
}
else
{
++count_;
if (count_ >= checkEveryN_) //每调用一次append计数一次,每调用1024次检查是否需要隔天rollfile或者flush缓冲区
{
count_ = 0;
time_t now = ::time(NULL);
time_t thisPeriod_ = now / kRollPerSeconds_ * kRollPerSeconds_;
if (thisPeriod_ != startOfPeriod_)
{
rollFile();
}
else if (now - lastFlush_ > flushInterval_) //外部文件流是全缓冲的,因此fwrite并不能立刻将数据写出到外部文件中,因此需要设定一个flush间隔,每隔一段时间将数据flush到外部文件中
{
lastFlush_ = now;
file_->flush();
}
}
}
}
在后端与日志文件的交互中,除了写出数据到日志文件,还进行了两个重要的操作:滚动日志、自动flush缓冲区。
日志滚动通过rollFile函数实现,如下所示:
bool LogFile::rollFile()
{
time_t now = 0;
string filename = getLogFileName(basename_, &now);//得到输出日志的文件名
time_t start = now / kRollPerSeconds_ * kRollPerSeconds_;//计算现在是第几天 now/kRollPerSeconds求出现在是第几天,再乘以秒数相当于是当前天数0点对应的秒数
if (now > lastRoll_)
{
rollcnt++;
lastRoll_ = now;//更新lastRoll
lastFlush_ = now;//更新lastFlush
startOfPeriod_ = start;
file_.reset(new FileUtil::AppendFile(filename));//让file_指向一个名为filename的文件,相当于新建了一个文件
return true;
}
return false;
}
可以看到,rollFile的作用,就是创建一个新文件,然后让file_去指向这个新文件,新文件的命名方式为:basename + time + hostname + pid + ".log",在此之后所有日志消息都将写到新文件中。
回到LogFile的append函数中,可以看到rollFile发生在两种情况下:1.当写出到日志文件的字节数达到滚动阈值,这个阈值由AsyncLogging构造时指定,并用来构造LogFile;2.每到新的一天就滚动一次。
需要注意的是第2点,并不是到了新的一天的第一条日志消息就会导致rollFile,而是每调用1024次append函数时会去检查是否到了新的一天。可见这种方式还是有点问题的,因为可能存在到了新的一天但是没有达到1024次调用的情况,不过如果连1024次都没有达到,说明日志消息很少,也没有什么必要创建一个新的日志文件。此外,如果每次调用append都去判断是否是新的一天,那么每次都需要通过gmtime、gettimeofday这类的函数去获取时间,这样一来可能就显得得不偿失了。(在muduo中,由于是通过gmtime来获取时间的,因此会在0时区0时,即北京时间8时才算是”新的一天“)。
为什么需要flush缓冲区?这是因为通过与日志文件交互的文件流是全缓冲的,只有当文件缓冲区满或者flush时才会将缓冲区中的内容写到文件中。而对于日志消息这种需要频繁写出的情况,如果不调用flush,那么就只有缓冲区满了才会将数据写出到文件中,如果进程突然崩溃,缓冲区中还未写出的数据就丢失了,而如果调用flush的次数过多,无疑又会影响效率。
因此,muduo通过flushInterval变量来设置flush的间隔,默认为3s,即至少过3s才会自动flush,之所以说是”至少“,是因为判断间隔是否达到3秒,也需要调用时间获取函数去获取时间,如果每一次append都来判断一次,那么也是得不偿失的,因此,是否需要flush也是每append1024次再来进行判断。
通过前文可以知道,每一条日志消息实际上都是基于Logger类实现的,因此,要想实现异步日志,就需要将日志消息成功存入”前端缓冲区“,而这一点,只需要将Logger的g_output设置为AsyncLogging的append函数即可,如下所示:
muduo::AsyncLogging* g_asyncLog = NULL;
void asyncOutput(const char* msg, int len)
{
g_asyncLog->append(msg, len);
}
muduo::Logger::setOutput(asyncOutput);
这样就可以将每条日志消息成功存储前端缓冲区,接着还需要开启后端线程,调用AsyncLogging类的start函数即可。
异步日志的实现,在Logger类的基础上,还需要AsyncLogging、LogFile、AppendFile类。
其中AppendFile类用于将缓冲区数据写出到日志文件;
LogFile中包含了AppendFile的实例,并且实现了滚动文件和自动flush缓冲区的功能;
AsyncLogging包含了异步日志的前端和后端,前端与Logger相连接,通过Logger来获得每一条日志消息并进行存储,后端线程创建LogFile局部实例,从前端缓冲区中得到日志消息后通过LogFile局部实例将日志消息写出到日志文件中。