使用可变模板进行延迟日志记录





5.00/5 (8投票s)
引言
最近我阅读了 valdok 的精彩文章,他介绍了一个只在真正需要时才记录信息的日志引擎 [https://codeproject.org.cn/Articles/64573/Superior-logging-design-Fast-but-comprehensive]。简单来说,它的主要思想是提供一种机制,你可以添加包含字符串格式化(!)的检查点,并且只有在需要时才会进行字符串格式化和写入日志。如果没有发生错误,日志信息既不会被格式化也不会被写入。这是一个巨大的优势,因为字符串格式化和写入非常耗费资源。此外,在写入所有日志条目时,日志文件会膨胀,其中包含大量对调试过程完全没有用的信息。因此,提供了一个快速的日志系统,但你需要付出很多缺失的功能的代价,这些功能需要复杂的运算。如果你对所有细节和该想法的讨论感兴趣,请参考 valdok 的文章。
然而,我尝试实现代码时遇到了一个问题,即在 unix x64;gcc/4.9.2 上,将 int*
转换为 va_list
无法编译。我必须承认,我没有努力直接解决这个问题,因为我更愿意使用 C++11 中引入的 *可变参数模板*。我在这里提供的代码完全没有进行类型转换,并且可以在 unix x64 机器上使用 gcc/4.9.2 *以及* 在 Windows 上使用 VC++ 2015 进行编译。此外,该代码还有一个优势,因为任何对象(类/结构体)都可以通过提供合适的 operator<<
以标准的 C++ 方式用于日志记录。
实现
所有延迟的日志信息都包含在所谓的 *检查点* 中。每个线程都有自己的检查点堆栈,完全构建在堆栈上(无需任何内存分配或释放)。这个检查点堆栈(CS)是通过定义 ICheckpoint 接口来构建的,该接口会自动在 CS 中注册和注销检查点。
struct ICheckpoint
{
inline ICheckpoint()
{
m_pNext = s_pTop;
s_pTop = this;
}
inline ~ICheckpoint()
{
// As all Checkpoints reside on the stack, stack unwinding guarantees that all elements are deleted in the correct order!
// Consequently, the assert should only fire when something was done wrong.
assert(this == s_pTop);
s_pTop = m_pNext;
}
// First stack entry
// the keyword thread_local ensures that each thread has its own stack automatically as the pointer is stored in the TLS (thread local storage)
thread_local static ICheckpoint* s_pTop;
// Next Checkpoint after this one in the Checkpoint Stack
ICheckpoint* m_pNext;
// Formats and writes the Checkpoint to file
// NOT thread safe
virtual void print(std::ostream& out) = 0;
};
在 C++11 中引入了 thread_local
关键字,这样就不再依赖于特定平台的实现,例如 __declspec(thread)
(MSVC)
纯虚方法 print
指示检查点格式化给定的表达式并将所有内容写入 ostream out
。对我来说,仅使用 ostream 就足够了,但你可以轻松地使用自己的流。
下一步是实现定义 CCheckpoint
类的检查点。它将 *未格式化* 表达式的所有变量保存为引用。只有当用户决定将检查点写入文件时,它们才会被使用。
template<typename... Arguments>
class CCheckpoint : public ICheckpoint
{
public:
// Just copy all arguments to the m_args tuple.
inline CCheckpoint(const Arguments&... args)
: m_args(std::forward<const Arguments&>(args)...)
{
}
// NOT Thread safe!
virtual void print(std::ostream& out) override
{
out << "\t";
// Recursive expansion of the std::tuple
detail<sizeof...(Arguments) == 0, sizeof...(Arguments), 0, std::tuple<const Arguments&...> >::recursivePrint(out, m_args);
}
private:
// Here the references to all Arguments are safed
std::tuple<const Arguments&...> m_args;
};
// helper function to create CCheckpoint which deduces the template parameters automatically
template<typename... Arguments>
inline CCheckpoint<Arguments...> Checkpoint(const Arguments&... args)
{
return CCheckpoint<Arguments...>(std::forward<const Arguments&>(args)...);
}
为了说明可变参数模板的工作原理,这里有一个 CCheckpoint
构造函数工作方式的例子。
// First example, unfortunately classes do not automatically deduce template parameters, therefore here all template parameters have to be specified explicitly
CCheckpoint< ... > ("Text, number, decimals ", __LINE__, " ", 0.56f, " ", 0.123);
// Second example, here the template parameters are deduced automatically because Checkpoint is a function
auto chkp = Checkpoint("Text, number, decimals ", __LINE__, " ", 0.56f, " ", 0.123);
构造函数只是将所有给定参数的引用复制到 std::tuple
。由于它继承自 ICheckpoint
,模板化的 CCheckpoint
会自动注册到 CS。print
函数格式化字符串,并在请求时将其写入文件。
理解 CCheckpoint 只保存引用而不是深层副本是非常重要的。 因此,传递给 CCheckpoint
的所有对象在 CCheckpoint
被销毁之前必须保持有效,例如:
{
int* p = new int(10);
auto chkp = Checkpoint(*p);
delete p; p = nullptr;
PrintCheckpoints(); // Function will be explained below.
}
将会崩溃,因为整数值没有被复制,并且在检查点被写入时指针已经被删除。然而,只要你只使用栈对象,你就不必担心。
为了格式化字符串,m_args
被递归展开,并且每个元素被单独写入流。让我们看一下 recursivePrint
方法。
// NOT Thread safe!
template<bool Last, size_t NTotal, size_t N, typename Tuple>
struct detail
{
static inline void recursivePrint(std::ostream& out, Tuple& t)
{
out << std::get<N>(t);
detail<N == NTotal - 1, NTotal, N + 1, Tuple>::recursivePrint(out, t);
}
};
// Specialization which stops the recursion
// NOT Thread safe!
template<size_t NTotal, size_t N, typename Tuple>
struct detail<true, NTotal, N, Tuple>
{
static inline void recursivePrint(std::ostream& out, Tuple& t)
{
out << std::endl;
}
};
recursivePrint
位于 detail 结构中,该结构接受四个模板参数:Last
(如果 m_args
中的 *所有* 值都已写入,则为 true,否则为 false)、NTotal
(元组中元素的总数)、N
(要写入的元素的索引)和 Tuple
。Tuple
参数是为了方便,因为我们不想处理所有参数的所有类型,而是让编译器来完成这项工作。recursivePrint
的第二个定义明确要求 bool Last
为 true。它只是停止递归,并在 ostream 中添加一个换行符。请注意,模板特化仅允许用于类和结构体,而 *不* 允许用于函数,因此定义静态成员函数而不是全局 recursivePrint
函数是必要的。当没有提供 recursivePrint
的特化版本时,代码也不会编译,因为编译器知道当 N = NTotal
时,调用 std::get<T>(t);
会产生越界情况。
在这一点上,我还要指出这个实现的另一个优点:你可以将 *任何* 类型传递给 Checkpoint
,只要你提供匹配的 operator<<
,代码就会编译,所以代码不限于字符串、整数和十进制数。
recursivePrint
首先从 CCheckpoint::print
方法调用,命令如下:
detail<sizeof...(Arguments) == 0, sizeof...(Arguments), 0, std::tuple<const Arguments&...> >::recursivePrint(out, m_args);
我将简要讨论这里发生的事情。sizeof...(Arguments)
运算符返回 Arguments
中指定的模板参数的数量。如果根本没有提供模板参数,则该运算符返回 0,并且 recursivePrint
的特化版本将立即被调用。NTotal
也由 sizeof...
运算符确定。重要的是,数组将从 0 扩展到 NTotal
,以在输出日志文件中重现正确的顺序。
在 recursivePrint
中,下一个递归调用是通过
detail<N == NTotal - 1, NTotal, N + 1, Tuple>::recursivePrint(out, t);
并简单地递增 N
。
所有检查点是如何写入文件的?这项工作由 PrintCheckpoints
方法完成。
void PrintCheckpoints()
{
for(ICheckpoint* pCP = ICheckpoint::s_pTop; pCP != nullptr; pCP = pCP->m_pNext)
pCP->print(*g_pErrorOut.get());
}
这里有一个非常重要的方面需要提及:ICheckpoint::s_pTop
被声明为 thread_local
。这很好,因为每个线程都有自己的 CS。然而,要将检查点写入日志,该方法必须由 *将要写入其检查点的同一线程* 调用,而不是由任何其他线程调用!
最后,我给你们展示用于在代码中创建检查点的 CHECKPOINT 宏。为什么我们需要宏?因为检查点必须 *驻留在堆栈上*,这意味着你必须为每个检查点自己创建一个变量名。然而,宏会处理这个问题。我想向你们展示定义的原因是,该宏使用了 C++11 的 *可变参数宏* 功能,对应于可变参数模板。
#define CHECKPOINT(...) \
auto RAND_IDENTIFIER(chkpt) = logging::Checkpoint(__VA_ARGS__);
其中 __VA_ARGS__
只是将传递给宏的所有参数转发给 Checkpoint。RAND_IDENTIFIER
为新的检查点创建唯一的名称。我直接从 valdok 的文章中借用了这个宏。
此外,我还定义了另外两个宏 PUTLOG(...)
和 PUTLOG_AND_PRINT_CHECKPOINTS(...)
,它们会立即格式化并写入给定的表达式。此外,PUTLOG_AND_PRINT_CHECKPOINTS
还会格式化并写入调用线程 CS 中的所有检查点。
备注
- 多线程
原则上,这里提供的所有函数都是线程安全的,因此根本不需要互斥锁。然而,如果使用一个单独的std::ofstream
来写入所有线程的检查点,那么对ofstream
的调用当然不是线程安全的。但这当然是一个实现细节。为了我的目的,我决定将所有信息写入一个文件就足够了,因此在我所有的宏中实现了线程同步,以保证消息不会与其他线程混淆。如果你希望为你的日志信息使用不同的目标(例如,每个线程一个不同的流),那么根本不需要线程同步。 - 宽字符
使用wchar_t
而不是char
来格式化错误输出是很容易的,因为一切都取决于模板,而你所需要的只是一个合适的operator<<
。但是,在上面的实现中,所有std::ostream
的出现都必须替换为std::wostream
才能正确处理wchar_t
。因此,日志机制不关心你传递的是wchar_t
还是char
。重要的是提供的目标流是否能处理它。 -
异常
如前所述,检查点堆栈驻留在堆栈上而不是堆上。因此,在 catch 块内调用PrintCheckpoints()
将不起作用,因为在抛出异常后,堆栈在 catch 处理程序中已经被销毁。相反,PrintCheckpoints()
必须在堆栈展开*之前*调用。在 Windows 上,你可以使用 SEH 异常模型(有关 SEH 异常的介绍,请再次参考 valdok 的文章)。如果只使用 STL,可以做什么?首先,STL 提供了信号处理程序,它们在堆栈展开*之前*会为段错误、非法指令和浮点异常调用。在处理程序中可以调用PrintCheckpoints()
,写入检查点,然后程序退出。对于用户定义的异常,PrintCheckpoints()
必须在异常的*构造函数*中调用,并且检查点将被写入。
不幸的是,这并不适用于所有抛出的非用户定义异常。例如,如果std::vector::at()
抛出out_of_range
,堆栈将会展开。然而,巧妙地使用 try 和 catch 可以绕过这个问题。
使用代码
使用日志非常简单:你只需要知道三个宏 PUTLOG
、PUTLOG_AND_PRINT_CHECKPOINTS
和 CHECKPOINT
。文件 test.cpp 包含一个小的示例,也许能给你一些关于如何在自己的异常、c++ 信号处理程序和自己的调试对象中使用这三个宏的思路。我建议仔细考虑 std::ostream
是否适合作为你特殊问题的日志输出目标(参见说明)。
结论
我希望我能向你解释如何使用 C++11 和可变参数模板实现延迟日志记录。我认为 valdok 的基本思想非常有益,因为不会将不必要的信息写入日志,从而大大减轻了程序员的工作量,因为你可以确定写入日志的每一条信息都有真正的意义。此外,它还能最大限度地减少 CPU 负载,因为在必要或明确请求之前,不会进行任何内存分配、格式化或写入操作。
我也想补充一点关于模板的评论:整个机制看起来相当复杂,因此并不真正快速,但请注意,所有模板都在编译时而不是运行时进行评估,也就是说,编译器必须处理它。
非常欢迎提问、改进或评论。