当我们的程序运行到线上,或者说它处于一个我们无法调试,或者不方便调试的状态下,日志有助于我们查看当前程序的运行状态,帮我们排除故障。也可以帮我们记录服务器运行的记录,从而可以还原一些处理过的关键信息,可以帮助我们避免一些无法查证的事情。
但是同步日志有可能在性能方面,无法满足服务器的要求,故而考虑设计异步日志组件。
这个日志组件并非是我独立思考设计,其中参考了很多前辈的思想和经验。在这写下一些总结,来帮助自己更好的思考整个过程。
首先一个日志组件应该具备以下特性:
- 日志应该是分级别的,如TRACE,DEBUG,INFO,WARN,ERROR这几个级别较为常见,不同的场景下,我们应该开启的日志级别应该是不同的,比如在开发过程中,我们更多的想要去记录程序的运行状态,那么,对日志级别的要求就会高一些,记录的信息也更多一些,但是在程序上线后,很多日志对于逻辑的处理并没有很大的帮助,反而对性能成为了不小的负荷,故而应该只记录关键信息
- 日志应该是支持rollSize的,这个字段代表着一个日志的大小应该是有上限的,因为过大的日志,我们的文本阅读其实加载起来也非常吃力,并且不利于排查,这个可以根据日志大小或时间段来分割,比如日志大于10M时,新建一个文件,又或者每隔30分钟,或者1小时新建一个文件
- 支持线程ID的记录,这样方便我们排查问题
- 支持截断,这个为可选项,用来控制日志的长度
要完成以上特性,则必须为相应的特性增加相应的处理,用以完成必要的操作。
除了以上的日志组件本身所要求的特性,我们还希望提升它的性能,考虑到日志在日常使用下的状态,那么应该是一个多个地方在使用日志,统一在一处来进行处理。故而,可以抽象为一个多写,单独的抽象模型。
我们用一个list来做缓冲队列,所以list就是我们需要征用的资源,当有线程在对list进行操作的时候,需要进行加锁(lock)操作。因此,我们的日志对象还需要mutex这样的对象。
故而我们的日志类,应该拥有如下成员变量:
static bool m_toFile; //是否写入控制台
static FILE* m_logFile;
static bool m_truncate; //是否截断日志
static LOG_LEVEL m_currentLevel; //当前日志级别
static std::string m_fileName;
static std::string m_PID;
static int64_t m_rollSize;
static int64_t m_currentWrittenSize; //已经写入的字数
static std::list<std::string> m_buffer;
static std::unique_ptr<std::thread> m_thread;
static std::mutex m_mutex;
static std::condition_variable m_cond;
static bool m_Exit;
static bool m_running;
当写入线程给日志对象扔进数据时,实际上,我们是将它写入缓存队列,而另外启动一个线程,专门来进行对磁盘的IO操作,因为IO操作本身是一个耗时操作,故而,这样可以降低处理线程的等待时间。
下面是我们的写入代码:
void AsyncLog::output(int32_t logLevel, const char* fileName, int32_t lineNo, const char* args, ...)
{
if(logLevel < m_currentLevel || logLevel > LOG_FATAL)
return;
std::string logInfo;
makeLinePrefix(logLevel, logInfo); //构建日志头部信息
char buf[512] = { 0 };
snprintf(buf, sizeof buf, "[%s:%d]", fileName, lineNo);
logInfo.append(buf);
va_list ap;
va_start(ap, args);
int32_t nLogMsgLength = vsnprintf(nullptr, 0, args, ap);
va_end(ap);
std::string strMsg;
if((int32_t)strMsg.capacity() < nLogMsgLength)
{
strMsg.resize(nLogMsgLength + 1);
}
va_list aq;
va_start(aq, args);
vsnprintf((char*)strMsg.data(), strMsg.capacity(), args, aq);
va_end(aq);
if(m_truncate && nLogMsgLength > MAX_LENGHT_LINE)
{
logInfo.append(strMsg.c_str(), MAX_LENGHT_LINE);
}else{
logInfo.append(strMsg.c_str(), nLogMsgLength);
}
if(!m_fileName.empty())
{
logInfo += "\n";
}
//写入缓冲区,习惯用{}来对RAII技术的加锁区域包裹,实际上这里可以不写{}
{
std::unique_lock<std::mutex> guard(m_mutex);
m_buffer.push_back(logInfo);
m_cond.notify_one();
}
}
再来看一看我们单独启动的线程中,是如何处理缓冲区中的内容的:
void AsyncLog::writeThreadProc()
{
m_running = true;
while(m_running)
{
if(!m_fileName.empty())
{
if(m_currentWrittenSize >= m_rollSize)
{
char buf[64] = { 0 };
time_t now = time(0);
tm tim;
localtime_r(&now, &tim);
strftime(buf, sizeof(buf), "%Y%m%d%H%M%S", &tim);
std::string newFileName = m_fileName + "." + buf + ".log";
if(!createNewFile(newFileName.c_str()))
return;
m_currentWrittenSize = 0;
}
}
std::string logInfo;
{
std::unique_lock<std::mutex> guard(m_mutex);
while(m_buffer.empty())
{
if(m_Exit)
return;
m_cond.wait(guard);
}
logInfo = m_buffer.front();
m_buffer.pop_front();
}
std::cout << logInfo;
if(m_logFile)
{
if(!writeToFile(logInfo))
return;
m_currentWrittenSize += logInfo.length();
}
}
m_running = false;
}
这份代码中,虽然C++相关的代码是可以跨系统的,但是因为采用了不少的Linux系统调用,故而应在Linux操作系统下编译运行。
查看完整代码可以点击这里。
这份代码仍然有许多可以优化的地方,如将缓冲区变更为无锁队列,放入共享内存运行,增加容灾性等等。希望有兴趣的朋友,可以继续探索~