服務器編程中,日志系統需要滿足幾個條件
.高效,日志系統不應占用太多資源
.簡潔,為了一個簡單的日志功能引入大量第三方代碼未必值得
.線程安全,服務器中各個線程都能同時寫出日志
.輪替,服務器不出故障是不重啟的,半年一年的日志放到一個文件會導致文件過大
.及時保存,程序故障導致異常退出,此時需要通過日志診斷問題,不緩沖的日志系統更易用
著名的日志庫有log4xxx系列,提供了非常靈活的功能,當然隨之而來的代價就是龐大的庫。在大多數服務器應用中,所需的功能不多,我偏向於選擇一個支持按時間輪替的簡潔的日志庫。
為了同時做到線程安全和支持輪替,大多數日志系統都使用鎖。寫出日志時,首先獲取鎖,如果需要輪替,則進行輪替操作,否則寫到現有文件,最后釋放鎖。
google開源的leveldb的日志系統中,同時做到了“線程安全”和輪替,但是沒有用鎖,這引發了我的興趣。
仔細閱讀發現它的運作原理是
.每個log操作,都會生成相關的字符串,最終調用write,寫出到日志系統的文件描述符fd。
.進行rotate操作時,重新命名舊文件,保持舊文件的打開狀態,然后打開新文件,將fd設置為新文件。
.接下來sleep 200ms,然后把close舊文件
那么輪替過程中,fd的值為fd_old或者fd_new,只要fd的讀寫是原子的,不會讀取到非fd_old和fd_new的其他值即可(fd是int,這點可以做到)。write操作就沒有問題
如果由於系統繁忙,fd讀取為fd_old之后,走到操作系統的write之前,線程被切換,並且經過了200ms,那么fd_old就有可能會在sleep 200ms之后被關閉,那么write就可能失敗。
因此這種做法是簡潔的,能夠應對絕大多數情況,但並非安全,而且切換時需要sleep 200ms也是個讓人頭疼的問題。
借鑒leveldb的做法,加上posix上的dup2調用則可以完美的解決這個問題。
.輪替時,首先重命名舊文件,保持舊文件的打開狀態,然后打開新文件。
rename(oldname, newname);
fd = open(oldname,...);
.使用dup2系統函數把fd_new復制到fd_old上
dup2(fd, fd_);
.關閉fd_new
close(fd);
其中dup2是原子操作,它會關閉fd_old並且把fd_old也指向fd_new打開的文件。因此fd_old這個文件描述符總是保持打開狀態,並且值不變,但是前后指向了不同的文件。另一邊write也是個原子操作,它與dup2不會交叉進行,因此保證了日志系統的正確性。
詳情參見開源庫handy中的logging.h和logging.cc,里面一部分代碼采用了C++11的語法
https://github.com/yedf/handy/tree/master/handy
handy的日志系統中,日志要做的內容就是使用snprintf格式化要輸出的內容,然后調用write,沒有多余的工作,因此做到了簡潔高效
通過前面介紹的原理同時實現了無鎖的線程安全,和日志輪替
每次日志的輸出都write,即使程序崩潰,日志也已經到了操作系統層,不會丟失,易於調試問題
當然高效與及時保存有一定的沖突,如果緩存多條數據然后合並write能夠提升一定的性能,但這里我選擇簡潔與易用
handy的日志系統性能測試可以參見項目examples下的log-bench.cc,在我筆記本電腦上的虛擬機的壓力測試中,輸出文件為/dev/null時,能夠達到75w/s的qps
PS:handy的日志輪替中,對lastRotate_的讀取和修改並非原子類型,可能會導致多輪替一次,解決方法為使用C++11中的原子類型,或者就容忍了(多輪替一次會在后續的操作中失敗,僅僅多輸出了一條信息到標准錯誤)。