C++中的异步日志系统:实现低延迟、高吞吐量的日志写入与并发控制

好的,下面开始:

C++中的异步日志系统:实现低延迟、高吞吐量的日志写入与并发控制

大家好,今天我们来探讨C++中如何构建一个高性能的异步日志系统。在高并发、低延迟的应用场景下,传统的同步日志写入方式会严重影响系统性能。异步日志系统通过将日志写入操作从主线程分离出来,从而避免阻塞主线程,提高系统的吞吐量和响应速度。

一、同步日志的局限性

首先,我们回顾一下同步日志的缺点。

  • 阻塞主线程: 同步日志写入操作会直接在主线程中执行,如果日志写入速度慢(例如,写入磁盘),主线程会被阻塞,导致系统响应延迟增加。
  • 吞吐量受限: 由于主线程被阻塞,系统的吞吐量也会受到限制。在高并发场景下,大量的日志写入操作会导致系统性能急剧下降。
  • 资源竞争: 如果多个线程同时写入同一个日志文件,可能会发生资源竞争,需要加锁保护,进一步降低性能。

二、异步日志的基本原理

异步日志的核心思想是将日志消息先放入一个缓冲区,然后由一个单独的线程(或线程池)负责将缓冲区中的消息写入到日志文件。这样,主线程只需要将日志消息放入缓冲区即可,无需等待实际的写入操作完成。

主要组件:

  • 日志消息(Log Message): 包含日志级别、时间戳、线程ID、文件名、行号以及日志内容等信息。
  • 缓冲区(Buffer): 用于存储待写入的日志消息。通常使用循环缓冲区(Circular Buffer)或双缓冲区(Double Buffer)。
  • 生产者(Producer): 负责将日志消息放入缓冲区。通常是主线程或其他业务线程。
  • 消费者(Consumer): 负责从缓冲区取出日志消息,并将它们写入到日志文件。通常是一个单独的线程或线程池。
  • 文件写入器(File Writer): 负责将日志消息写入到磁盘。

工作流程:

  1. 生产者线程生成日志消息。
  2. 生产者线程将日志消息放入缓冲区。
  3. 消费者线程从缓冲区取出日志消息。
  4. 消费者线程调用文件写入器将日志消息写入到日志文件。

三、异步日志的实现方案

下面我们介绍几种常见的异步日志实现方案,并给出相应的代码示例。

1. 单缓冲区 + 单线程

这是最简单的异步日志实现方案。只有一个缓冲区和一个消费者线程。

  • 优点: 实现简单,易于理解。
  • 缺点: 如果消费者线程写入速度慢,缓冲区可能会被填满,导致生产者线程阻塞。
#include <iostream>
#include <fstream>
#include <string>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <chrono>

class AsyncLogger {
public:
    AsyncLogger(const std::string& filename) : filename_(filename), running_(false) {}

    ~AsyncLogger() {
        stop();
    }

    void start() {
        if (running_) return;
        running_ = true;
        worker_thread_ = std::thread(&AsyncLogger::run, this);
    }

    void stop() {
        if (!running_) return;
        running_ = false;
        cv_.notify_one(); // Wake up the worker thread to exit.
        if (worker_thread_.joinable()) {
            worker_thread_.join();
        }
        // Flush any remaining messages in the queue.
        flush();
    }

    void log(const std::string& message) {
        {
            std::lock_guard<std::mutex> lock(mutex_);
            queue_.push(message);
        }
        cv_.notify_one(); // Notify the worker thread that a new message is available.
    }

private:
    void run() {
        std::ofstream outfile(filename_, std::ios::app);
        if (!outfile.is_open()) {
            std::cerr << "Error opening log file: " << filename_ << std::endl;
            return;
        }

        while (running_) {
            std::unique_lock<std::mutex> lock(mutex_);
            cv_.wait(lock, [this] { return !queue_.empty() || !running_; });

            while (!queue_.empty()) {
                std::string message = queue_.front();
                queue_.pop();
                lock.unlock(); // Unlock before writing to file to avoid blocking other threads.
                outfile << "[" << now_str() << "] " << message << std::endl;
                lock.lock();
            }
        }

        outfile.close();
    }

