65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIconstarIcon

5.00/5 (8投票s)

2015年9月22日

CPOL

8分钟阅读

viewsIcon

14046

downloadIcon

7

引言

最近我阅读了 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(要写入的元素的索引)和 TupleTuple 参数是为了方便,因为我们不想处理所有参数的所有类型,而是让编译器来完成这项工作。
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 可以绕过这个问题。

使用代码

使用日志非常简单:你只需要知道三个宏 PUTLOGPUTLOG_AND_PRINT_CHECKPOINTSCHECKPOINT。文件 test.cpp 包含一个小的示例,也许能给你一些关于如何在自己的异常、c++ 信号处理程序和自己的调试对象中使用这三个宏的思路。我建议仔细考虑 std::ostream 是否适合作为你特殊问题的日志输出目标(参见说明)。

结论

我希望我能向你解释如何使用 C++11 和可变参数模板实现延迟日志记录。我认为 valdok 的基本思想非常有益,因为不会将不必要的信息写入日志,从而大大减轻了程序员的工作量,因为你可以确定写入日志的每一条信息都有真正的意义。此外,它还能最大限度地减少 CPU 负载,因为在必要或明确请求之前,不会进行任何内存分配、格式化或写入操作。
我也想补充一点关于模板的评论:整个机制看起来相当复杂,因此并不真正快速,但请注意,所有模板都在编译时而不是运行时进行评估,也就是说,编译器必须处理它。

非常欢迎提问、改进或评论。

© . All rights reserved.