目錄
在前文中分析了日誌消息的存儲和輸出,不過並沒有涉及到異步日誌,下面就來分析一下異步日誌是如何實現的。
什麼是異步日誌
在默認的情況下,日誌消息都是直接打印到終端屏幕上,但是實際應用中,日誌消息都應該寫到本地文件,方便記錄以及查詢。
最簡單的方式就是每產生一條日誌消息,都將其寫到相應的文件中,然而這種方式效率低下,如果很多線程在某一段時間內需要輸出大量日誌,那麼顯然日誌輸出的效率是很低的。之所以效率低,就是因爲每條日誌消息都需要通過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局部實例將日誌消息寫出到日誌文件中。