    void flush() {
        std::ofstream outfile(filename_, std::ios::app);
        if (!outfile.is_open()) {
            std::cerr << "Error opening log file for flushing: " << filename_ << std::endl;
            return;
        }

        std::lock_guard<std::mutex> lock(mutex_);
        while (!queue_.empty()) {
            std::string message = queue_.front();
            queue_.pop();
            outfile << "[" << now_str() << "] " << message << std::endl;
        }

        outfile.close();
    }

    std::string now_str() {
        auto now = std::chrono::system_clock::now();
        auto now_c = std::chrono::system_clock::to_time_t(now);
        std::tm now_tm;
        localtime_r(&now_c, &now_tm);
        char buf[64];
        strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &now_tm);
        return buf;
    }

private:
    std::string filename_;
    std::queue<std::string> queue_;
    std::mutex mutex_;
    std::condition_variable cv_;
    std::thread worker_thread_;
    bool running_;
};

int main() {
    AsyncLogger logger("async_log.txt");
    logger.start();

    for (int i = 0; i < 100; ++i) {
        logger.log("Log message " + std::to_string(i));
        std::this_thread::sleep_for(std::chrono::milliseconds(10)); // Simulate some work
    }

    logger.stop();

    return 0;
}

2. 双缓冲区 + 单线程

为了解决单缓冲区方案的阻塞问题,可以使用双缓冲区。有两个缓冲区,一个用于生产者写入,另一个用于消费者读取。当一个缓冲区被填满时,生产者切换到另一个缓冲区。

  • 优点: 生产者线程不会被阻塞,吞吐量更高。
  • 缺点: 实现相对复杂一些。
#include <iostream>
#include <fstream>
#include <string>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <vector>
#include <chrono>

class DoubleBufferAsyncLogger {
public:
    DoubleBufferAsyncLogger(const std::string& filename, size_t buffer_size = 4096)
        : filename_(filename), buffer_size_(buffer_size), current_buffer_(0), running_(false) {
        buffers_[0].resize(buffer_size_);
        buffers_[1].resize(buffer_size_);
    }

    ~DoubleBufferAsyncLogger() {
        stop();
    }

    void start() {
        if (running_) return;
        running_ = true;
        worker_thread_ = std::thread(&DoubleBufferAsyncLogger::run, this);
    }

    void stop() {
        if (!running_) return;
        running_ = false;
        cv_.notify_one();
        if (worker_thread_.joinable()) {
            worker_thread_.join();
        }
        flush();
    }

    void log(const std::string& message) {
        std::lock_guard<std::mutex> lock(mutex_);
        if (current_buffer_size_ + message.size() + 1 > buffer_size_) {
            // Switch buffers if the current buffer is full.
            buffers_full_ = true;
            cv_.notify_one();  // Notify the worker thread to swap buffers.
            buffer_ready_cv_.wait(lock, [this] { return !buffers_full_; }); //Wait for buffer to be ready
        }

        std::string formatted_message = "[" + now_str() + "] " + message + "n";
        std::memcpy(buffers_[current_buffer_].data() + current_buffer_size_, formatted_message.data(), formatted_message.size());
        current_buffer_size_ += formatted_message.size();

    }

private:
    void run() {
        std::ofstream outfile(filename_, std::ios::app);
        if (!outfile.is_open()) {
            std::cerr << "Error opening log file: " << filename_ << std::endl;
            return;
        }

        size_t buffer_to_write = 1 - current_buffer_; // Start with the inactive buffer.

        while (running_) {
            std::unique_lock<std::mutex> lock(mutex_);
            cv_.wait(lock, [this] { return buffers_full_ || !running_; }); //Wait for buffer to be full or thread to stop

            buffers_full_ = false; // Reset the flag

            lock.unlock(); //Unlock before writing to disk

            outfile.write(buffers_[buffer_to_write].data(), current_buffer_size_);
            outfile.flush();

            lock.lock(); //Reacquire lock before modifying shared state

            current_buffer_size_ = 0;
            buffer_to_write = current_buffer_;
            current_buffer_ = 1- current_buffer_; //Switch buffers
            buffer_ready_cv_.notify_one(); // Notify logging thread that the buffer is now ready

        }

        outfile.close();
    }

