在上一篇文章中,我们完成了基础日志系统的搭建,实现了日志级别控制、日志格式封装、文件滚动输出等核心功能。但同步日志存在一个明显问题:日志写入操作会阻塞业务线程,尤其是在高并发场景下,频繁的磁盘 IO 会严重影响程序性能。因此,本文将基于原有框架,实现异步日志系统,通过 "生产 - 消费" 模型解耦日志写入与业务逻辑,大幅提升日志系统的性能。
一、异步日志核心设计思路
异步日志的核心是 "内存缓冲区 + 后台线程":
- 生产端:业务线程将日志内容写入内存缓冲区,不直接操作磁盘,保证业务线程快速返回;
- 消费端:单独的后台线程从缓冲区读取日志数据,批量写入磁盘,降低磁盘 IO 频率;
- 线程同步:通过互斥锁、条件变量实现生产端与消费端的同步,保证数据安全;
- 缓冲区管理:设置固定大小的缓冲区,缓冲区满时自动切换,避免内存溢出。
二、核心组件实现解析
本次新增的核心文件包括:AsynLogging.hpp(异步日志类头文件)、AsynLogging.cpp(异步日志实现)、CountDownLatch.hpp/cpp(倒计时门闩,用于线程同步),以及测试文件Test04_11_Asyn.cpp。
2.1 辅助组件:CountDownLatch(倒计时门闩)
倒计时门闩的作用是保证后台线程完全启动后,主线程再继续执行 ,避免主线程先写入日志但后台线程未就绪的问题。(注:本文核心聚焦异步日志,CountDownLatch 的实现逻辑可参考经典多线程同步模型,核心是基于std::mutex和std::condition_variable实现计数等待)。
2.2 核心类:AsynLogging(异步日志类)
2.2.1 类成员设计(AsynLogging.hpp)
先看类的核心成员定义,逐行解析设计思路:
cpp
namespace tulun
{
class AsynLogging
{
private:
void workthreadfunc(); // 后台消费线程的核心函数
private:
const int flushInterval_; // 日志刷盘间隔(默认3s)
std::atomic<bool> running_; // 线程运行标记(原子变量,保证线程安全)
const std::string basename_; // 日志文件基础名
const size_t rollSize_; // 日志文件滚动大小(默认2M)
tulun::CountDownLatch latch_; // 倒计时门闩,保证线程启动完成
std::unique_ptr<std::thread> pthread_; // 后台消费线程(智能指针自动管理)
std::mutex mutex_; // 互斥锁,保护缓冲区操作
std::condition_variable cond_; // 条件变量,实现线程等待/唤醒
std::string currentBuffer_; // 当前写入的缓冲区(4K固定大小)
std::deque<std::string> buffers_; // 待刷盘的缓冲区队列
tulun::LogFile output_; // 日志文件操作类(复用之前的LogFile)
public:
// 构造函数:初始化核心参数,预留缓冲区空间
AsynLogging(const std::string &basename,
const size_t rollsize = 1024 * 1024 * 2,
int flushInterval = 3);
~AsynLogging();
AsynLogging(const AsynLogging &) = delete; // 禁用拷贝
AsynLogging &operator=(const AsynLogging &) = delete;
void append(const std::string &msg); // 重载:写入字符串
void append(const char *msg, const size_t len); // 核心写入接口:写入字符+长度
void start(); // 启动后台线程
void stop(); // 停止后台线程
void flush(); // 手动刷盘
};
}
设计思路解析:
running_使用std::atomic<bool>:原子变量无需加锁即可保证多线程读写安全,避免互斥锁的性能开销;- 缓冲区设计:
currentBuffer_(当前写入缓冲区) +buffers_(待刷盘队列):业务线程只写currentBuffer_,满了就放入队列,避免直接操作队列带来的频繁锁竞争; LogFile output_:复用之前实现的日志文件滚动、追加写入逻辑,减少重复代码;- 禁用拷贝构造 / 赋值:异步日志类是单例语义,拷贝会导致线程、锁等资源混乱,因此直接禁用。
2.2.2 构造函数与初始化(AsynLogging.cpp)
cpp
AsynLogging::AsynLogging(const std::string &basename,
const size_t rollsize,
int flushInterval)
: basename_(basename),
flushInterval_(flushInterval),
rollSize_(rollsize),
running_(false),
pthread_(nullptr),
mutex_{},
cond_{},
output_(basename, rollsize, flushInterval),
latch_{1} // 倒计时门闩初始值1:等待后台线程启动
{
currentBuffer_.reserve(BufMaxLen); // 预留4K缓冲区空间,避免频繁扩容
}
关键细节:
currentBuffer_.reserve(BufMaxLen):提前预留 4K 内存(BufMaxLen = 1024*4),避免日志写入时字符串频繁扩容,提升性能;latch_{1}:门闩初始计数为 1,后台线程启动后调用countDown(),主线程通过latch_.wait()等待计数为 0。
2.2.3 核心写入接口:append 方法
append 是业务线程写入日志的入口,核心逻辑是 "判断缓冲区是否够用,不够则切换缓冲区,最后写入数据":
cpp
void AsynLogging::append(const char *msg, const size_t len)
{
std::unique_lock<std::mutex> locker(mutex_); // 加锁保护缓冲区
// 两种情况需要切换缓冲区:
// 1. 当前缓冲区已达最大容量;2. 剩余空间不足以写入当前日志
if (currentBuffer_.size() >= BufMaxLen ||
currentBuffer_.capacity() - currentBuffer_.size() < len)
{
buffers_.push_back(std::move(currentBuffer_)); // 移动语义,避免拷贝
currentBuffer_.reserve(BufMaxLen); // 重置当前缓冲区
}
currentBuffer_.append(msg, len); // 写入日志数据
cond_.notify_all(); // 唤醒消费线程(有数据需要刷盘)
}
核心优化点:
- 使用
std::move转移缓冲区:buffers_.push_back(std::move(currentBuffer_)),避免字符串拷贝,提升性能; - 加锁范围最小化:仅在操作缓冲区时加锁,写入数据后立即解锁(
unique_lock出作用域自动解锁); - 唤醒消费线程:每次写入后调用
cond_.notify_all(),保证有数据时消费线程能及时处理。
2.2.4 后台消费线程:workthreadfunc 方法
这是异步日志的核心消费逻辑,后台线程循环读取缓冲区数据并写入磁盘:
cpp
void AsynLogging::workthreadfunc()
{
std::deque<std::string> buffersToWrite;
latch_.countDown(); // 门闩计数-1,主线程可继续执行
while (running_)
{
{
std::unique_lock<std::mutex> locker(mutex_);
// 等待条件:要么有数据,要么超时(1s),避免死等
while (buffers_.empty() && running_)
{
cond_.wait_for(locker, std::chrono::seconds(1));
}
// 将当前缓冲区加入队列,准备刷盘
buffers_.push_back(std::move(currentBuffer_));
currentBuffer_.reserve(BufMaxLen);
// 交换缓冲区队列:减少锁持有时间(交换操作是O(1))
buffersToWrite.swap(buffers_);
}// 解锁:刷盘操作不需要加锁,提升并发性能
// 保护机制:如果待刷盘队列过大,丢弃部分日志(避免内存溢出)
if(buffersToWrite.size() > 50)
{
fprintf(stderr,"Dropped log message at larger buffers \n");
buffersToWrite.erase(buffersToWrite.begin()+2,buffersToWrite.end());
}
// 批量写入磁盘(复用LogFile的append方法)
for (const auto &buf : buffersToWrite)
{
output_.append(buf);
}
buffersToWrite.clear(); // 清空已刷盘的缓冲区
}
output_.flush(); // 线程退出前,刷入剩余数据
}
设计思路拆解:
- 线程启动确认 :
latch_.countDown()通知主线程 "消费线程已就绪"; - 等待数据 / 超时 :
cond_.wait_for设置 1s 超时,避免消费线程一直阻塞,即使无数据也能定期检查退出标记; - 交换缓冲区队列 :
buffersToWrite.swap(buffers_),将待刷盘的缓冲区转移到局部变量,解锁后再刷盘,最大化减少锁持有时间; - 过载保护 :当
buffersToWrite.size() > 50时,丢弃部分日志,避免缓冲区队列无限增长导致内存耗尽; - 批量刷盘:遍历缓冲区队列,批量写入磁盘,相比单条写入大幅降低磁盘 IO 次数。
2.2.5 线程启停控制:start/stop 方法
cpp
void AsynLogging::start()
{
running_ = true;
pthread_.reset(new std::thread(&AsynLogging::workthreadfunc, this)); // 启动后台线程
latch_.wait(); // 等待线程启动完成
}
void AsynLogging::stop()
{
running_ = false;
cond_.notify_all(); // 唤醒消费线程,使其退出循环
pthread_->join(); // 等待消费线程退出
}
关键细节:
start()中latch_.wait():确保主线程在消费线程完全启动后再写入日志,避免 "生产先于消费";stop()中cond_.notify_all():即使消费线程处于等待状态,也能被唤醒并检查running_标记,正常退出。
2.3 测试文件:Test04_11_Asyn.cpp
测试文件验证异步日志的性能,核心逻辑是 "多线程写入大量日志,统计耗时":
cpp
tulun::AsynLogging asynfile("yhping"); // 初始化异步日志对象
const int n = 100000; // 每个线程写入10万条日志
// 线程1:写入DEBUG级别日志
void funa()
{
for (int i = 0; i < n; ++i)
{
LOG_DEBUG << "yhping funa" << i;
}
}
// 线程2:写入TRACE级别日志
void funb()
{
for (int i = 0; i < n; ++i)
{
LOG_TRACE << "yhp funb" << i;
}
}
// 日志输出回调:将日志写入异步缓冲区
void asynFile(const std::string &msg)
{
asynfile.append(msg);
}
// 日志刷盘回调:手动刷盘(可选)
void asynFlush()
{
asynfile.flush();
}
int main()
{
asynfile.start(); // 启动异步日志线程
tulun::Logger::setLogLevel(tulun::LOG_LEVEL::TRACE); // 设置日志级别
tulun::Logger::setOutput(asynFile); // 替换日志输出函数为异步写入
tulun::Logger::setFlush(asynFlush); // 设置刷盘回调
// 记录开始时间
tulun::Timestamp start,end;
start = tulun::Timestamp::Now();
// 启动两个业务线程写入日志
std::thread tha(funa);
std::thread thb(funb);
tha.join();
thb.join();
// 记录结束时间,输出耗时(微秒)
end = tulun::Timestamp::Now();
cout<<" "<<tulun::diffMicro(end,start)<<endl;
return 0;
}
测试逻辑解析:
- 替换日志系统的输出回调:
Logger::setOutput(asynFile),让所有日志写入异步缓冲区; - 启动两个线程,各写入 10 万条日志,统计总耗时;
- 对比同步日志:异步日志的耗时会大幅降低(同步日志需等待磁盘 IO,异步仅需写入内存)。
2.4 编译配置:example/CMakeLists.txt
新增异步日志测试程序的编译配置:
bash
# 新增异步日志测试程序
add_executable(testAsyn Test04_11_Asyn.cpp ${SRC_LOG})
# 链接pthread库(多线程必须)
target_link_libraries(testAsyn pthread)
关键配置 :异步日志依赖多线程,必须链接pthread库,否则会报线程相关的未定义符号错误。
三、核心知识点总结
本次异步日志实现涉及的核心 C++ 知识点:
- 多线程同步 :
std::mutex(互斥锁)、std::condition_variable(条件变量)、std::atomic(原子变量); - 智能指针 :
std::unique_ptr管理线程资源,避免内存泄漏; - 移动语义 :
std::move转移字符串缓冲区,减少拷贝开销; - 生产 - 消费模型:解耦日志写入与磁盘 IO,提升并发性能;
- 锁优化:最小化锁持有时间,通过交换队列将刷盘操作移出锁范围。
四、性能对比与优化方向
4.1 性能对比
- 同步日志:20 万条日志(双线程)耗时约 5-10 秒(取决于磁盘 IO 速度);
- 异步日志:20 万条日志耗时约 100-200 毫秒(仅内存操作,刷盘由后台线程异步完成)。
4.2 后续优化方向
- 多缓冲区设计:增加备用缓冲区,避免单缓冲区切换时的锁竞争;
- 日志压缩:刷盘前对日志数据进行压缩,减少磁盘占用;
- 线程池替代单线程:高并发场景下,用线程池处理刷盘操作;
- 缓冲区预分配:提前分配多个缓冲区,避免运行时频繁 reserve。
五、总结
本文基于原有日志系统,实现了异步日志的核心逻辑,通过 "内存缓冲区 + 后台线程" 的生产 - 消费模型,彻底解决了同步日志的性能瓶颈。核心设计思路是 "让业务线程只写内存,后台线程批量刷盘",同时通过锁优化、移动语义等手段保证高性能,通过倒计时门闩、条件变量等保证线程安全。
至此,一套完整的高性能 C++ 日志系统已基本成型,涵盖了日志级别、格式封装、文件滚动、异步写入等核心功能,可直接应用于高性能服务端程序的开发。
一键三连私信我分享源码压缩包,可直接粘贴使用哦~