一、同步异步日志
同步日志:
同步日志是指产生日志的同时就将其写入至文件中,这种方式优点是设计简单、能够及时的将日志输出到文件中,缺点是会频繁的触发 IO 操作,且对写日志的线程阻塞。在客户端程序中,这种方式很常见,写入几条日志的时间相比于用户的反应时间来说可以忽略不计,用户很难察觉这点性能损失。即使是在 UI 线程中,只要日志量不大的情况下也不会造成什么影响。
异步日志:
对于服务端程序而言,使用同步写日志的方式会影响性能,服务器线程阻塞时间越长,其为客户端提供服务的时间就越短,同时为多个客户端提供服务的能力也越差。现代计算机的 CPU 一般都是具有多个核心的,因此服务端程序常采用多线程程序来尽量发挥每一个核心的能力,异步日志为了减少写日志对线程的阻塞,采用一个单独的线程来向磁盘写入日志,其它线程只是产生日志并将其交给写磁盘线程。
服务器的⽇志系统是⼀个多⽣产者,单消费者的任务场景:多⽣产者负责把⽇志写⼊缓冲区,单消费者负责把缓冲 区中数据写⼊⽂件。如果只⽤⼀个缓冲区,不光要同步各个⽣产者,还要同步⽣产者和消费者。⽽且最重要的是需要 保证⽣产者与消费者的并发,也就是前端不断写⽇志到缓冲区的同时,后端可以把缓冲区写⼊⽂件。
二、结构
日志库分为前端和后端两个部分,前端用于生成日志消息并传送到后端,后端则负责将日志消息写入本地日志文件。日志系统的前后端之间只有一个简单的回调函数作为接口:
日志模块前端部分的调用时序为:Logger => Impl => LogStream => operator<<FixBuffer => g_output => AsyncLogging:: append。
三 、日志前端
通过宏定义 #define LOG Logger(__FILE__, __LINE__).stream() 使得我们在使⽤⽇志写⼊语句 LOG << info; 之类的内容时,先构建⼀个临时的 Logger 对象,在构造时⽣成详细的⽇志信息,由于临时对象会在语句结束后析构,我们可以在析构的时候再将⽇志真正地 写⼊⽂件,来保证实现的简洁性。
#define LOG_TRACE if (Logger::logLevel() <= Logger::TRACE) \
Logger(__FILE__, __LINE__, Logger::TRACE, __func__).stream()
#define LOG_DEBUG if (Logger::logLevel() <= Logger::DEBUG) \
Logger(__FILE__, __LINE__, Logger::DEBUG, __func__).stream()
#define LOG_INFO if (Logger::logLevel() <= Logger::INFO) \
Logger(__FILE__, __LINE__).stream()
#define LOG_WARN Logger(__FILE__, __LINE__, Logger::WARN).stream()
#define LOG_ERROR Logger(__FILE__, __LINE__, Logger::ERROR).stream()
#define LOG_FATAL Logger(__FILE__, __LINE__, Logger::FATAL).stream()
#define LOG_SYSERR Logger(__FILE__, __LINE__, false).stream()
#define LOG_SYSFATAL Logger(__FILE__, __LINE__, true).stream()
#define LOG_DEBUG_BIN(x,l) if (Logger::logLevel() <= Logger::DEBUG) \
Logger(__FILE__, __LINE__, Logger::DEBUG, __func__).WriteLog((x), (l))
使用LOG宏时会创建一个匿名Logger对象(其中包含一个Impl类型的成员变量),并调用stream()函数得到一个LogStream对象的引用,而LogStream重载了<<操作符,可以将日志信息存入LogStream的buffer中。这样LOG语句执行结束时,匿名Logger对象被销毁,在Logger的析构函数中,会在日志消息的末尾添加LOG语句的位置信息(文件名和行号),最后调用g_output()函数将日志信息传输到后端,由后端日志线程将日志消息写入日志文件。
LOG << XXXX // 调用格式
#define LOG Logger(__FILE__, __LINE__).stream()
LogStream &stream() { return impl_.stream_; } // stream() 会返回一个logstream 对象, logstream 重载了各种 << 写到 缓冲区 FixedBuffer 中, 在LOG 调用析构函数进行销毁时,调用output() 缓冲区内容写到标准输出,或则后端中,再由后端写到硬盘,从这里开始就进入 AsyncLogger 类中
void output(const char* msg, int len)
{
pthread_once(&once_control_, once_init);
AsyncLogger_->append(msg, len); /// 在析构函数中调用AsyncLogger 写回文件
}
然后是 AsynLogging 类,这个类的作⽤则是将从前端获得的 Buffer A 放⼊ 后端的 Buffer B中,并且将 Buffer B 的内容最终写⼊到磁盘中(也就是整个后端所作的内容)
四、日志后端
日志后端有自己的缓冲区,使用的是双缓冲区,实际实现上使用了 4 个缓冲区。缓冲区使用FixedBuffer<kLargeBuffer>
对象(大小为4000*1000字节),缓冲区使用std::unique_ptr
管理,通过移动语义来实现缓冲区之间的交换,避免了缓冲区内容的复制成本。
class AsyncLogging : noncopyable{
public:
AsyncLogging(std::string base, size_t rollSize, int flushInterval=3);
~AsyncLogging() {
if (running_) {
stop();
}
}
void append(const char* logLine, int len); // 日志前端(生产者)调用该函数写入
void start() {
running_ = true;
thread_ = std::make_unique<std::thread>(&AsyncLogging::theadFunc, this);
}
void stop() {
if (running_) {
running_ = false;
cond_.notify_one();
thread_->join();
}
}
private:
void theadFunc(); // 消费缓冲区中的日志
typedef detail::FixedBuffer<detail::kLargeBuffer> Buffer;
typedef std::vector<std::unique_ptr<Buffer>> BufferVector;
typedef BufferVector::value_type BufferPtr;
const int flushInterval_;
bool running_;
std::string basename_;
size_t rollSize_;
std::unique_ptr<std::thread> thread_;
std::mutex mutex_;
std::condition_variable cond_;
BufferPtr currentBuffer_; // 前端使用的第一个缓冲区
BufferPtr nextBuffer_; // 如果日志写入速度很快,短时间写满第一个缓冲区,则使用第二个缓冲区
BufferVector buffers_; // 存储已经写满的缓冲区
};
currentBuffer 和 nextBuffer 主要用于防止前端写得过快,一下子写满缓冲区,所以设置两个,写满一个,继续写第二个。
首先看前端(生产者)调用的写日志函数:
可能同时多个线程向日志后端提交日志,因此 append
函数加锁,其逻辑如下:首先写 currentBuffer_
缓冲区,写满之后使用 nextBuffer_
缓冲区,两个都写满时再重新分配新的缓冲区。所有写满的缓冲区都移入buffers_
中保存,等待消费者写入磁盘。当写满一个缓冲区后就立即唤醒条件变量,从而让消费者线程将缓冲区写入磁盘。
void AsyncLogging::append(const char *logLine, int len) {
std::lock_guard<std::mutex> lockGuard(mutex_);
// 当前缓冲区空间足够
if (currentBuffer_->avail() > len) {
currentBuffer_->append(logLine, len);
} else {
buffers_.emplace_back(currentBuffer_.release());
if (nextBuffer_) {
// 使用另一个缓冲区
currentBuffer_ = std::move(nextBuffer_); // now nextBuffer_ == nullptr
} else {
// 两个缓冲区都满了,重新分配一个缓冲区,很少发生
currentBuffer_ = std::make_unique<Buffer>();
}
currentBuffer_->append(logLine, len);
cond_.notify_one();
}
}
其中 LogFile
是对写入磁盘文件的一个封装,它能在文件大小超过 rollSize_
后自动写一个新的文件,这样防止单个日志文件过大。调用其 append
函数 flush
函数能够写入磁盘和刷新磁盘缓冲区,其详细设计可以参考源码。
线程函数每次在条件变量上休眠,唤醒条件有两个:(1) 生产者唤醒;(2) 超时。超时时间为 3 s,因此最长每 3 s就会将缓冲区的日志写入磁盘一次。这里注意临界区里将当前在写的缓冲区加入了 buffers_
并 swap
了 buffersToWrite
和 buffers_
,而不是将整个写入磁盘的操作也放进临界区里,提高效率。
之后调用output.append()
来将日志写入磁盘,并调用output.flush()
来刷新 IO 的内核缓冲区,确保日志写入磁盘中。