muduo源碼學習(2):異步日誌——異步日誌的實現

目錄

什麼是異步日誌

異步日誌的實現

前端與後端

前端與後端的交互

資源回收

後端與日誌文件

滾動日誌

自動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<muduo::detail::kLargeBuffer> Buffer;
  typedef std::vector<std::unique_ptr<Buffer>> BufferVector;
  typedef BufferVector::value_type BufferPtr;

  const int flushInterval_;   //前端緩衝區定期向後端寫入的時間(沖刷間隔)
  std::atomic<bool> 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<int>(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局部實例將日誌消息寫出到日誌文件中。

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章