muduo 日志库学习(二)

       上一篇文章讲到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>),其存放已经满了的前台buffer。

//所有LOG_*最终都会调用append函数。
voidAsyncLogging::append(const char* logline, int len)
{
  //可能有多个线程同时调用LOG_*写入日志,所以需要同步。
  muduo::MutexLockGuard lock(mutex_);
  if ( currentBuffer_->avail()  >  len) //buffer还够大,可以装下新一条日志内容。
  {
    currentBuffer_->append(logline, len);
  }
  else
  {
    //currentBuffer_已经满了,要将之存放到buffers_中。
   buffers_.push_back(currentBuffer_.release());
 
    if (nextBuffer_)
    {
      currentBuffer_ =boost::ptr_container::move(nextBuffer_);
    }
    else
    {
      currentBuffer_.reset(new Buffer); //Rarely happens。先不考虑这种情况。
    }
    currentBuffer_->append(logline,len);
 
    cond_.notify(); //通知后台线程,已经有一个满了的buffer了。
  }
}

        可以看到前台线程所做的工作比较简单,如果currentBuffer_够用,就把日志内容写入到currentBuffer_中,如果不够用(就认为其满了),就把currentBuffer_放到已满buffer数组中,等待消费者线程(即后台线程)来取。并且把currentBuffer_指向nextBuffer_的buffer。

        可以看到前台线程把两个前台buffer用掉了,下面可以看看后台线程是怎么归还这个两个buffer的。

      

       后台线程代码由于比较长,这里将删除一些和本文无关的代码。

        在AsyncLogging类中还有下面的声明:

typedef boost::ptr_vector BufferVector;
typedef BufferVector::auto_type BufferPtr;

voidAsyncLogging::threadFunc()
{
  LogFile output(basename_, rollSize_, false);//定义一个直接进行IO的类。
  BufferPtr newBuffer1(new Buffer); //这两个是后台线程的buffer
  BufferPtr newBuffer2(new Buffer);
  BufferVector buffersToWrite; //用来和前台线程的buffers_进行swap.
 
  while (running_)
  {
    {
      muduo::MutexLockGuard lock(mutex_);
      if (buffers_.empty())  // unusual usage!
      {
        cond_.waitForSeconds(flushInterval_);//睡眠的时间是日志库flush时间。
      }
 
      //无论cond是因何而醒来,都要将currentBuffer_放到buffers_中。
      //如果是因为时间到而醒,那么currentBuffer_还没满,此时也要将之写入LogFile中。
      //如果已经有一个前台buffer满了,那么在前台线程中就已经把一个前台buffer放到buffers_中
      //了。此时,还是需要把currentBuffer_放到buffers_中(注意,前后放置是不同的buffer,
      //因为在前台线程中,currentBuffer_已经被换成nextBuffer_指向的buffer了)
     buffers_.push_back(currentBuffer_.release());
 
      currentBuffer_ =boost::ptr_container::move(newBuffer1); /*---归还一个buffer---*/
      buffersToWrite.swap(buffers_); //交换
      if (!nextBuffer_)
      { //the nextBuffer_ is still free. notuse for swap with currentBuffer_
        nextBuffer_ = boost::ptr_container::move(newBuffer2); /*-----假如需要,归还第二个----*/
      }
    }
 
    //将已经满了的buffer内容写入到LogFile中。由LogFile进行IO操作。
    for (size_t i = 0; i reset();
}
    //前台buffer是由newBuffer1 2 归还的。现在把buffersToWrite的buffer归还给后台buffer
    if (!newBuffer2)
    {
      newBuffer2 = buffersToWrite.pop_back();
      newBuffer2->reset();
    }
 
    buffersToWrite.clear();
    output.flush(); //flush to drive. less than3 mins a time
  }
  output.flush();
}

        从代码可以看到后台线程的主要如下:

        先用线程同步中的条件变量进行睡眠,睡眠时间为日志库的flush时间。所以,当条件变量的条件满足(即前台线程把一个已经满了的buffer放到了buffers_中),或者超时。无论是哪种情况,都还会有一个currentBuffer_前台buffer正在使用。将这个currentBuffer_放到已满buffers_数组中。这样buffers_就有了待进行IO的buffer了。

        将bufferToWrite和buffers_进行swap。这就完成了将写了日志记录的buffer从前台线程到后台线程的转变。后台线程慢慢进行IO即可。

        这个过程就完成了日志的异步输出。下面说一下后台线程怎么进行具体的IO的。这个过程将涉及到另外一个类 LogFile。

 

        LogFile类有一个私有的嵌套类File,所有的IO都是这个File类来完成。LogFile直接把日志内容作为参数调用File类提供的一个append函数。LogFile更多是进行文件roll。

       下面给出File类的具体内容。

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:

        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网络库)