spdlog 中的 C++ 技巧
潘忠显 / 2022-07-01
spdlog 是一个用起来非常方便的 C++ 日志库。这里整理了在阅读 spdlog 源码时学到一些技术点:相对深入地介绍了头文件库和静态库的区别、锁应该如何使用、容器和容器适配器的关系、如何实现线程安全的队列、利用注册器带来编程便利等内容。也欢迎阅读另外一篇《gRPC代码中的C/C++技巧》
我自己写了一个处理日志的代理,实现通过配置创建多个接收器监听网络请求/本地文件变更,也能够配置多个导出器将经过不同处理的消息发送给不同的接收端。其中,就利用了 spdlog 库构造文件导出器。
一、头文件库 vs. 静态库
Header-only Library 是只需要 include
头文件即可,而不需要使用 .cc
源文件构建出静态库或动态库,这会给使用者带来很大的便利。我们如果提供给别人一些简单库时,也可以是使用 Header-only Library 的方式。
常用的这类库有 RapidJson、spdlog 等,但他们的实现形式上还稍有不同。
- RapidJson 中只有
.h
文件类型,一个.h
文件处理一块完整的逻辑,比如document.h
- spdlog 中有
.h
和-inl.h
两种文件,大部分成对出现
spdlog 这种组织方式,是为了支持用户自行选择何种方式使用 spdlog:既可以选择以头文件库方式,也可以选择使用静态库的方式。
两种库的区别
既然头文件库这么便利,为什么还会有人选择使用静态库呢?**因为头文件库在提供便利的同时,也带来代价:每次的编译时间增加。**在第三方库没有变更的情况下:
- 以静态库的方式,每次构建主程序时只需要进行符号链接
- 以头文件库的方式,每次构建主程序都需要对库进行编译、链接
以 spdlog/example 的代码作为示范,分别展示使用两种方式进行构建的耗时:
通过宏定控制库的类型
logger.h 中,先像普通的头文件一样,定义类、声明普通函数、定义模板函数,在文件最后的部分,有判断是否存在 SPDLOG_HEADER_ONLY
宏定义:
#ifdef SPDLOG_HEADER_ONLY
# include "logger-inl.h"
#endif
logger-inl.h 中,在文件的开头,判断是否存在 SPDLOG_HEADER_ONLY
宏定义,若没有定义,则像普通的 C++ 源文件一样,#include
对应的头文件:
#ifndef SPDLOG_HEADER_ONLY
# include <spdlog/logger.h>
#endif
上边提到的 SPDLOG_HEADER_ONLY
宏定义在 common.h,所有的 spdlog 的头文件都会 include 最基础的头文件:
#ifdef SPDLOG_COMPILED_LIB
# undef SPDLOG_HEADER_ONLY
# define SPDLOG_INLINE
#else // !defined(SPDLOG_COMPILED_LIB)
# define SPDLOG_API
# define SPDLOG_HEADER_ONLY
# define SPDLOG_INLINE inline
#endif // #ifdef SPDLOG_COMPILED_LIB
这么做的原因是:spdlog 可能认为更多的人会选择头文件库的方式去使用,所以只有需要静态库的方式才需要额外的编译选项,而 SPDLOG_HEADER_ONLY
则是为了更好的代码可读性。
伴随 SPDLOG_HEADER_ONLY
一起定义的还有另外两个:
#define SPDLOG_API
用于兼容不同平台的函数定义#define SPDLOG_INLINE inline
表示在以头文件库提供时,函数以 inline 的方式编译
剩下的工作就是编写 CMakeList.txt 用于构建出静态库了(头文件库不需要做额外的操作)。
spdlog ISSUE #120 中讨论了静态库的支持工作。
inline
编译
接下来聊聊为什么 头文件库的时候,需要将函数定义为inline,而静态库的时候不要。
inline “不能” 放在 .cpp 文件中
这里说不能是加引号的,C++ FAQ 中有提到,除非一个 inline 函数在对应头文件的 .cpp 文件中使用,不然外部使用该函数时,会报 “undefined reference to xxx” 的错误。
构建:
g++ t.cc main.cc
链接错误:
/tmp/ccLw3WSW.o: In function `main':
main.cc:(.text+0x1c): undefined reference to `A::get_a()'
collect2: error: ld returned 1 exit status
header 中的函数使用 inline
在类声明中定义的函数默认是 inline 的:
class Fred {
public:
void f(int i, char c) { /* ... */ }
};
但是上边这种方式影响可读性:会做什么(函数声明)和如何做(函数定义)混在一起。所以通常会将其分开:
class Fred {
public:
void f(int i, char c);
};
inline void f(int i, char c) { /* ... */ }
带 inline
跟上边定义在类内部是相同的效果;如果去掉 inline
,不是将代码展开,而是调用另外的函数,会降低性能。
二、如何用锁
spdlog 中也提供两种类型的 logger,线程安全 _mt
(multiple-thread 的缩写) 和非线程安全 _st
(single-thread的缩写) 的接口:
using rotating_file_sink_mt = rotating_file_sink<std::mutex>;
using rotating_file_sink_st = rotating_file_sink<details::null_mutex>;
如果创建了一个 logger,确定只在单个线程中使用,那就没有必要使用锁,因为使用锁会带来额外的开销:单个线程取锁,也会进行额外的trylock。接下来跟着 spdlog 学学如何使用锁。
lock_guard 源码
以 GNU C++ Library 中对 lock_guard 实现源码(微软的实现也类似)为例,我们看看这个 std::lock_guard
做了什么:
template<typename _Mutex>
class lock_guard
{
public:
typedef _Mutex mutex_type;
explicit lock_guard(mutex_type& __m) : _M_device(__m)
{ _M_device.lock(); }
lock_guard(mutex_type& __m, adopt_lock_t) : _M_device(__m)
{ } // calling thread owns mutex
~lock_guard()
{ _M_device.unlock(); }
lock_guard(const lock_guard&) = delete;
lock_guard& operator=(const lock_guard&) = delete;
private:
mutex_type& _M_device;
};
跟我们想象的一样:
lock_guard
是一个类模板,接受一个 BasicLockable 类型的形参BasicLockable
要求类型需要具有m.lock()
和m.unlock()
两个函数lock_guard
在构建时调用m.lock()
,在析构时调用m.unlock()
空互斥量
前边单线程代码不需要使用锁,但同时为了复用代码,spdlog 直接将锁的类型作为模板参数。多线程的时候使用 std::mutex
,而单线程的时候就使用自定义的空锁 null_mutex
:
struct null_mutex {
void lock() const {}
void unlock() const
};
锁的粒度
以 spdlog 里边最重要的 sink 为例,先看下他的类之间的继承关系:
- 基类
class sink
是个“抽象类”,声明了需要实现的纯虚函数而无法被实例化 - 类模板
template<typename Mutex> class base_sink
继承sink
类,实现了大部分 sink 接口log(const log_msg&)
、flush()
、set_pattern()
、set_formatter()
,但是每个 sinker 不同的sink_it_
和flush_()
两个函数作为纯虚函数,需要子类来实现 - 类模板
class rotating_file_sink
继承base_sink<Mutex>
并实现了sink_it_()
和flush_()
,在sink_it_()
的时候判断文件大小是否需要 rotate,如果需要则在此进行文件旋转
**为保证多线程之间的同步,sink 过程中会使用锁,而这个锁的范围该如何设置呢?**在类模板 base_sink 中使用,在 log(const log_msg&)
、flush()
、set_pattern()
、set_formatter()
函数范围内会使用 lock_guard
进行保护:
template<typename Mutex>
void SPDLOG_INLINE spdlog::sinks::base_sink<Mutex>::log(const details::log_msg &msg) {
std::lock_guard<Mutex> lock(mutex_);
sink_it_(msg);
}
针对同一种资源,更大范围内加锁了,其中一部分小范围就不用也不应该加锁。因此,在调用特例化类型的 sink_it_()
的外层函数 log()
里边已经加了锁,在 sink_it_()
中是不需要再加锁的。
不需要额外的线程 rotate 文件:因为在几乎每个用户调用的函数上都会加锁,在其中的任何一个函数中 rotate 就可以阻塞住其他线程,所以不需要额外的线程去做单独的 rotate 动作。
三、线程安全的循环队列
spdlog 中定义了两个队列:循环队列 circular_q 和线程安全的 mpmc_blocking_queue。MPMC 是指多生产者/多消费者(Multiple Producer Multiple Consumer)。
接下来会先说明容器不是线程安全的,然后看看如何实现一个线程安全的循环队列。
容器与容器适配器
容器是为了有效地存储数据,比如 std::array
、std::vector
、std::list
、std::deque
。
std::deque (double-ended queue) is an indexed sequence container that allows fast insertion and deletion at both its beginning and its end. In addition, insertion and deletion at either end of a deque never invalidates pointers or references to the rest of the elements.
容器适配器是在容器的基础上包了一层,增加了一些限制,比如 std::stack
、std::queue
、std::priority_queue
。
The std::stack class is a container adaptor that gives the programmer the functionality of a stack - specifically, a LIFO (last-in, first-out) data structure.
要实现一个 stack 可以使用 std::vector
、std::list
、std::deque
,但实现上往往会选择效率相对较高的实现方式。比如 GCC 在 std::stack
的实现中,默认选用的就是 std::deque
:
// This is not a true container, but an @e adaptor. ...
template<typename _Tp, typename _Sequence = deque<_Tp> >
class stack {...}
容器不是线程安全
容器的某些函数是线程不安全的,即多线程并发的调用,会造成数据不一致,甚至程序崩溃。
简单地,可以将容器理解为两部分:开辟一块内存空间存放数据,额外的信息存储内存空间和容器状态的信息。这样对理解 std::vector
不是线程安全的就比较简单。
再以 vector::push_back()
成员函数为例,每次调用该函数:会往已经开辟的内存空间中塞数据,如果数据多到原来的内存塞不下,就需要新开辟一块空间。具体地,看 push_back()
的定义中,先会判断当前结尾元素是否已经达到了之前申请的内存边界,如果已经达到,则会调用 _M_realloc_insert()
分配新的空间,然后插入元素:
void push_back(const value_type& __x) {
if (this->_M_impl._M_finish != this->_M_impl._M_end_of_storage) {
...
} else
_M_realloc_insert(end(), __x);
}
而 _M_realloc_insert()
的实现中的会申请新的内存,搬移老数据,最后销毁旧的内存,将指针指向新的内存地址:
template <typename _Tp, typename _Alloc>
void vector<_Tp, _Alloc>::_M_realloc_insert(iterator __position,
const _Tp& __x) {
...
std::_Destroy(__old_start, __old_finish, _M_get_Tp_allocator());
_GLIBCXX_ASAN_ANNOTATE_REINIT;
_M_deallocate(__old_start, this->_M_impl._M_end_of_storage - __old_start);
this->_M_impl._M_start = __new_start;
this->_M_impl._M_finish = __new_finish;
this->_M_impl._M_end_of_storage = __new_start + __len;
...
}
多个线程如果同时调用 push_back()
,就可能造成一个线程在销毁一块内存,而另外一个线程同时在往这块内存上写数据(“Segmentation fault” 等运行时错误),或者两个线程同时销毁同一块内存(“double free or corruption”)。
容器不是线程安全的,那么依赖于容器的容器适配器也就不是线程安全的。写一个简单的测试程序,下边的程序基本不会正常退出:
// g++ -g st_vector_test.cc -pthread
#include <cassert>
#include <thread>
#include <vector>
int main() {
constexpr int kQueueLen = 100000;
for (int i = 0; i < 10; i++) {
std::vector<int> v_;
auto x = [&v_]() {
while (v_.size() < kQueueLen) {
v_.push_back(1);
}
};
std::thread t1(x);
std::thread t2(x);
t1.join();
t2.join();
assert(v_.size() == kQueueLen);
}
}
循环队列
“circular_q.h” 文件中,以 std::vector
容器作为存储,封装了一个循环队列。他有几个要素:
- 循环队列最大能容纳元素
max_items_
个 - 使用大小为
max_items_ + 1
的向量保留元素,额外留一个用于标识队列满 - 使用下标维护头和尾,head 指向真正头的位置,而每次 tail 总指向下一个要填充的位置
- 使用
((tail_ + 1) % max_items_) == head_
判断队列满;使用tail_ == head_
判断队列空 - 队列满时覆盖最老的元素,记录被覆盖的次数
下面的动画展示了其 push_back()
的实际过程,最后一次发生覆盖,head 也会往后移动,一直保持 N 个最新元素:
下面的动画展示了其 pop_front()
的实际过程,当 head 往后移动,直到 head 和 tail 指到同一个位置,意味着队列变空:
线程安全循环队列
这个循环队列和普通队列有一个区别:底层用户存储的 std::vector
因为大小从一开始就不会变化,所以不会发生重新分配空间。如果不加任何的同步控制,多线程调用该循环队列的成员函数,不会发生程序崩溃,但是会有数据不一致的情况。
线程安全的类 mpmc_blocking_queue
使用 circular_q
作为存储,使用了以下成员变量控制同步:
- 队列锁
queue_mutex_
,基本上每个成员函数调用的时候,都会先调用该锁进行保护 - 条件变量
push_cv_
,用于队列满时wait_for()
,在 pop 成功后notify_one()
通知可以 push - 条件变量
pop_cv_
,用于队列空时wait_for()
,在 push 成功后notify_one()
通知可以 pop
以 enqueue 为例,我们他调用 pop_cv_.wait()
阻塞到队列非空;在将元素入队列后,调用 push_cv_.notify_one()
通知可以取。
void enqueue(T &&item) {
{
std::unique_lock<std::mutex> lock(queue_mutex_);
pop_cv_.wait(lock, [this] { return !this->q_.full(); });
q_.push_back(std::move(item));
}
push_cv_.notify_one();
}
之前的文章有介绍过虚假唤醒,这里在 wait()
的时候,需要填上校验的函数,等价于:
while (!this->q_.full()) {
pop_cv_.wait(lock);
}
四、注册机制
spdlog 提供了一种比较方便的使用方式:在任何代码片段中,可以创建一个 logger 并将其进行注册;在其他的代码片段中,可以通过logger的名称获得该对象,记录日志。
注册新的 logger:
auto net_logger = std::make_shared<spdlog::logger>("net", daily_sink);
spdlog::register_logger(net_logger);
其他地方根据名字获取 logger,并利用其记录日志:
auto l = spdlog::get("logger1");
l->info("hello again");
利用静态变量实现单例
SPDLOG_INLINE registry ®istry::instance() {
static registry s_instance;
return s_instance;
}
利用 unordered_map
存储映射
注意使用 lock_guard
进行保护
// std::unordered_map<std::string, std::shared_ptr<logger>> loggers_;
SPDLOG_INLINE void registry::register_logger_(std::shared_ptr<logger> new_logger)
{
auto logger_name = new_logger->name();
throw_if_exists_(logger_name);
loggers_[logger_name] = std::move(new_logger);
}
SPDLOG_INLINE void registry::register_logger(std::shared_ptr<logger> new_logger)
{
std::lock_guard<std::mutex> lock(logger_map_mutex_);
register_logger_(std::move(new_logger));
}
封装函数不暴露 registry 实例
namespace spdlog {
SPDLOG_INLINE void set_formatter(std::unique_ptr<spdlog::formatter> formatter) {
details::registry::instance().set_formatter(std::move(formatter));
}
} // namespace spdlog