muduo笔记 日志库(一)
后半部分见muduo笔记 日志库(二)。
muduo日志库是异步高性能日志库,其性能开销大约是前端每写一条日志消息耗时1.0us~1.6us。
日志库模型
采用双缓冲区(double buffering)交互技术。基本思想:准备2部分buffer(A、B),前端(front end)线程往buffer A填入数据(日志消息),后端(back end)线程负责将buffer B写入日志文件;当A写满时,交换A和B。如此往复。
实现时,在后端设置一个已满缓冲队列(Buffer1~n,2<=n<=16),用于缓存一个周期内临时要写的日志消息。
这样做到好处在于:
1)线程安全;2)非阻塞。
这样,2个buffer在前端写日志时,不必等待磁盘文件操作,也避免每写一条日志消息都触发后端线程。
异常处理:当一个周期内,产生过多Buffer入队列,即当超过队列元素数上限(默认值25)时,直接丢弃多余部分,并记录。
组成部分:muduo日志库由前端和后端组成。
前端
前端主要包括:Logger, LogStream, FixedBuffer<kSmallBuffer>, SourceFile。
类图关系:
Logger类
Logger位于Logging.h/Logging.cc,主要为用户(前端线程)提供使用日志库的接口,是一个pointer to impl的实现(即GoF 桥接模式),详细由内部类Impl实现。
Logger 可以根据用户提供的__FILE__,__LINE__等宏构造对象,记录日志的代码自身信息(所在文件、行数);还提供构造不同等级的日志消息对象。每个Logger对象代表一个日志消息。
Logger 内部定义了日志等级(enum LogLevel),提供全局日志等级(g_logLevel)的获取、设置接口;提供访问内部LogStream对象的接口。
日志等级类型LogLevel
定义:
enum LogLevel
{
TRACE = 0,
DEBUG,
INFO,
WARN,
ERROR,
FATAL,
NUM_LOG_LEVELS
};
各日志等级通常含义:
-
TRACE
指出比DEBUG粒度更细的一些信息事件(开发过程中使用) -
DEBUG
指出细粒度信息事件对调试应用程序是非常有帮助的(开发过程中使用) -
INFO
表明消息在粗粒度级别上突出强调应用程序的运行过程。 -
WARN
系统能正常运行,但可能会出现潜在错误的情形。 -
ERROR
指出虽然发生错误事件,但仍然不影响系统的继续运行。 -
FATAL
指出每个严重的错误事件将会导致应用程序的退出。
muduo默认级别为INFO,开发过程中可以选择TRACE或DEBUG。低于指定级别日志不会被输出。
用户接口
Logging.h中,还定义了一系列LOG_开头的宏,便于用户以C++风格记录日志:
#define LOG_TRACE if (muduo::Logger::logLevel() <= muduo::Logger::TRACE) \
muduo::Logger(__FILE__, __LINE__, muduo::Logger::TRACE, __func__).stream()
#define LOG_DEBUG if (muduo::Logger::logLevel() <= muduo::Logger::DEBUG) \
muduo::Logger(__FILE__, __LINE__, muduo::Logger::DEBUG, __func__).stream()
#define LOG_INFO if (muduo::Logger::logLevel() <= muduo::Logger::INFO) \
muduo::Logger(__FILE__, __LINE__).stream()
#define LOG_WARN muduo::Logger(__FILE__, __LINE__, muduo::Logger::WARN).stream()
#define LOG_ERROR muduo::Logger(__FILE__, __LINE__, muduo::Logger::ERROR).stream()
#define LOG_FATAL muduo::Logger(__FILE__, __LINE__, muduo::Logger::FATAL).stream()
#define LOG_SYSERR muduo::Logger(__FILE__, __LINE__, false).stream()
#define LOG_SYSFATAL muduo::Logger(__FILE__, __LINE__, true).stream()
例如,用户可以用这样的方式使用日志:
LOG_TRACE << "trace" << 1;
构造函数
不难发现,每个宏定义都构造了一个Logger临时对象,然后通过stream(),来达到写日志的功能。
选取参数最完整的Logger构造函数,构造Logger临时对象:
muduo::Logger(__FILE__, __LINE__, muduo::Logger::TRACE, __func__)
__FILE__ 是一个宏, 表示当前代码所在文件名(含路径)
__LINE__ 是一个宏, 表示当前代码所在文件的行数
muduo::Logger::TRACE 日志等级TRACE
__func__ 是一个宏, 表示当前代码所在函数名
对应原型:
Logger(SourceFile file, int line, LogLevel level, const char* func);
这里SourceFile也是一个内部类,用来对构造Logger对象的代码所在文件名进行了包装,只记录基本的文件名(不含路径),以节省日志消息长度。
输出位置,冲刷日志
一个应用程序,通常只有一个全局Logger。Logger类定义了2个函数指针,用于设置日志的输出位置(g_output),冲刷日志(g_flush)。
类型:
typedef void (*OutputFunc)(const char* msg, int len);
typedef void (*FlushFunc)();
Logger默认向stdout输出、冲刷:
void defaultOutput(const char* msg, int len)
{
size_t n = fwrite(msg, 1, static_cast<size_t>(len), stdout);
//FIXME check n
(void)n;
}
void defaultFlush()
{
fflush(stdout);
}
Logger::OutputFunc g_output = defaultOutput;
Logger::FlushFunc g_flush = defaultFlush;
Logger也提供2个static函数来设置g_output和g_flush。
static void setOutput(OutputFunc);
static void setFlush(FlushFunc);
用户代码可用这两个函数修改Logger的输出位置(需要同步修改)。一种典型的应用,就是将g_output重定位到后端AsyncLogging::append(),这样后端线程就能在缓冲区满,或定时,从缓冲区取出数据并(与前端线程异步)写到日志文件。
muduo::AsyncLogging* g_asyncLog = NULL;
void asyncOutput(const char* msg, int len)
{
g_asyncLog->append(msg, len);
}
void func()
{
muduo::Logger::setOutput(asyncOutput);
LOG_INFO << "123456";
}
int main()
{
char name[256] = { '\0' };
strncpy(name, argv[0], sizeof name - 1);
muduo::AsyncLogging log(::basename(name), kRollSize);
log.start();
g_asyncLog = &log;
func();
}
日志等级,时区
还定义了2个全局变量,用于存储日志等级(g_logLevel)、时区(g_logTimeZone)。当前日志消息等级,如果低于g_logLevel,就不会进行任何操作,几乎0开销;只有不低于g_logLevel等级的日志消息,才能被记录。这是通过LOG_xxx宏定义 的if语句实现的。
#define LOG_TRACE if (muduo::Logger::logLevel() <= muduo::Logger::TRACE) \
...
g_logTimeZone 会影响日志记录的时间是用什么时区,默认UTC时间(GMT时区)。例如:
20220306 07:37:08.031441Z 3779 WARN Hello - Logging_test.cpp:75
这里面的“20220306 07:37:08.031441Z”会受到日志时区的影响。
根据g_logTimeZone产生日志记录的时间,位于formatTime()。由于不是核心功能,这里不详述,后续有时间再研究,通常采用默认的GMT时区即可。
析构函数
前面说过,Logger是一个桥接模式,具体实现交给Impl。Logger析构代码如下:
Logger::~Logger()
{
impl_.finish(); // 往Small Buffer添加后缀 文件名:行数
const LogStream::Buffer& buf(stream().buffer());
g_output(buf.data(), buf.length()); // 回调保存的g_output, 输出Small Buffer到指定文件流
if (impl_.level_ == FATAL) // 发生致命错误, 输出log并终止程序
{
g_flush(); // 回调冲刷
abort();
}
}
析构函数中,Logger主要完成工作:为LogStream对象stream_中的log消息加上后缀(文件名:行号<LF>,LF指换行符'\n'),将stream_缓存的log消息通过g_output回调写入指定文件流。另外,如果有致命错误(FATAL级别log),就终止程序。
缓冲Small Buffer 大小默认4KB,实际保存每条log消息,具体参见LogStream描述。
Impl类
Logger::Impl是Logger的内部类,负责Logger主要实现,提供组装一条完整log消息的功能。
下面是3条完整log:
20220306 09:15:44.681220Z 4013 WARN Hello - Logging_test.cpp:75
20220306 09:15:44.681289Z 4013 ERROR Error - Logging_test.cpp:76
20220306 09:15:44.681296Z 4013 INFO 4056 - Logging_test.cpp:77
格式:日期 + 时间 + 微秒 + 线程id + 级别 + 正文 + 原文件名 + 行号
日期 时间 微秒 线程 级别 正文 源文件名: 行号
20220306 09:15:44.681220Z 4013 WARN Hello - Logging_test.cpp:75
...
Impl的数据结构
class Impl
{
public:
typedef Logger::LogLevel LogLevel;
Impl(LogLevel level, int old_errno, const SourceFile& file, int line);
void formatTime(); // 根据时区格式化当前时间字符串, 也是一条log消息的开头
void finish(); // 添加一条log消息的后缀
Timestamp time_; // 用于获取当前时间
LogStream stream_; // 用于格式化用户log数据, 提供operator<<接口, 保存log消息
LogLevel level_; // 日志等级
int line_; // 源代码所在行
SourceFile basename_; // 源代码所在文件名(不含路径)信息
};
包含了需要组装成一条完整log信息的所有组成部分。当然,正文部分是由用户线程直接通过Logstream::operator<<,传递给stream_的。
Impl构造函数
除了对各成员进行初始构造,还生成线程tid、格式化时间字符串等,并通过stream_加入Samall Buffer。
Logger::Impl::Impl(LogLevel level, int savedErrno, const SourceFile &file, int line)
: time_(Timestamp::now()),
stream_(),
level_(level),
line_(line),
basename_(file)
{
formatTime();
CurrentThread::tid();
stream_ << T(CurrentThread::tidString(), static_cast<unsigned int>(CurrentThread::tidStringLength()));
stream_ << T(LogLevelName[level], kLogLevelNameLength); // 6
if (savedErrno != 0) // 发生系统调用错误
{
stream_ << strerror_tl(savedErrno) << " (errno=" << savedErrno << ") "; // 自定义函数strerror_tl将错误号转换为字符串, 相当于strerror_r(3)
}
}
LogStream类实现
LogStream 主要提供operator<<操作,将用户提供的整型数、浮点数、字符、字符串、字符数组、二进制内存、另一个Small Buffer,格式化为字符串,并加入当前类的Small Buffer。
Small Buffer存放log消息
Small Buffer,是模板类FixedBuffer<>的一个具现,i.e. FixedBuffer<kSmallBuffer>,默认大小4KB,用于存放一条log消息。为前端类LogStream持有。
相对的,还有Large Buffer,也是FixedBuffer的一个具现,FixedBuffer<kLargeBuffer>,默认大小4MB,用于存放多条log消息。为后端类AsyncLogging持有。
const int kSmallBuffer = 4000;
const int kLargeBuffer = 4000 * 1000;
class LogStream : noncopyable
{
...
typedef detail::FixedBuffer<detail::kSmallBuffer> Buffer; // Small Buffer Type
...
Buffer buffer_; // 用于存放log消息的Small Buffer
}
class AsyncLogging: noncopyable
{
...
typedef muduo::detail::FixedBuffer<muduo::detail::kLargeBuffer> Buffer; // Large Buffer Type
...
}
模板类FixedBuffer<int SIZE>,内部是用数组char data_[SIZE]存储,用指针char* cur_表示当前待写数据的位置。对FixedBuffer<>的各种操作,实际上是对data_数组和cur_指针的操作。
template<int SIZE>
class FixedBuffer : noncopyable
{
public:
...
private:
...
char data_[SIZE];
char* cur_;
}
例如,往FixedBuffer加入数据FixedBuffer<>::append():
void append(const char* buf, size_t len)
{
// FIXME: append partially
if (implicit_cast<size_t>(avail()) > len) // implicit_cast隐式转换int avail()为size_t
{
memcpy(cur_, buf, len);
cur_ += len;
}
}
int avail() const { return static_cast<int>(end() - cur_); } // 返回Buffer剩余可用空间大小
const char* end() const { return data_ + sizeof(data_); } // 返回Buffer内部数组末尾指针
operator<<格式化数据
针对不同类型数据,LogStream重载了一系列operator<<操作符,用于将数据格式化为字符串,并存入LogStream::buffer_。
{
typedef LogStream self;
public:
...
self& operator<<(bool v)
self& operator<<(short);
self& operator<<(unsigned short);
self& operator<<(int);
self& operator<<(unsigned int);
self& operator<<(long);
self& operator<<(unsigned long);
self& operator<<(long long);
self& operator<<(unsigned long long);
self& operator<<(const void*);
self& operator<<(float v);
self& operator<<(double);
self& operator<<(char v);
self& operator<<(const char* str);
self& operator<<(const unsigned char* str);
self& operator<<(const string& v);
self& operator<<(const StringPiece& v);
self& operator<<(const Buffer& v);
...
}
1)对于字符串类型参数,operator<<本质上是调用buffer_对应的FixedBuffer<>::append(),将其存放当到Small Buffer中。
self& operator<<(const char* str)
{
if (str)
{
buffer_.append(str, strlen(str));
}
else
{
buffer_.append("(null)", 6);
}
return *this;
}
2)对于字符类型,跟参数是字符串类型区别是长度只有1,并且无需判断指针是否为空。
self& operator<<(char v)
{
buffer_.append(&v, 1);
return *this;
}
3)对于十进制整型,如int/long,则是通过模板函数formatInteger(),将转换为字符串并直接填入Small Buffer尾部。
formatInteger() 并没有用snprintf对整型数据进行格式转换,而是用到了Matthew Wilson提出的高效的转换方法convert()。基本思想:从末尾开始,对待转换的整型数,由十进制位逐位转换为char类型,然后填入缓存,直到剩余待转数值已为0。
注意:将int等整型转换为string,muduo并没有使用std::to_string,而是使用了效率更高的自定义函数formatInteger()。
template<typename T>
void LogStream::formatInteger(T v)
{
if (buffer_.avail() >= kMaxNumericSize) // Small Buffer剩余空间够用
{
size_t len = convert(buffer_.current(), v);
buffer_.add(len);
}
}
const char digits[] = "9876543210123456789";
const char* zero = digits + 9; // zero pointer to '0'
static_assert(sizeof(digits) == 20, "wrong number of digits");
/* Efficient Integer to String Conversions, by Matthew Wilson. */
template<typename T>
size_t convert(char buf[], T value)
{
T i = value;
char* p = buf;
do {
int lsd = static_cast<int>(i % 10);
i /= 10;
*p++ = zero[lsd];
} while (i != 0);
if (value < 0)
{
*p++ = '-';
}
*p = '\0';
std::reverse(buf, p);
return static_cast<size_t>(p - buf);
}
4)对于double类型,使用库函数snprintf转换为const char*,并直接填入Small Buffer尾部。
LogStream::self &LogStream::operator<<(double v)
{
if (buffer_.avail() >= kMaxNumericSize)
{
int len = snprintf(buffer_.current(), kMaxNumericSize, "%.12g", v ); // 将v转换为字符串, 并填入buffer_当前尾部. %g 自动选择%f, %e格式, 并且不输出无意义0. %.12g 最多保留12位小数
buffer_.add(static_cast<size_t>(len));
}
return *this;
}
5)对于二进制数,原理同整型数,不过并不以10进制格式存放到Small Buffer,而是以16进制字符串(非NUL结尾)形式,在每个数会加上前缀"0x"。
将二进制内存转换为16进制数的核心函数convertHex,使用了类似于convert的高效转换算法。
LogStream::self &LogStream::operator<<(const void* p)
{
uintptr_t v = reinterpret_cast<uintptr_t>(p); // uintptr_t 位数与地址位数相同, 便于跨平台使用
if (buffer_.avail() >= kMaxNumericSize) // Small Buffer剩余空间够用
{
char* buf = buffer_.current();
buf[0] = '0';
buf[1] = 'x';
size_t len = convertHex(&buf[2], v);
buffer_.add(len + 2);
}
return *this;
}
const char digitsHex[] = "0123465789ABCDEF";
static_assert(sizeof(digitsHex) == 17, "wrong number of digitsHex");
size_t convertHex(char buf[], uintptr_t value)
{
uintptr_t i = value;
char* p = buf;
do
{
int lsd = static_cast<int>(i % 16); // last digit for hex number
i /= 16;
*p++ = digitsHex[lsd];
} while (i != 0);
*p = '\0';
std::reverse(buf, p);
return static_cast<size_t>(p - buf);
}
注意:uintptr_t 位数跟平台地址位数相同,在64位系统中,占64位;在32位系统中,占32位。使用uintptr_t是为了提高可移植性。
6)对于其他类型,都是转换为以上基本类型,然后再转换为字符串,添加到Small Buffer末尾。
staticCheck()静态检查
在operator<<(char void*)和formatInteger(T v)中分别对二进制内存数据、整型数进行格式化时,都有一个判断:Small Buffer剩余空间是否够用,这里面有一个静态常量kMaxNumericSize(默认48)。那么,如何对kMaxNumericSize进行取值呢?48是否合理,如何验证?
这就可以用到staticCheck()进行验证了。目的是为了确保kMaxNumericSize取值,能满足Small Buffer剩余空间一定能存放下要格式化的数据。取数据位较长的double、long double、long、long long,进行static_assert断言。
void LogStream::staticCheck()
{
static_assert(kMaxNumericSize - 10 > std::numeric_limits<double>::digits10,
"kMaxNumericSize is large enough");
static_assert(kMaxNumericSize - 10 > std::numeric_limits<long double>::digits10,
"kMaxNumericSize is large enough");
static_assert(kMaxNumericSize - 10 > std::numeric_limits<long>::digits10,
"kMaxNumericSize is large enough");
static_assert(kMaxNumericSize - 10 > std::numeric_limits<long long>::digits10,
"kMaxNumericSize is large enough");
}
std::numeric_limits<T>::digits10 返回T类型的十进制数的有效数字的位数,比如float有效位数6位,double是15位,int是9位。kMaxNumericSize-10 是为了确保kMaxNumericSize足够大,能让Small Buffer可用空间比要转换的位数最长的类型还要多出10byte(1byte容纳一个位数)。
小结
至此,muduo日志库前端核心部分已讲完。但默认只能将数据以非线程安全方式输出到stdout,还不能实现异步记录log消息。
关键点:
1)Logger 提供用户接口,将实现细节隐藏到Impl,Logger定义一组宏定义LOG_XXX方便用户在前端使用日志库;
2)Impl实现除正文部分,一条完整log消息的组装;
3)LogStream提供operator<< 格式化用户正文内容,将其转换为字符串,并添加到Small Buffer(4KB)末尾;
muduo库其它部分解析参见:muduo库笔记汇总