muduo源码学习(2):异步日志——异步日志的实现

目录

什么是异步日志

异步日志的实现

前端与后端

前端与后端的交互

资源回收

后端与日志文件

滚动日志

自动flush缓冲区

开启异步日志功能

总结


        在前文中分析了日志消息的存储和输出,不过并没有涉及到异步日志,下面就来分析一下异步日志是如何实现的。

什么是异步日志

        在默认的情况下,日志消息都是直接打印到终端屏幕上,但是实际应用中,日志消息都应该写到本地文件,方便记录以及查询。

        最简单的方式就是每产生一条日志消息,都将其写到相应的文件中,然而这种方式效率低下,如果很多线程在某一段时间内需要输出大量日志,那么显然日志输出的效率是很低的。之所以效率低,就是因为每条日志消息都需要通过write这类的函数写出到本地磁盘,这就导致频繁调用IO函数,而磁盘操作本身就比较费时,这样一来后面的代码就只能阻塞住,直到前一条日志写出成功

        为了优化上述问题,一个比较好的办法就是:当日志消息积累到一定量的时候再写到磁盘中,这样就可以显著减少IO操作的次数,从而提高效率

        换句话说,当日志消息需要输出时,并不会立即将其写出到磁盘上,而是先把日志消息存储,直到达到”写出时机“才会将存储的日志消息写出到磁盘,这样一来,当日志消息生成时,只需要将其进行存储而不需要写出,后续代码也不会被阻塞,相对于前面的那种阻塞式日志,这种就是非阻塞式日志

        muduo的异步日志核心思想正是如此。当需要输出日志的时候,会先将日志存下来,日志消息存储达到某个阈值时将这些日志消息全部写到磁盘。需要考虑的是,如果日志消息产生比较慢,可能很长一段时间都达不到这个阈值,那就相当于这些日志消息一直无法写出到磁盘,因此,还应当设置一个超时值如3s,每过3s不管日志消息存储量是否达到阈值,都会将已经存储的日志消息写出到磁盘中。即日志写出到磁盘的两个时机:1、日志消息存储量达到写出阈值;2、每过3秒自动将存储的日志消息全部写出。

        这种非阻塞式日志也是异步的,因为产生日志的线程只负责产生日志,并不需要去管它产生的这条日志何时写出,写往何处...

       

异步日志的实现

       muduo中通过AsyncLogging类来实现异步日志。

       异步日志分为前端和后端两部分,前端负责存储生成的日志消息,而后端则负责将日志消息写出到磁盘,因此整个异步日志的过程可以看做如下所示:

muduo源码学习(2):异步日志——异步日志的实现_第1张图片

       先来看看前端和后端分别指的是什么。

前端与后端

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,那么就只有缓冲区满了才会将数据写出到文件中,如果进程突然崩溃,缓冲区中还未写出的数据就丢失了,而如果调用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局部实例将日志消息写出到日志文件中。

 

 

你可能感兴趣的:(muduo)