    void flush() {
        std::ofstream outfile(filename_, std::ios::app);
        if (!outfile.is_open()) {
            std::cerr << "Error opening log file for flushing: " << filename_ << std::endl;
            return;
        }

        std::lock_guard<std::mutex> lock(mutex_);
        outfile.write(buffers_[1-current_buffer_].data(), current_buffer_size_); //Write the remaining buffer
        outfile.close();
    }

    std::string now_str() {
        auto now = std::chrono::system_clock::now();
        auto now_c = std::chrono::system_clock::to_time_t(now);
        std::tm now_tm;
        localtime_r(&now_c, &now_tm);
        char buf[64];
        strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &now_tm);
        return buf;
    }

private:
    std::string filename_;
    size_t buffer_size_;
    std::vector<char> buffers_[2];
    size_t current_buffer_;
    size_t current_buffer_size_ = 0;
    std::mutex mutex_;
    std::condition_variable cv_;
    std::condition_variable buffer_ready_cv_;
    std::thread worker_thread_;
    bool running_;
    bool buffers_full_ = false;
};

int main() {
    DoubleBufferAsyncLogger logger("double_buffer_log.txt", 8192);
    logger.start();

    for (int i = 0; i < 1000; ++i) {
        logger.log("Log message " + std::to_string(i));
        std::this_thread::sleep_for(std::chrono::microseconds(100)); // Simulate some work
    }

    logger.stop();

    return 0;
}

3. 循环缓冲区 + 线程池

循环缓冲区可以视为一个固定大小的队列,生产者将消息放入队列尾部,消费者从队列头部取出消息。线程池可以提高并发处理能力,多个消费者线程可以同时从缓冲区取出消息并写入文件。

  • 优点: 高吞吐量,并发处理能力强。
  • 缺点: 实现复杂,需要仔细考虑线程同步和资源管理。

这里提供一个简化的线程池实现,并结合循环缓冲区完成异步日志的示例:

#include <iostream>
#include <fstream>
#include <string>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <vector>
#include <queue>
#include <functional>
#include <chrono>

// Simplified Thread Pool
class ThreadPool {
public:
    ThreadPool(size_t num_threads) : stop_(false) {
        threads_.reserve(num_threads);
        for (size_t i = 0; i < num_threads; ++i) {
            threads_.emplace_back([this]() {
                while (true) {
                    std::function<void()> task;
                    {
                        std::unique_lock<std::mutex> lock(queue_mutex_);
                        cv_.wait(lock, [this]() { return stop_ || !tasks_.empty(); });
                        if (stop_ && tasks_.empty()) return;
                        task = std::move(tasks_.front());
                        tasks_.pop();
                    }
                    task();
                }
            });
        }
    }

    ~ThreadPool() {
        {
            std::unique_lock<std::mutex> lock(queue_mutex_);
            stop_ = true;
        }
        cv_.notify_all();
        for (std::thread& thread : threads_) {
            thread.join();
        }
    }

    template<typename F>
    void enqueue(F&& f) {
        {
            std::unique_lock<std::mutex> lock(queue_mutex_);
            tasks_.emplace(std::forward<F>(f));
        }
        cv_.notify_one();
    }

private:
    std::vector<std::thread> threads_;
    std::queue<std::function<void()>> tasks_;
    std::mutex queue_mutex_;
    std::condition_variable cv_;
    bool stop_;
};

// Circular Buffer
template <typename T>
class CircularBuffer {
public:
    CircularBuffer(size_t capacity) : capacity_(capacity), buffer_(capacity), head_(0), tail_(0), size_(0) {}

    bool is_empty() const {
        std::lock_guard<std::mutex> lock(mutex_);
        return size_ == 0;
    }

    bool is_full() const {
        std::lock_guard<std::mutex> lock(mutex_);
        return size_ == capacity_;
    }

    bool enqueue(const T& item) {
        std::lock_guard<std::mutex> lock(mutex_);
        if (is_full()) return false;

        buffer_[tail_] = item;
        tail_ = (tail_ + 1) % capacity_;
        ++size_;
        return true;
    }

