為何要列印日誌?C++在高併發下如何寫日誌檔案(附原始碼)?

一隻會鏟史的貓發表於2021-08-24

為何要列印日誌?讓程式裸奔不是一件很快樂的事麼?

有些BUG就像薛定諤的貓,具有波粒二象性,當你試圖去觀察它時它就消失了,當你不去觀察它時,它又會出現。當你在測試人員面前賭咒發誓,親自路演把程式跑一遍的時候,這些bug就會神奇的消失;一旦離開你的騷操作重新回到測試人員手中,這些bug又會突然的出現。這就是開發人員和測試人員互相對天發誓自證清白的尬聊場面。
因為這些幽靈Bug破壞了團隊的氛圍,傷害了開發和測試人員的感情,從而導致了產品質量下降。 因此非常有必要找到這些bug發生的原因。

試圖找到這些具備量子特性的bug發生的原因,通常的手段就是列印日誌,因為你很難通過幾次思想實驗或幾次尬聊就能自證清白。

列印日誌是查詢、分析、定位問題的一個非常有效的手段。尤其對後臺程式而言更加重要,後臺程式需要長期執行,執行期間會面臨各種各樣的情況,這些情況包括正常業務邏輯,非正常業務邏輯,甚至是異常情況。我們往往需要在不同情況下將這些發生的事情按時間軸記錄下來,從而在系統出現問題或瓶頸時進行回溯分析。

Java中有現成的日誌列印類log4j,並將日誌定義了4個級別:ERROR、WARN、INFO、DEBUG。通常級別越高輸出的日誌越少,如果你的後臺程式一天就能輸出一個G的ERROR日誌,那也是非常不容易的事,基本可以告別程式設計師生涯了,還打啥日誌啊?還能讓測試人員愉快的工作麼?
通常在出現ERROR日誌時,我們就需要定位問題,這就需要列印大量的DEBUG或INFO日誌來進行問題回溯。這篇文章不是介紹Java如何列印日誌,網路上的文章已經很多了。
這裡說一下C++在高併發下如何列印日誌,這裡實現最基本功能(僅做拋磚引玉之用)。通常我們會寫一個日誌輸出函式,如下:

void Logout(const char* pszLogFile, const char* pszLog)
{
	// 引數檢查
	assert(pszLogFile);
	assert(pszLog);
	if(NULL == pszLogFile || NULL == pszLog)
		return;
	
	// 開啟日誌檔案
	FILE* pFile = fopen(pszLogFile, "ab+");
	if(pFile)
	{
		// 寫日誌
		fwrite((void*)pszLog, 1, strlen(pszLog), pFile);
		fclose(pFile);
	}
}

如果是不同級別的日誌,可以定義不同日誌檔名,你也可以豐富上面的日誌函式,增加發生的時間、所線上程ID、所在檔名等輔助資訊。

通常情況下這種方式沒有問題,通過一次檔案IO操作將資訊記錄下來。但如果是一個後臺服務,當高併發發生的時候,問題就會出現了。此時將會有多個執行緒同時寫日誌的情況發生,尤其是那些INFO型別的日誌,比如記錄HTTP請求的request資訊,這時你的日誌將會成為系統的瓶頸。畢竟大量的檔案IO操作也是一種負擔,這些IO操作可能要超過你的正常業務邏輯,比如寫資料庫、網路IO等。

此時我們需要將日誌先寫入記憶體塊中,當記憶體寫滿後在一次性Flush到磁碟中,這樣就避免了大量的磁碟IO操作。下面是CFileMem封裝類,很簡單,裡面註釋的很詳細就不解釋了。

標頭檔案.h


#include <windows.h>	// for CRITICAL_SECTION

// 預設記憶體塊大小,大於該值時進行將快取資料寫入磁碟檔案
#define SIZE_DEFAULT_MEM	(1024*64)		// 64K

class CFileMem  
{
public:
	CFileMem();
	virtual ~CFileMem();

public:
	// 開啟記憶體檔案,dwMemBlockSize為快取塊大小,大於該值時進行一次IO操作
	BOOL Open(const char* pszPathFile, DWORD dwMemBlockSize = SIZE_DEFAULT_MEM);

