不简单改改
从这一章开始,我们得考虑多线程写日志了,当我们的应用程序开始处理更多的并发操作时,日志系统也需要升级以应对多线程环境。在多线程写日志的情境下,我们面临的主要挑战是如何保证日志数据的一致性和完整性,同时又不过度牺牲性能。接下来,我们将一步一步探索如何在多线程环境中有效地实现日志记录。
异步日志
spdlog
的异步机制是设计来提高日志记录的性能和效率的。在同步日志记录中,每次记录日志时,执行线程都会直接写入日志目标(如文件、控制台等),这可能会因为 IO 操作而阻塞执行线程。而异步日志记录通过将日志消息首先发送到一个队列中,然后由另一个专门的线程从队列中取出消息并进行日志记录,从而避免了直接的 IO 操作阻塞执行线程。
关键组件
-
预分配队列:
spdlog
使用一个预分配的队列来存储日志消息。这个队列通常是一个固定大小的环形队列,当队列满时,它将根据配置的溢出策略(如阻塞、丢弃最旧的消息或丢弃新消息)来处理新的日志消息。 -
后台日志线程:
spdlog
创建一个后台线程,专门负责从队列中取出日志消息并将它们写入到指定的日志目标中。这个分离的线程允许执行线程无需等待 IO 操作即可继续执行,从而提高了应用程序的性能。 -
溢出策略:
spdlog
提供了几种不同的队列溢出策略,允许开发者根据应用程序的需求来选择最合适的策略。这些策略包括阻塞写入操作直到队列有空间、丢弃队列中最旧的消息来为新消息腾出空间,或者丢弃尝试添加的新消息。
我们来看代码实现
class SPDLOG_API async_logger final : public std::enable_shared_from_this<async_logger>,
public logger {
friend class details::thread_pool;
public:
template <typename It>
async_logger(std::string logger_name,
It begin,
It end,
std::weak_ptr<details::thread_pool> tp,
async_overflow_policy overflow_policy = async_overflow_policy::block)
: logger(std::move(logger_name), begin, end),
thread_pool_(std::move(tp)),
overflow_policy_(overflow_policy) {}
async_logger(std::string logger_name,
sinks_init_list sinks_list,
std::weak_ptr<details::thread_pool> tp,
async_overflow_policy overflow_policy = async_overflow_policy::block);
async_logger(std::string logger_name,
sink_ptr single_sink,
std::weak_ptr<details::thread_pool> tp,
async_overflow_policy overflow_policy = async_overflow_policy::block);
std::shared_ptr<logger> clone(std::string new_name) override;
protected:
void sink_it_(const details::log_msg &msg) override;
void flush_() override;
void backend_sink_it_(const details::log_msg &incoming_log_msg);
void backend_flush_();
private:
std::weak_ptr<details::thread_pool> thread_pool_;
async_overflow_policy overflow_policy_;
};
} // namespace spdlog
async_logger
->logger
->thread_pool
,通过线程池来循环处理队列中的日志消息
void SPDLOG_INLINE thread_pool::worker_loop_() {
while (process_next_msg_()) {
}
}
// process next message in the queue
// return true if this thread should still be active (while no terminate msg
// was received)
bool SPDLOG_INLINE thread_pool::process_next_msg_() {
async_msg incoming_async_msg;
q_.dequeue(incoming_async_msg);
switch (incoming_async_msg.msg_type) {
case async_msg_type::log: {
incoming_async_msg.worker_ptr->backend_sink_it_(incoming_async_msg);
return true;
}
case async_msg_type::flush: {
incoming_async_msg.worker_ptr->backend_flush_();
return true;
}
case async_msg_type::terminate: {
return false;
}
default: {
assert(false);
}
}
return true;
}
加上线程池
一步一步来,异步很容易想到用独立的线程去干活,那我们先给代码加上线程池
#include "ThreadPool.h" // 开源库线程池,C11实现,100行代码不到,基本功能都有
static ThreadPool log_pool(1); // 创建线程池,里面默认一个线程
给线程池分配任务,来了日志就分配线程去写
log_pool.enqueue([level, format, args...] {
if (log_file.is_open()) {
auto log_entry = fmt::format("[{}] [{}] {}\n", currentDateTime(), toString(level), fmt::format(format, args...));
log_file << log_entry;
} else {
std::cerr << "无法打开日志文件!" << std::endl;
}
});
我们代码变成了这样
#include <iostream>
#include <fstream>
#include <string>
#include <ctime>
#include <fmt/core.h> // 包含 fmt 库的核心功能
#include "ThreadPool.h" // 包含 ThreadPool 类
static std::ofstream log_file("log.txt", std::ios::app); // 静态日志文件对象
static ThreadPool log_pool(1); // 创建一个拥有 1 个工作线程的线程池用于日志
enum LogLevel {
INFO,
WARNING,
ERROR
};
// 日志级别到字符串的转换
const char* toString(LogLevel level) {
switch(level) {
case INFO: return "INFO";
case WARNING: return "WARNING";
case ERROR: return "ERROR";
default: return "UNKNOWN";
}
}
// 获取格式化的当前时间字符串
std::string currentDateTime() {
std::time_t now = std::time(nullptr);
std::string dt = std::ctime(&now);
dt.pop_back(); // 移除换行符
return dt;
}
// 使用模板和参数包来支持格式化的参数
template <typename... Args>
void log_message(LogLevel level, const std::string& format, Args... args) {
// 线程池添加任务,每次写日志时用独立线程去写
log_pool.enqueue([level, format, args...] {
if (log_file.is_open()) {
auto log_entry = fmt::format("[{}] [{}] {}\n", currentDateTime(), toString(level), fmt::format(format, args...));
log_file << log_entry;
} else {
std::cerr << "无法打开日志文件!" << std::endl;
}
});
}
int main() {
log_message(INFO, "这是一条信息级别的消息。");
log_message(ERROR, "错误代码:{}. 错误信息:{}", 404, "未找到");
// 确保在退出前处理所有日志消息
// 在真实应用中,这可能会以不同方式处理,例如,在应用关闭时等待所有任务完成。
std::this_thread::sleep_for(std::chrono::seconds(1)); // 简单的等待日志完成的方式(生产使用不推荐)
return 0;
}
> [Fri Mar 8 14:00:25 2024] [INFO] 这是一条信息级别的消息。
> [Fri Mar 8 14:00:25 2024] [ERROR] 错误代码:404. 错误信息:未找到
关键变化和注意事项
- 日志的线程池:我们初始化了一个拥有一个工作线程的
ThreadPool
,专门用于处理日志任务。这将日志 I/O 操作卸载到另一个线程,可能提高主应用程序逻辑的性能。 - 异步日志记录:
log_message
函数现在将日志任务加入到线程池的队列中。每个任务捕获日志级别、格式和参数,然后在工作线程中格式化和写入日志条目。 - 等待日志完成:在此简单示例中,我们使用
std::this_thread::sleep_for
来稍等一会,确保程序退出前所有日志消息都被处理。在实际情况中,需要实现更复杂的方式来确保应用退出前所有任务完成,可能涉及跟踪任务或优雅地关闭线程池。 - 错误处理和鲁棒性:为了简单起见,某些方面如错误处理(例如,如果无法打开日志文件该怎么办)和溢出策略(例如,限制排队任务的数量或处理队列已满的情况)在这里没有广泛涉及。
- 和
spdlog
的区别:这两者线程模型还是有点区别的,spdlog
是把日志写进消息队列,线程池里面的线程处理消息,而我们的线程池是将处理的函数放进tasks
,执行task
,目前两者都能实现目标。
溢出策略
我们再来实现下溢出策略,首先我们得知道当前任务有多少个,才能做判断。那么这个判断放哪呢?
template <typename... Args>
void log_message(LogLevel level, const std::string& format, Args... args) {
// 获取tasks的大小,如果溢出,return?
log_pool.enqueue([level, format, args...] {
// 获取tasks的大小,如果溢出,return?
if (log_file.is_open()) {
auto log_entry = fmt::format("[{}] [{}] {}\n", currentDateTime(), toString(level), fmt::format(format, args...));
log_file << log_entry;
} else {
std::cerr << "无法打开日志文件!" << std::endl;
}
});
}
放在这两地方首先得解决几个问题,一个是tasks.size()
我们目前访问不到,另一个是获取大小得加锁。我们看看spdlog
放哪
void SPDLOG_INLINE thread_pool::post_async_msg_(async_msg &&new_msg,
async_overflow_policy overflow_policy) {
if (overflow_policy == async_overflow_policy::block) {
q_.enqueue(std::move(new_msg));
} else if (overflow_policy == async_overflow_policy::overrun_oldest) {
q_.enqueue_nowait(std::move(new_msg));
} else {
assert(overflow_policy == async_overflow_policy::discard_new);
q_.enqueue_if_have_room(std::move(new_msg));
}
}
他放在线程池里,入队前,由消息队列来判断要不要入队。那我们也放在任务入队的时候,由 enqueue
决定阻塞策略,这样都在类内部实现了,外面就不需要关心阻塞这件事了。而且还要好处,入队时本身就要上锁,放在这也减少加锁次数。对了,还得设置一个上限,也放线程池里,初始化的时候设置。代码如下:
#ifndef THREAD_POOL_H
#define THREAD_POOL_H
#include <vector>
#include <queue>
#include <memory>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <future>
#include <functional>
#include <stdexcept>
// Async overflow policy - block by default.
enum class async_overflow_policy {
block, // Block task can be enqueued
overrun_oldest, // Discard oldest task in the queue if full when trying to
// add new item.
discard_new // Discard new task if the queue is full when trying to add new item.
};
class ThreadPool {
public:
ThreadPool(size_t threads, size_t maxQueueSize = 1000, async_overflow_policy policy = async_overflow_policy::block);
template<class F, class... Args>
auto enqueue(F&& f, Args&&... args)
-> std::future<typename std::result_of<F(Args...)>::type>;
~ThreadPool();
private:
// need to keep track of threads so we can join them
std::vector< std::thread > workers;
// the task queue
std::queue< std::function<void()> > tasks;
// synchronization
std::mutex queue_mutex;
std::condition_variable condition;
bool stop;
// overflow policy
size_t maxQueueSize;
async_overflow_policy overflow_policy;
};
// the constructor just launches some amount of workers
inline ThreadPool::ThreadPool(size_t threads, size_t maxQueueSize, async_overflow_policy policy)
: stop(false), maxQueueSize(maxQueueSize), overflow_policy(policy)
{
for(size_t i = 0;i<threads;++i)
workers.emplace_back(
[this]
{
for(;;)
{
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(this->queue_mutex);
this->condition.wait(lock,
[this]{ return this->stop || !this->tasks.empty(); });
if(this->stop && this->tasks.empty())
return;
task = std::move(this->tasks.front());
this->tasks.pop();
}
task();
}
}
);
}
// add new work item to the pool
template<class F, class... Args>
auto ThreadPool::enqueue(F&& f, Args&&... args)
-> std::future<typename std::result_of<F(Args...)>::type>
{
using return_type = typename std::result_of<F(Args...)>::type;
auto task = std::make_shared< std::packaged_task<return_type()> >(
std::bind(std::forward<F>(f), std::forward<Args>(args)...)
);
std::future<return_type> res = task->get_future();
{
std::unique_lock<std::mutex> lock(queue_mutex);
if(stop)
throw std::runtime_error("enqueue on stopped ThreadPool");
// 应用不同的溢出策略
if(tasks.size() >= maxQueueSize) {
switch (overflow_policy) {
case async_overflow_policy::block:
// 阻塞直到有空间
condition.wait(lock, [this]{ return tasks.size() < maxQueueSize; });
break;
case async_overflow_policy::overrun_oldest:
// 溢出最旧的任务
tasks.pop();
break;
case async_overflow_policy::discard_new:
// 丢弃新任务
return std::future<return_type>(); // 返回一个空的 future
}
}
tasks.emplace([task](){ (*task)(); });
}
condition.notify_one();
return res;
}
// the destructor joins all threads
inline ThreadPool::~ThreadPool()
{
{
std::unique_lock<std::mutex> lock(queue_mutex);
stop = true;
}
condition.notify_all();
for(std::thread &worker: workers)
worker.join();
}
#endif
外面将 main.cpp 里面的改成 1 个线程,只允许一个任务,新任务丢弃用来测试
static ThreadPool log_pool(1, 1, async_overflow_policy::discard_new); // 创建一个拥有 1 个工作线程的线程池用于日志
输出情况,只有一条日志,另一条被丢弃了
> [Fri Mar 8 17:24:57 2024] [INFO] 这是一条信息级别的消息。