    bool dequeue(T& item) {
        std::lock_guard<std::mutex> lock(mutex_);
        if (is_empty()) return false;

        item = buffer_[head_];
        head_ = (head_ + 1) % capacity_;
        --size_;
        return true;
    }

private:
    size_t capacity_;
    std::vector<T> buffer_;
    size_t head_;
    size_t tail_;
    size_t size_;
    std::mutex mutex_;
};

class ThreadPoolAsyncLogger {
public:
    ThreadPoolAsyncLogger(const std::string& filename, size_t buffer_size = 4096, size_t num_threads = 4)
        : filename_(filename), buffer_(buffer_size), pool_(num_threads), running_(false) {}

    ~ThreadPoolAsyncLogger() {
        stop();
    }

    void start() {
        if (running_) return;
        running_ = true;
    }

    void stop() {
        if (!running_) return;
        running_ = false;
        // Wait for all tasks to complete (optional, could also just terminate the pool)
        std::this_thread::sleep_for(std::chrono::seconds(1)); //Give it a second to flush

    }

    void log(const std::string& message) {
        std::string formatted_message = "[" + now_str() + "] " + message + "n";
        while (!buffer_.enqueue(formatted_message)) {
            std::this_thread::yield(); // Buffer is full, yield and try again
        }
    }

private:
    std::string now_str() {
        auto now = std::chrono::system_clock::now();
        auto now_c = std::chrono::system_clock::to_time_t(now);
        std::tm now_tm;
        localtime_r(&now_c, &now_tm);
        char buf[64];
        strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &now_tm);
        return buf;
    }

    std::string filename_;
    CircularBuffer<std::string> buffer_;
    ThreadPool pool_;
    bool running_;
    std::mutex  file_mutex_; // Protect file access

    // File writing task (executed by thread pool)

    void write_task(const std::string& filename, const std::string& message) {
          std::lock_guard<std::mutex> lock(file_mutex_); //Protect file writes
          std::ofstream outfile(filename, std::ios::app);
          if (outfile.is_open()) {
              outfile << message;
              outfile.close();
          } else {
              std::cerr << "Error opening log file: " << filename << std::endl;
          }
    }

    // Start the file writing task in the thread pool.
    void log(const std::string& filename, CircularBuffer<std::string>& buffer)
    {
        std::string message;
        while(running_)
        {
            if(buffer.dequeue(message))
            {
                pool_.enqueue([this, filename, message]() {
                    write_task(filename, message);
                });
            }
            else
            {
                std::this_thread::yield(); //Nothing to write
            }

        }
        //Flush any remaining messages
          while(buffer.dequeue(message))
          {
                pool_.enqueue([this, filename, message]() {
                    write_task(filename, message);
                });
          }

    }

    //Create an instance of this, this allows us to call the logger from the main thread.
    struct PoolStarter{
        PoolStarter(ThreadPoolAsyncLogger* logger, std::string filename, CircularBuffer<std::string>& buffer)
        {
            thread_ = std::thread(&ThreadPoolAsyncLogger::log, logger, filename,std::ref(buffer));
        }
        ~PoolStarter()
        {
            thread_.join();
        }
        std::thread thread_;

    };
    PoolStarter starter_{this, filename_, buffer_};

};

int main() {
    ThreadPoolAsyncLogger logger("thread_pool_log.txt", 8192, 4);
    logger.start();

    for (int i = 0; i < 1000; ++i) {
        logger.log("Log message " + std::to_string(i));
        std::this_thread::sleep_for(std::chrono::microseconds(100)); // Simulate some work
    }

    logger.stop();

    return 0;
}

四、并发控制

在高并发环境下,需要仔细考虑并发控制,避免数据竞争和死锁。

  • 互斥锁(Mutex): 用于保护共享资源,例如缓冲区和日志文件。
  • 条件变量(Condition Variable): 用于线程间的同步,例如通知消费者线程缓冲区中有新的消息。
  • 原子操作(Atomic Operations): 用于对简单变量进行原子操作,例如计数器。

在上面的代码示例中,我们使用了互斥锁和条件变量来保护缓冲区和同步生产者线程和消费者线程。

五、性能优化

