C++工业级日志项目(六)异步日志器

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 的正确性和高性能
相关推荐
s_w.h5 小时前
【 linux 】文件系统
linux·运维·服务器·算法·bash
fastjson_5 小时前
Win11 关闭拖动窗口自动出现的贴靠窗口分栏
windows
PAK向日葵5 小时前
从零实现 Python 虚拟机(二):S.A.A.U.S.O 的总体架构设计
c++·python
无限进步_5 小时前
【C++】weak_ptr、循环引用与线程安全
开发语言·数据结构·c++·算法·安全
都在酒里6 小时前
Linux字符设备驱动开发(七):输入子系统——驱动GPIO按键并上报事件
linux·驱动开发·交互
风曦Kisaki6 小时前
# Linux运维Day06:HAproxy负载均衡(代理调度软件对比)、Tomcat服务部署与LNMJ架构
linux·运维·负载均衡
早睡身体真不戳6 小时前
【无标题】
java·服务器·windows
不总是6 小时前
JDK17在Windows 系统 安装与环境变量配置
windows
咩咦6 小时前
C++学习笔记30:友元类、内部类和封装
c++·学习笔记·类和对象·封装·内部类·友元类·friend