1:承接上一篇
上一篇我们完成了异步日志的核心基石 ------非环形动态缓冲区 Buffer,实现了高性能的内存数据存储、动态扩容和零拷贝交换功能。但缓冲区本身只是一个 "数据容器",它不知道什么时候该写入数据,什么时候该把数据写到磁盘。
要实现真正的异步日志,我们还需要一个 "大脑" 来调度生产和消费流程:让业务线程把日志写入缓冲区,让后台线程负责把缓冲区的数据写到磁盘,并且让这两个过程尽可能互不阻塞。这个 "大脑" 就是我们本篇要实现的异步日志调度器 AsyncLooper。
2:为什么需要AsyncLooper
很多人会问:"不就是开个后台线程写磁盘吗?有必要单独写一个类吗?" 答案是:非常有必要。一个设计良好的异步调度器需要解决很多复杂的问题:
1:解决同步日志的IO阻塞问题
这是最根本的需求。同步日志中,业务线程每写一条日志都要等待磁盘 IO 完成,而磁盘 IO 的速度比内存慢几个数量级。在高并发场景下,大量业务线程会阻塞在日志写入上,严重拖慢系统响应速度。
AsyncLooper 通过生产者 - 消费者模型彻底解耦了日志生产和消费:
- 生产者(业务线程):只需要把日志写入内存缓冲区,立即返回,不需要等待 IO
- 消费者(后台线程):单独负责从缓冲区读取数据,批量写入磁盘
2:解决单缓冲区的锁竞争问题
如果只用一个缓冲区,生产者写数据和消费者读数据都需要加锁,会导致严重的锁竞争:生产者写数据时消费者不能读,消费者读数据时生产者不能写。
我们采用双缓冲区设计来解决这个问题:
- 两个缓冲区:一个负责生产(_pro_buf),一个负责消费(_con_buf)
- 生产缓冲区写满后,原子交换两个缓冲区
- 消费者只需要处理消费缓冲区,生产者可以继续往新的生产缓冲区写数据
- 整个交换过程只需要加一个非常短的锁,几乎不会影响性能
3:提供灵活的异步模式
不同的业务场景对异步日志有不同的需求:
- 核心业务场景:不能丢失日志,即使阻塞业务线程也不能让缓冲区溢出
- 非核心业务场景:宁愿丢失少量日志,也不能阻塞业务线程
因此我们设计了两种异步模式:
- ASYNC_SAFE(安全模式):生产缓冲区满时,阻塞生产者线程,直到有空闲空间
- ASYNC_UNSAFE(非安全模式):缓冲区无限扩容,永远不阻塞生产者(适合对延迟敏感的场景)
3:整体设计思路
1:核心架构
AsyncLooper 的核心架构非常简洁:
[业务线程1] ──┐
[业务线程2] ──┼─> [生产缓冲区 _pro_buf] ──[交换]──> [消费缓冲区 _con_buf] ──> [回调函数] ──> 磁盘
[业务线程3] ──┘
↑
[后台消费线程]
2:核心流程
- 业务线程调用
push()方法写入日志 - 如果是安全模式且缓冲区已满,业务线程阻塞等待
- 日志写入生产缓冲区
- 唤醒后台消费线程
- 后台线程交换生产缓冲区和消费缓冲区
- 唤醒所有阻塞的生产者线程
- 后台线程调用回调函数处理消费缓冲区的数据
- 重置消费缓冲区,等待下一次交换
3:同步机制
- 使用
std::mutex互斥锁保护缓冲区的并发访问 - 使用两个条件变量分别控制生产者和消费者的等待 / 唤醒:
_cond_pro:生产者等待缓冲区有空闲空间_cond_con:消费者等待缓冲区有数据可读
4:核心代码解析
1:头文件和类型定义
cpp
/*实现异步工作器*/
#pragma once
#include "buffer.hpp"
#include <thread>
#include <mutex>
#include <condition_variable>
#include <functional>
#include <memory>
#include <atomic>
namespace my_log {
// 缓冲区数据处理回调函数类型:由使用者定义具体的消费逻辑(比如写文件、写网络)
using Functor = std::function<void(Buffer&)>;
// 异步模式枚举
enum class AsyncType {
ASYNC_SAFE, // 安全模式:缓冲区满了就阻塞生产者,保证不丢失日志
ASYNC_UNSAFE // 非安全模式:不限制缓冲区大小,永远不阻塞(可能导致OOM)
};
设计思路
- 使用
std::function定义回调函数类型,让 AsyncLooper 与具体的消费逻辑解耦。使用者只需要传入一个处理 Buffer 的函数,就可以自定义日志的去向(文件、网络、数据库等) - 两种异步模式通过枚举类定义,类型安全,语义清晰
2:类定义与成员变量
cpp
class AsyncLooper {
public:
using ptr = std::shared_ptr<AsyncLooper>;
// 构造函数:传入回调函数和异步模式,默认安全模式
AsyncLooper(const Functor &cb, AsyncType loop_type = AsyncType::ASYNC_SAFE):
_callback(cb),
_loopr_type(loop_type),
_stop(false),
_thread(std::thread(&AsyncLooper::threadEntry, this))
{ }
// 析构函数:自动停止后台线程
~AsyncLooper() { stop(); }
cpp
private:
Functor _callback; // 数据处理回调函数,由使用者传入
AsyncType _loopr_type; // 异步模式
std::atomic<bool> _stop; // 线程停止标志(原子变量,保证多线程可见性)
Buffer _pro_buf; // 生产缓冲区:业务线程往这里写数据
Buffer _con_buf; // 消费缓冲区:后台线程从这里读数据
std::mutex _mutex; // 互斥锁:保护缓冲区的并发访问
std::condition_variable _cond_pro; // 生产者条件变量:等待缓冲区有空闲空间
std::condition_variable _cond_con; // 消费者条件变量:等待缓冲区有数据可读
std::thread _thread; // 后台消费线程
};
}//namespace my_log
设计思路
- 使用
std::shared_ptr管理 AsyncLooper 的生命周期,避免手动释放资源 _stop使用std::atomic<bool>原子变量,保证多线程下的可见性,不需要额外加锁- 两个缓冲区分别负责生产和消费,通过 swap 交换,实现零拷贝数据传输
- 两个条件变量分别控制生产者和消费者,避免不必要的唤醒,提升性能
3:线程停止函数
cpp
// 安全停止后台线程
void stop() noexcept
{
_stop = true; // 设置停止标志
_cond_con.notify_all(); // 唤醒所有阻塞的消费线程
try {
if (_thread.joinable()) // 检查线程是否可等待
_thread.join(); // 等待线程退出,确保资源释放
}
catch (...) {
// 忽略所有异常,确保析构函数不会抛出异常
// 这是C++析构函数的最佳实践:析构函数永远不应该抛出异常
}
}
noexcept关键字明确表示这个函数不会抛出异常,符合 C++11 及以后的异常规范- 先设置
_stop标志,再唤醒线程,确保线程被唤醒后能立即看到停止标志 - 使用
join()等待线程退出,避免线程变成僵尸线程,导致资源泄漏 - 捕获所有异常,确保析构函数不会抛出异常。这是非常重要的一点:如果析构函数抛出异常,会导致程序直接终止
当然这段代码,我一开始是没有加检查线程是否可以等待,导致多次join,上层使用智能指针的时候导致多次析构
4:生产者push函数
cpp
// 生产者写入数据
void push(const char* data, size_t len)
{
// 加锁:保护生产缓冲区的并发访问
std::unique_lock<std::mutex> lock(_mutex);
// 安全模式:如果缓冲区剩余空间不足,阻塞等待
if(_loopr_type == AsyncType::ASYNC_SAFE)
_cond_pro.wait(lock, [&]() {
// 等待条件:缓冲区有足够空间写入len字节,或者线程被停止
return _pro_buf.writeAbleSize() >= len || _stop;
});
// 如果线程已经停止,不再写入数据
if (_stop) return;
// 写入数据到生产缓冲区
_pro_buf.push(data, len);
// 唤醒消费者线程处理数据
_cond_con.notify_one();
}
设计思路
- 使用
std::unique_lock管理互斥锁,自动加锁解锁,避免手动管理锁导致的死锁 - 条件变量的
wait方法接受一个谓词函数,只有当谓词返回 true 时才会继续执行。这可以自动处理条件变量的虚假唤醒问题 - 安全模式下,只有当缓冲区有足够空间或者线程被停止时,才会从 wait 返回
- 写入数据后,调用
notify_one()唤醒一个等待的消费者线程处理数据 - 增加了
_stop检查:如果线程已经停止,不再写入任何数据
5:消费者入口函数
这是整个 AsyncLooper 最核心的函数,负责后台线程的所有逻辑:
cpp
// 线程函数入口
void threadEntry()
{
while (1)
{
// 1. 加锁,保护缓冲区操作
std::unique_lock<std::mutex> lock(_mutex);
// 2. 检查退出条件:线程被停止 且 生产缓冲区为空
// 注意:必须确保生产缓冲区的所有数据都被处理完再退出
if (_stop && _pro_buf.empty()) break;
// 3. 等待数据:要么线程被停止,要么生产缓冲区有数据
_cond_con.wait(lock, [&]() {
return _stop || !_pro_buf.empty();
});
// 4. 交换生产缓冲区和消费缓冲区
// 这是双缓冲区设计的核心:O(1)时间复杂度,零拷贝
_con_buf.swap(_pro_buf);
// 5. 安全模式下,唤醒所有阻塞的生产者线程
// 因为交换后生产缓冲区已经是空的,可以继续写入了
if (_loopr_type == AsyncType::ASYNC_SAFE)
_cond_pro.notify_all();
// 6. 释放锁:消费数据不需要加锁,生产者可以继续往新的生产缓冲区写数据
lock.unlock();
// 7. 调用回调函数处理消费缓冲区的数据
// 这一步是真正的IO操作,可能会比较慢,但此时已经释放了锁,不会阻塞生产者
if (_callback) {
_callback(_con_buf);
}
// 8. 重置消费缓冲区,准备下一次使用
_con_buf.reset();
}
}
设计思路
- 整个循环逻辑非常清晰:等待数据→交换缓冲区→处理数据→重置缓冲区
- 最关键的优化点:交换缓冲区后立即释放锁。这样消费线程在处理 IO 的时候,生产者可以继续往新的生产缓冲区写数据,两者完全并行,没有锁竞争
- 退出条件非常严谨:只有当线程被停止并且生产缓冲区的所有数据都被处理完之后,才会退出。这保证了程序退出时不会丢失任何日志
- 回调函数在锁外执行,避免 IO 操作持有锁导致生产者长时间阻塞
5:测试(AI生成)
1:基本测试
cpp
#include "looper.hpp"
#include <iostream>
#include <string>
// 简单的回调函数:把缓冲区的数据打印到控制台
void printCallback(Buffer& buf) {
std::cout << "收到数据:" << std::string(buf.begin(), buf.readAbleSize()) << std::endl;
}
int main() {
// 创建异步工作器,安全模式
my_log::AsyncLooper::ptr looper = std::make_shared<my_log::AsyncLooper>(printCallback);
// 写入测试数据
looper->push("Hello, AsyncLooper!", 20);
looper->push("测试日志1", 10);
looper->push("测试日志2", 10);
// 等待一下,让后台线程处理完数据
std::this_thread::sleep_for(std::chrono::seconds(1));
// 手动停止线程
looper->stop();
std::cout << "测试完成" << std::endl;
return 0;
}
2:两种异步模式对比测试
cpp
// 测试安全模式:缓冲区满时阻塞
void testSafeMode() {
std::cout << "测试安全模式..." << std::endl;
// 创建一个缓冲区很小的异步工作器(默认1MB,这里我们修改Buffer的默认大小为1KB方便测试)
my_log::AsyncLooper looper(printCallback, my_log::AsyncType::ASYNC_SAFE);
// 写入大量数据,超过缓冲区大小
for (int i = 0; i < 1000; i++) {
std::string msg = "这是第" + std::to_string(i) + "条日志\n";
looper.push(msg.c_str(), msg.size());
}
std::cout << "所有数据写入完成" << std::endl;
}
// 测试非安全模式:缓冲区无限扩容
void testUnsafeMode() {
std::cout << "测试非安全模式..." << std::endl;
my_log::AsyncLooper looper(printCallback, my_log::AsyncType::ASYNC_UNSAFE);
// 写入大量数据,不会阻塞
for (int i = 0; i < 10000; i++) {
std::string msg = "这是第" + std::to_string(i) + "条日志\n";
looper.push(msg.c_str(), msg.size());
}
std::cout << "所有数据写入完成" << std::endl;
}
6:总结
- 设计并实现了基于生产者 - 消费者模型的异步日志调度器 AsyncLooper
- 采用双缓冲区设计,将锁竞争降到最低,实现了高性能的异步日志
- 提供了两种异步模式:安全模式和非安全模式,适配不同的业务场景
- 实现了优雅的线程停止机制,确保程序退出时不会丢失日志
- 解决了条件变量虚假唤醒、死锁、线程安全等多个常见的多线程问题
- 做了全面的功能和性能测试,验证了 AsyncLooper 的正确性和高性能