以下是一些常见的性能优化技巧:

  • 减少内存拷贝: 尽量避免在日志消息传递过程中进行内存拷贝。可以使用零拷贝技术,例如使用指针传递消息。
  • 批量写入: 将多个日志消息合并成一个大的写入操作,可以减少IO次数,提高写入效率。
  • 使用更快的存储介质: 例如,使用SSD代替HDD。
  • 调整缓冲区大小: 根据实际情况调整缓冲区大小,以达到最佳性能。
  • 日志级别过滤: 在生产者线程中进行日志级别过滤,可以减少不必要的日志写入操作。
  • 避免频繁的文件打开和关闭: 尽可能保持日志文件处于打开状态,避免频繁的文件打开和关闭操作。

六、日志格式

日志格式应该易于阅读和解析。常见的日志格式包括:

  • 文本格式: 简单易懂,但解析效率较低。
  • JSON格式: 易于解析,但占用空间较大。
  • Protocol Buffers格式: 高效的序列化格式,但需要定义protobuf文件。

七、日志滚动

为了防止日志文件过大,需要进行日志滚动。常见的日志滚动策略包括:

  • 按文件大小滚动: 当日志文件达到指定大小时,创建一个新的日志文件。
  • 按时间滚动: 每天、每周或每月创建一个新的日志文件。
  • 按文件数量滚动: 保持指定数量的日志文件,当超出数量时,删除最旧的日志文件。

代码示例(简单的按文件大小滚动):

#include <iostream>
#include <fstream>
#include <string>
#include <thread>
#include <mutex>
#include <chrono>
#include <sstream>
#include <iomanip>

class RollingFileLogger {
public:
    RollingFileLogger(const std::string& base_filename, size_t max_file_size)
        : base_filename_(base_filename), max_file_size_(max_file_size), current_file_size_(0), file_index_(0) {
        open_new_file();
    }

    ~RollingFileLogger() {
        if (outfile_.is_open()) {
            outfile_.close();
        }
    }

    void log(const std::string& message) {
        std::lock_guard<std::mutex> lock(mutex_);
        std::string formatted_message = "[" + now_str() + "] " + message + "n";

        if (current_file_size_ + formatted_message.size() > max_file_size_) {
            outfile_.close();
            file_index_++;
            open_new_file();
            current_file_size_ = 0;
        }

        outfile_ << formatted_message;
        current_file_size_ += formatted_message.size();
    }

private:
    void open_new_file() {
        std::stringstream filename_stream;
        filename_stream << base_filename_ << "." << std::setw(3) << std::setfill('0') << file_index_;
        outfile_.open(filename_stream.str(), std::ios::app);
        if (!outfile_.is_open()) {
            std::cerr << "Error opening log file: " << filename_stream.str() << std::endl;
        }
    }

    std::string now_str() {
        auto now = std::chrono::system_clock::now();
        auto now_c = std::chrono::system_clock::to_time_t(now);
        std::tm now_tm;
        localtime_r(&now_c, &now_tm);
        char buf[64];
        strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &now_tm);
        return buf;
    }

private:
    std::string base_filename_;
    size_t max_file_size_;
    size_t current_file_size_;
    int file_index_;
    std::ofstream outfile_;
    std::mutex mutex_;
};

int main() {
    RollingFileLogger logger("rolling_log.txt", 1024 * 100); // Max file size: 100KB

    for (int i = 0; i < 1000; ++i) {
        logger.log("Log message " + std::to_string(i));
        std::this_thread::sleep_for(std::chrono::milliseconds(1));
    }

    return 0;
}

八、总结:选择合适的策略至关重要

异步日志是构建高性能应用的关键组成部分。 通过将日志写入操作从主线程分离出来,可以显著提高系统的吞吐量和响应速度。 选择合适的异步日志实现方案取决于具体的应用场景和性能要求。 在选择方案的时候,注意选择合适的并发控制策略,避免数据竞争和死锁。

九、总结:异步日志的实践要点

异步日志系统需要仔细的设计和实现。 需要考虑缓冲区的选择,并发控制,性能优化以及日志滚动等问题。 通过合理的配置和优化,可以构建一个高效、可靠的异步日志系统,为应用程序提供强大的日志支持。

更多IT精英技术系列讲座,到智猿学院

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注