	/*
	 寫資料到記憶體檔案,dwFileSize將返回當前磁碟檔案的大小,
	 該值可用於外部判斷日誌檔案是否過大,比如當dwFileSize大於多少M時,可重新命名檔案
	 從而避免單個日誌檔案過大
	*/
	BOOL Write(const char* pszLog, DWORD& dwFileSize);
	BOOL Write(const unsigned char* pData, DWORD dwDataSize, DWORD& dwFileSize);

	// 將記憶體資料寫入磁碟
	BOOL Flush(DWORD& dwFileSize);

	// 關閉記憶體檔案
	void Close();

	// 重新命名檔案
	void Rename(const char* pszOldPathFile, const char* pszNewPathFile);

protected:
	CRITICAL_SECTION m_csMem;		// 記憶體池鎖
	char  m_szPathFile[256];		// 日誌檔案路徑名
	char* m_pMemBlock;				// 記憶體塊,用於儲存檔案資料
	DWORD m_dwMemBlockSize;			// 記憶體塊大小
	DWORD m_dwPos;					// 記憶體塊的當前位置
};

實現檔案.cpp

#include "FileMem.h"
#include <stdio.h>

CFileMem::CFileMem()
{
	m_pMemBlock = NULL;
	m_dwMemBlockSize = 0;
	m_dwPos = 0;
	memset(m_szPathFile, 0, 256);
	::InitializeCriticalSection(&m_csMem);
}

CFileMem::~CFileMem()
{
	Close();
	::DeleteCriticalSection(&m_csMem);
}

// 開啟記憶體檔案,dwMemBlockSize為快取塊大小,大於該值時進行一次IO操作
BOOL CFileMem::Open(const char* pszPathFile, DWORD dwMemBlockSize)
{
	if(!pszPathFile)
		return FALSE;

	// 關閉之前已開啟記憶體塊
	Close();

	// 儲存日誌檔案全路徑名
	strcpy(m_szPathFile, pszPathFile);

	if(dwMemBlockSize <= 0)
		return FALSE;
	m_pMemBlock = (char*)malloc(dwMemBlockSize);
	if(NULL == m_pMemBlock)
		return FALSE;

	memset(m_pMemBlock, 0, dwMemBlockSize);
	m_dwMemBlockSize = dwMemBlockSize;
	m_dwPos = 0;
	return TRUE;
}

/*
	 寫資料到記憶體檔案,dwFileSize將返回當前磁碟檔案的大小,
	 該值可用於外部判斷日誌檔案是否過大,比如當dwFileSize大於多少M時,可重新命名檔案
	 從而避免單個日誌檔案過大
*/
BOOL CFileMem::Write(const char* pszLog, DWORD& dwFileSize)
{
	return Write((const unsigned char*)pszLog, strlen(pszLog), dwFileSize);
}
BOOL CFileMem::Write(const unsigned char* pData, DWORD dwDataSize, DWORD& dwFileSize)
{
	dwFileSize = 0;
	if(NULL == pData || 0 == dwDataSize)
		return FALSE;
	
	// 如果內部沒有開闢緩衝區,則直接寫檔案
	if(NULL == m_pMemBlock || 0 == m_dwMemBlockSize)
	{
		FILE* pFile = ::fopen(m_szPathFile, "ab+");
		if(NULL == pFile)
			return FALSE;
		::fwrite(pData, 1, dwDataSize, pFile);

		// 獲取磁碟檔案大小
		::fseek(pFile, 0L, SEEK_END); 
		dwFileSize = ::ftell(pFile);
		::fclose(pFile);
		return TRUE;
	}

::EnterCriticalSection(&m_csMem);

	// 如果記憶體塊已滿,則寫入磁碟檔案
	DWORD dwTotalSize = m_dwPos + dwDataSize;
	if(dwTotalSize >= m_dwMemBlockSize)
	{
		FILE* pFile = ::fopen(m_szPathFile, "ab+");
		if(NULL == pFile)
		{
::LeaveCriticalSection(&m_csMem);
			return FALSE;
		}
		
		// 將當前記憶體中資料寫入檔案
		::fwrite(m_pMemBlock, 1, m_dwPos, pFile);
		::fwrite(pData, 1, dwDataSize, pFile);
		
		// 獲取磁碟檔案大小
		::fseek(pFile, 0L, SEEK_END); 
		dwFileSize = ::ftell(pFile);
		::fclose(pFile);
		
		// 清空記憶體塊
		memset(m_pMemBlock, 0, m_dwMemBlockSize);
		m_dwPos = 0;
	}
	else
	{
		// 如果記憶體未滿,將當前資料寫入記憶體
		memcpy(m_pMemBlock+m_dwPos, pData, dwDataSize);
		m_dwPos += dwDataSize;
	}
::LeaveCriticalSection(&m_csMem);
	return TRUE;
}

