文章目录
一、日志写入逻辑
1.1 相关接口函数
fwrite() 函数
用于将数据块按字节写入到文件中,返回实际成功写入的数据块个数。
size_t fwrite(const void *ptr, size_t size, size_t count, FILE *stream);
参数说明:
ptr:指向要写入的数据块的指针。
size:每个数据块的字节数。
count:要写入的数据块个数。
stream:指向要写入的文件的指针。
使用 fwrite() 函数时,它会将 size 字节的数据块从 ptr 写入到指定的文件流 stream 中,并重复这个过程 count 次。
fread() 函数
用于从文件中按字节读取数据块,返回实际成功读取的数据块个数。
size_t fread(void *ptr, size_t size, size_t count, FILE *stream);
参数说明:
ptr:指向存储读取数据的内存块的指针。
size:每个数据块的字节数。
count:要读取的数据块个数。
stream:指向要读取的文件的指针。
使用 fread() 函数时,它会从指定的文件流 stream 中读取 size 字节的数据块,并重复这个过程 count 次,将读取的数据存储到 ptr 指向的内存块中。
fclose()函数
用于关闭打开的文件流,若成功关闭文件,则返回0;若关闭文件失败,则返回非零值。
int fclose(FILE *stream);
使用 fclose() 函数时,它会关闭指定的文件流,并刷新缓冲区中的数据。在关闭文件之前,它会将缓冲区中的数据写入到文件中。
fflush()函数
用于刷新输出缓冲区。若成功刷新缓冲区,则返回0;若刷新缓冲区失败,则返回非零值。
int fflush(FILE *stream);
参数说明:
stream:指向要刷新缓冲区的文件的指针。如果传入 NULL,则会刷新所有打开的文件流的缓冲区。
使用 fflush() 函数时,它会将输出缓冲区的内容立即写入到文件中(对于输出流)或者屏幕上(对于标准输出流 stdout)。这个函数通常用于确保数据被及时写入,而不是等到缓冲区满或者程序结束时才自动刷新。
fsync()函数
用于将文件数据同步到磁盘上的永久存储空间。若成功同步到磁盘,则返回0;若同步失败,则返回非零值。
int fsync(int fd);
参数说明:
fd:要同步到磁盘的文件描述符。
使用 fsync() 函数时,它会强制将文件缓冲区中的数据写入到磁盘上的永久存储空间。这个函数主要用于确保在系统崩溃或断电等异常情况下,文件数据不会丢失或损坏。
需要注意的是,fsync() 函数的调用可能会导致性能下降,因为它会强制进行磁盘写入操作。
setbuf()函数
用于在打开的文件上设置自定义的缓冲区。
void setbuf(FILE *stream, char *buffer);
参数说明:
stream:指向要设置缓冲区的文件的指针。
buffer:指向用于设置缓冲区的字符数组的指针。如果传入 NULL,则禁用缓冲区。
使用 setbuf() 函数时,它可以用于将自定义的字符数组作为缓冲区与文件关联。缓冲区提供了一种临时存储数据的方式,可以提高文件的读写性能。
需要注意的是,如果传入的 buffer 参数为 NULL,则会禁用缓冲区,即设置为无缓冲模式。在无缓冲模式下,每次写入或读取都会立即进行 I/O 操作,而不会暂存数据。这种模式适合于需要实时数据交换或者文件较小的情况。
write()函数
用于将数据写入文件或文件描述符的系统调用函数。成功时,返回写入的字节数。失败时,返回-1。
ssize_t write(int fd, const void *buf, size_t count);
参数说明:
fd:文件描述符,表示要写入的目标文件或设备。
buf:指向要写入数据的缓冲区的指针。
count:要写入的字节数。
write() 函数会尽可能将指定数量的字节从缓冲区 buf 写入到文件或设备中。它是一个阻塞函数,即在数据完全写入之前会一直等待。
1.2 写入逻辑
fwrite 与 write 的区别
- fwrite() 是库函数,默认使用缓冲区,即将数据先写入缓冲区,可以使用 setbuf() 或 setvbuf() 函数自定义缓冲区。缓冲区可以提高写入效率,在遇到文件关闭、缓冲区满、调用 fflush() 等情况时会触发真正的写入操作,一次写入磁盘。
- write() 函数 是系统调用,无缓冲的,数据直接写入到磁盘,涉及用户态和内核态的切换。
fwrite通过fflush(内部触发 write )把用户缓冲区数据刷新到内核缓冲区中,通过fsync把内核缓冲区数据刷新到磁盘。
二、log4cpp 日志框架
log4cpp是个基于LGPL的开源项⽬,移植⾃Java的⽇志处理跟踪项⽬log4j,并保持了API上的⼀致。log4cpp 性能很低,可以在客户端使用,不适合服务器端使用。Log4cpp不是批量写入模式,而是直接调用write() 进行实时写入,这样的性能就不会非常高。
Log4cpp中最重要概念有Category(种类)、Appender(附加器)、Layout(布局)、Priorty(优先级)、NDC(嵌套的诊断上下⽂)。
2.1 下载和编译
下载地址:
https://sourceforge.net/projects/log4cpp/files/latest/download
解压
tar zxf log4cpp-1.1.3.tar.gz
编译
cd log4cpp
./configure
make
make check
sudo make install
sudo ldconfig
默认安装路径:
/usr/local/include/log4cpp # 头文件
/usr/local/lib/liblog4cpp # 库文件
2.2 日志级别
log4cpp为例,具体的级别看具体的日志库
◼ EMERG
◼ FATAL
◼ ALERT
◼ CRIT
◼ ERROR
◼ WARN
◼ NOTICE
◼ INFO
◼ DEBUG
有些日志库 DEBUG和INFO的级别是反过来的。
日志输出级别应当在运行时可调,在必要的时候可以临时在线调整日志的输出级别。
muduo 库来说,调整日志的输出级别不需要重新编译,也不需要重启进程,只需要调用 muduo::Logger::setLoglevel()就能即时生效。
2.3 日志格式
一般日志输出时会携带一些关注的信息,比如
20210410 14:18:15.299684Z 30836 INFO NO.1506710 Root Error Message! - log_test.cpp:17
格式为:年月日 时分秒 微妙 时区 日志级别 日志内容 文件名 行号
Log4cpp支持的转义定义:
◼ %% - 转义字符’%’
◼ %c - Category
◼ %d - 日期;日期可以进一步设置格式,用花括号包围,例如%d{%H:%M:%S,%l}。日期的格式符号与ANSI C函数strftime中的一致。但增加了一个格式符号%l,表示毫秒,占三个十进制位。
◼ %m - 消息
◼ %n - 换行符;会根据平台的不同而不同,但对用户透明。
◼ %p - 优先级
◼ %r - 自从layout被创建后的毫秒数
◼ %R - 从1970年1月1日开始到目前为止的秒数
◼ %u - 进程开始到目前为止的时钟周期数
◼ %x - NDC
◼ %t - 线程id
muduo 库默认日志的格式是固定的,不需要运行时配置,这样可以节省每条日志解析格式字符串的开销,如果需要调整消息格式,直接修改代码重新编译即可
log4cpp 可以支持多种格式 Layout,如 SimpleLayout(简单布局), BasicLayout(基本布局)、 PatternLayout(格式化布局)。
1)BasicLayout::format
基本布局。它会为你添加“时间”、“优先级”、“种类”、“NDC”。相当于PatternLayout格式化为:“%R %p %c %x: %m%n”。
2) PassThroughLayout::format
直通布局。顾名思义,这个就是没有布局的“布局”,你让它写什么它就写什么,它不会为你添加任何东西,连换行符都懒得为你加。但是,它支持自定义的布局,我们可以继承他实现自定义的日志格式。
3) PatternLayout::format log4cpp
格式化布局,支持用户配置日志格式。它的使用方式类似C语言中的printf,使用格式化它符串来描述输出格式;目前支持的转义定义。
4) SimpleLayout::format
简单布局。比BasicLayout还简单的日志格式输出,它只会为你添加“优先级”的输出。相当于PatternLayout格式化为:“%p:%m%n”。
2.4 日志输出
日志有不同的输出方式,以log4cpp为例:
1)日志输出到控制台。ConsoleAppender。
2)日志输出到本地文件。FileAppender,值得注意的是,log4cpp使用write()来输出到文件,这种方式需要频繁的切换用户态和内核态,性能不高。这是一个可以优化的地方。
3)日志通过网络传输到远程服务器。RemoteSyslogAppender。
2.5 日志回滚
日志库一般都具备如下功能:
1)本地日志支持最大文件限制。
2)当本地日志达到最大文件限制的时候新建一个文件。
3)每天至少一个文件。
log4cpp回滚日志时,采用文件改名的方式,以文件名数字的大小表示创建的先后,数字越大文件越旧。比如若支持5个日志备份文件,有新的日志文件到来时,先删除最旧的log.5,然后把log.4改名为log.5,log.3改名为log.2,log.2改名为log.3,log.1改名为log.2,log.改名为log.1,最后新建一个log文件存储新的日志。
muduo 库的日志回滚没有采用文件改名,dmesg.log 始终是最新日志,便于编写及时解析日志的脚本
在性能方面,统计文件大小时,log4cpp 调用 lseek来实现,这导致其性能极低。而 muduo 库在应用层增加当前写入日志的大小参数。
三、muduo 异步日志库
3.1 异步日志机制
异步日志机制,是将日志写入操作放入一个独立的线程或者进程中来提高性能,而其他业务线程只需要往这个线程发送日志消息即可,避免因为日志写入操作导致的主线程阻塞。
通常,异步日志机制包含以下几个核心组件:
1)日志缓冲区(Log Buffer):用于临时存储日志消息的缓冲区。
2)日志队列(Log Queue):用于存放待写入日志文件的日志消息。
3)后台写入线程(Logging Thread):负责从日志队列中取出日志缓冲区,并将其中的日志消息写入到日志文件中。该线程通常是一个专门的线程或者进程,与主线程并发运行。
需要注意的是,这是一个典型的消费者生产者模型。生产者线程不是将日志消息逐条传递给消费者线程,而是积攒日志,将多条日志消息缓存组成一个大的buffer(默认4M·),作为队列的元素。等队列满了,再唤醒消费者线程,将日志批量写入磁盘。
因此,后端日志线程的唤醒有两个条件
1)buffer 写满唤醒:批量写入写满1个 buffer 后,唤醒后端日志线程,减少线程被唤醒的频率,降低系统开销。
2)超时被唤醒:为了及时将日志消息写入文件,防止系统故障导致内存中的日志消息丢失。超过规定的时间阈值,即使 buffer 未满,也会通过wait_timeout唤醒日志落盘线程,立即将 buffer 中的数据写入。
3.2 双缓冲机制
双缓冲机制,是准备两个缓冲区,bufferA 和 bufferB。前端负责向 A 中写入日志消息,后端负责将 B 中的数据写入文件。
当 bufferA 写满或者达到一定的时间间隔后,交换 A 和 B。此时让后端将 A 的数据写入文件,前端向 B 中写入新的日志消息,如此往复。这样在追加日志消息的时候不必等待磁盘 IO 操作,同时也避免了每条新日志消息都触发唤醒后端日志线程。
3.3 前端日志写入
AsyncLogging::append():前端生成一条日志消息。
前端准备一个前台缓冲区队列 buffers_和两个 buffer。前台缓冲队列 buffers_用来存放积攒的日志消息。两个 buffer,一个是当前缓冲区 currentBuffer,追加的日志消息存放于此;另一个作为当前缓冲区的备份,即预备缓冲区 nextBuffer,减少内存的开销。
函数执行逻辑如下:
判断当前缓冲区 currentBuffer_是否已经写满
- 若当前缓冲区未满,追加日志消息到当前缓冲,这是最常见的情况
- 若当前缓冲区写满,首先,把它移入前台缓冲队列 buffers_。其次,尝试把预备缓冲区 nextBuffer_移用为当前缓冲,若失败则创建新的缓冲区作为当前缓冲。最后,追加日志消息并唤醒后端日志线程开始写入日志数据。
int append_cnt = 0;
void AsyncLogging::append(const char *logline, int len) {
// 1. 多线程加锁,线程安全
std::lock_guard<std::mutex> lock(mutex_);
// 2.判断是否写满buffer,批量数据的积攒阶段
if (currentBuffer_->avail() > len) // 判断buffer还有没有空间写入这条日志
{
// 2.1 buffer未满直接写入buffer
currentBuffer_->append(logline, len); // 直接写入
}
// 2、当前缓冲写满,两件事
// 其一,将写满的当前缓冲的日志消息写入前台缓冲队列 buffers
// 其二,追加日志消息到当前缓冲,唤醒后台日志落盘线程
else {
// 其一,当前缓冲移入(move)前台缓冲队列 buffers
buffers_.push_back(std::move(currentBuffer_)); // buffers_是vector,把buffer入队列
// 判断预备缓冲nextBuffer是否写满,如果未满则复用,如果满了则重新分配
if (nextBuffer_) // 用了双缓存
{
// 未满,复用
currentBuffer_ = std::move(nextBuffer_); // 如果不为空则将buffer转移到currentBuffer_
} else {
// 满了,重新分配buffer
currentBuffer_.reset(new Buffer); // Rarely happens如果后端写入线程没有及时读取数据,那要再分配buffer
}
// 其二,追加日志信息到当前缓冲,唤醒日志落盘线程
currentBuffer_->append(logline, len);
cond_.notify_one(); // 唤醒日志落盘线程
}
}
3.4 后端日志落盘
AsyncLogging::threadFunc():后端日志落盘线程的执行函数。
后端同样也准备了一个后台缓冲区队列 buffersToWrite 和两个备用 buffer。后台缓冲区队列 buffersToWrite 存放待写入磁盘的数据。两个备用 buffer,newBuffer1和newBuffer2,分别用来替换前台的当前缓冲和预备缓冲,而这两个备用 buffer 最后会被buffersToWrite内的两个 buffer 重新填充,减少了内存的开销。
参考 异步日志模块的实现
void AsyncLogging::threadFunc() {
assert(running_ == true);
latch_.countDown();
// logFile 类负责将数据写入磁盘
LogFile output(basename_, rollSize_, false);
BufferPtr newBuffer1(new Buffer); // 用于替换前台的当前缓冲 currentbuffer
BufferPtr newBuffer2(new Buffer); // 用于替换前台的预备缓冲 nextbuffer
newBuffer1->bzero();
newBuffer2->bzero();
BufferVector buffersToWrite; // 后台缓冲队列
buffersToWrite.reserve(16); // 两个不同的缓冲队列,涉及到锁的粒度问题
// 异步日志开启,则循环执行
while (running_) {
assert(newBuffer1 && newBuffer1->length() == 0);
assert(newBuffer2 && newBuffer2->length() == 0);
assert(buffersToWrite.empty());
// <---------- 交换前台缓冲队列和后台缓冲队列 ---------->
{ // 锁的作用域,放在外面,锁的粒度就大了,日志落盘的时候都会阻塞 append
// 1、多线程加锁,线程安全,注意锁的作用域
MutexLockGuard lock(mutex_);
// 2、判断前台缓冲队列 buffers 是否有数据可读
// buffers 没有数据可读,休眠
if (buffers_.empty()) {
// 触发日志的落盘 (唤醒) 的两个条件:1.超时 or 2.被唤醒,即前台写满 buffer
cond_.waitForSeconds(flushInterval_); // 内部封装 pthread_cond_timedwait
}
// 只要触发日志落盘,不管当前的 buffer 是否写满都必须取出来,写入磁盘
// 3、将当前缓冲区 currentbuffer 移入前台缓冲队列 buffers。
// currentbuffer 被锁住 -> currentBuffer 被置空
buffers_.push_back(std::move(currentBuffer_));
// 4、将空闲的 newbuffer1 移为当前缓冲,复用已经分配的空间
currentBuffer_ = std::move(newBuffer1); // currentbuffer 需要内存空间
// 5、核心:把前台缓冲队列的所有buffer交换(互相转移)到后台缓冲队列
// 这样在后续的日志落盘过程中不影响前台缓冲队列的插入
buffersToWrite.swap(buffers_);
// 若预备缓冲为空,则将空闲的 newbuffer2 移为预备缓冲,复用已经分配的空间
// 这样前台始终有一个预备缓冲可供调配
if (!nextBuffer_) {
nextBuffer_ = std::move(newBuffer2);
}
} // 注意这里加锁的粒度,日志落盘的时候不需要加锁了,主要是双队列的功劳
// <-------- 日志落盘,将buffersToWrite中的所有buffer写入文件 -------->
assert(!buffersToWrite.empty());
// 6、异步日志消息堆积的处理。
// 同步日志,阻塞io,不存在堆积问题;异步日志,直接删除多余的日志,并插入提示信息
if (buffersToWrite.size() > 25) {
printf("Dropped\n");
// 插入提示信息
char buf[256];
snprintf(buf, sizeof buf, "Dropped log messages at %s, %zd larger buffers\n",
Timestamp::now().toFormattedString().c_str(),
buffersToWrite.size()-2);
fputs(buf, stderr);
output.append(buf, static_cast<int>(strlen(buf)));
// 只保留2个buffer(默认4M)
buffersToWrite.erase(buffersToWrite.begin()+2, buffersToWrite.end());
}
// 7、循环写入 buffersToWrite 的所有 buffer
for (const auto& buffer : buffersToWrite) {
// 内部封装 fwrite,将 buffer中的一行日志数据,写入用户缓冲区,等待写入文件
output.append(buffer->data(), buffer->length());
}
// 8、刷新数据到磁盘文件?这里应该保证数据落到磁盘,但事实上并没有,需要改进 fsync
// 内部调用flush,只能将数据刷新到内核缓冲区,不能保证数据落到磁盘(断电问题)
output.flush();
// 9、重新填充 newBuffer1 和 newBuffer2
// 改变后台缓冲队列的大小,始终只保存两个 buffer,多余的 buffer 被释放
// 为什么不直接保存到当前和预备缓冲?这是因为加锁的粒度,二者需要加锁操作
if (buffersToWrite.size() > 2) {
// 只保留2个buffer,分别用于填充备用缓冲 newBuffer1 和 newBuffer2
buffersToWrite.resize(2);
}
// 用 buffersToWrite 内的 buffer 重新填充 newBuffer1
if (!newBuffer1) {
assert(!buffersToWrite.empty());
newBuffer1 = std::move(buffersToWrite.back()); // 复用 buffer
buffersToWrite.pop_back();
newBuffer1->reset(); // 重置指针,置空
}
// 用 buffersToWrite 内的 buffer 重新填充 newBuffer2
if (!newBuffer2) {
assert(!buffersToWrite.empty());
newBuffer2 = std::move(buffersToWrite.back()); // 复用 buffer
buffersToWrite.pop_back();
newBuffer2->reset(); // 重置指针,置空
}
// 清空 buffersToWrite
buffersToWrite.clear();
}
// 存在问题
output.flush();
}
3.5 coredump 查找未落盘的日志
3.6 总结
如何实现高性能的日志
1)批量写入:同步方式通过积攒一定的数据(如4M)或者设定超时时间,以此触发写入。如glog日志库。异步方式通过append积攒数据,异步落盘线程负责数据写入磁盘,如moduo日志库。
2)唤醒机制:通知唤醒 notify + 超时唤醒 wait_timeout
3)锁的粒度:为减少锁的粒度,减少刷新磁盘的时候日志接口阻塞,采用双队列方式。前台队列实现日志接口,后台队列实现刷新磁盘。
4)内存分配:通过move语义,避免深拷贝;双缓冲,前台后台都设有。