// 將緩衝區的內容寫入磁碟
BOOL CFileMem::Flush(DWORD& dwFileSize)
{
	// 引數
	dwFileSize = 0;
	if(NULL == m_pMemBlock || 0 == m_dwMemBlockSize || 0 == m_dwPos)
		return TRUE;

::EnterCriticalSection(&m_csMem);
	FILE* pFile = ::fopen(m_szPathFile, "ab+");
	if(NULL == pFile)
	{
::LeaveCriticalSection(&m_csMem);
		return FALSE;
	}
	// 記憶體資料寫入磁碟檔案
	::fwrite(m_pMemBlock, 1, m_dwPos, pFile);

	// 獲取磁碟檔案大小
	::fseek(pFile, 0L, SEEK_END); 
	dwFileSize = ::ftell(pFile);
	::fclose(pFile);

	// 清空記憶體塊
	memset(m_pMemBlock, 0, m_dwMemBlockSize);
	m_dwPos = 0;

::LeaveCriticalSection(&m_csMem);

	return TRUE;
}

void CFileMem::Close()
{
	// 將記憶體中檔案寫入磁碟
	DWORD dwFileSize = 0;
	Flush(dwFileSize);

	// 釋放記憶體塊
	free(m_pMemBlock);
	m_pMemBlock = NULL;
	m_dwMemBlockSize = 0;
	m_dwPos = 0;
	memset(m_szPathFile, 0, 256);
}

// 重新命名檔案
void CFileMem::Rename(const char* pszOldPathFile, const char* pszNewPathFile)
{
::EnterCriticalSection(&m_csMem);
	::rename(pszOldPathFile, pszNewPathFile);
::LeaveCriticalSection(&m_csMem);
}

測試函式:

#include <assert.h>
#include <stdio.h>
#include <time.h>	// for Clock
// 傳統的日誌輸出函式
void Logout(const char* pszLogFile, const char* pszLog)
{
	// 引數檢查
	assert(pszLogFile);
	assert(pszLog);
	if(NULL == pszLogFile || NULL == pszLog)
		return;
	
	// 開啟日誌檔案
	FILE* pFile = fopen(pszLogFile, "ab+");
	if(pFile)
	{
		// 寫日誌
		fwrite((void*)pszLog, 1, strlen(pszLog), pFile);
		fclose(pFile);
	}
}

static void TestFileMem()
{
	const char* pszLogFile1 = "c:/test1.log";
	const char* pszLogInfo = "Hello world\r\n";
	int iCount  = 10000;	

	// 迴圈寫日誌1萬次

	// 第一種方式:傳統寫日誌方式
	clock_t cStart = clock();
	for(int i = 0; i < iCount; i++)
		Logout(pszLogFile1, pszLogInfo);
	clock_t cEnd = clock();
	long lElapsed = cEnd - cStart;
	printf("Logout Elpased time %d ms\r\n", lElapsed);

	// 第二種方式:使用CFileMem寫日誌
	const char* pszLogFile2 = "c:/test2.log";
	CFileMem fm;
	DWORD dwFileSize = 0;
	fm.Open(pszLogFile2);
	cStart = clock();
	for(i = 0; i < iCount; i++)
		fm.Write(pszLogInfo, dwFileSize);
	fm.Close();
	cEnd = clock();
	lElapsed = cEnd - cStart;
	
	printf("CFileMem Elpased time %d ms\r\n", lElapsed);
	
}

輸出結果

在這裡插入圖片描述
通過測試比較,第二種方式速度更加快速,而且是執行緒安全的。
我寫的“HttpServer:一款Windows平臺下基於IOCP模型的高併發輕量級web伺服器”,就使用該類進行日誌列印,效果不錯。

感謝您的閱讀!

相關文章