一、什么是双缓冲日志模式?
双缓冲日志模式,简单说就是:
准备两个缓冲区,一个负责让业务线程写日志,另一个负责让日志线程刷盘。
当一个缓冲区写满或满足刷盘条件时,两个缓冲区交换角色。
它的核心目标是:
- 减少业务线程直接写磁盘的开销;
- 降低锁竞争;
- 提高日志写入吞吐;
- 避免每条日志都调用
write(); - 让日志写入变成批量写入。
二、为什么需要双缓冲?
如果每次打印日志都直接写文件,大概是这样:
cpp
void log(const string& msg) {
write(fd, msg.c_str(), msg.length());
}
这看起来简单,但问题很多:
-
磁盘 I/O 慢
write()可能涉及系统调用;- 如果频繁调用,性能开销很大。
-
多线程竞争严重
- 多个业务线程同时写日志,需要加锁;
- 所有线程都在争抢同一个日志文件。
-
业务线程被阻塞
- 日志本来是辅助功能;
- 但直接写文件可能拖慢主业务逻辑。
所以更好的做法是:
业务线程只负责把日志写入内存缓冲区,真正写文件的事情交给后台日志线程。
三、双缓冲的基本结构
通常会有两个缓冲区:
cpp
ostringstream streamA;
ostringstream streamB;
或者类似:
cpp
string bufferA;
string bufferB;
再维护两个指针:
cpp
ostringstream* currentStream; // 当前业务线程写入的缓冲区
ostringstream* writeStream; // 当前日志线程刷盘的缓冲区
逻辑大概是:
cpp
currentStream = &streamA;
writeStream = &streamB;
业务线程不断写:
cpp
*currentStream << logMessage;
日志线程发现需要写文件时,交换两个缓冲区:
cpp
swap(currentStream, writeStream);
之后:
- 业务线程继续往新的
currentStream写日志; - 日志线程把旧的
writeStream内容写入文件。
四、结合其他代码来看
你之前的代码里有这一段:
cpp
lock();
ostringstream *stream = swapStream();
string logstr = stream->str();
stream->str("");
unlock();
这就是典型的双缓冲设计。
可以理解为:
cpp
lock();
加锁,防止交换缓冲区时业务线程还在写。
cpp
ostringstream *stream = swapStream();
切换缓冲区。
比如原来业务线程正在写 streamA,日志线程就把它切走,然后让业务线程改写 streamB。
cpp
string logstr = stream->str();
取出旧缓冲区中的日志内容。
cpp
stream->str("");
清空旧缓冲区,方便下次继续使用。
cpp
unlock();
释放锁。
然后日志线程在锁外写文件:
cpp
int retval = write(fd, logstr.c_str(), logstr.length());
这一点很关键:
只在切换缓冲区时加锁,真正写磁盘时不持有锁。
这样做可以避免业务线程被磁盘 I/O 长时间阻塞。
五、双缓冲日志的运行过程
假设有两个缓冲区:
text
Buffer A
Buffer B
初始状态:
text
业务线程写入 -> Buffer A
日志线程备用 -> Buffer B
业务线程不断写日志:
text
Buffer A:
[log1]
[log2]
[log3]
日志线程准备刷盘时:
text
交换 Buffer A 和 Buffer B
交换后:
text
业务线程写入 -> Buffer B
日志线程刷盘 -> Buffer A
日志线程把 Buffer A 写入文件:
text
write(fd, Buffer A)
写完后清空:
text
Buffer A = empty
下一次再交换:
text
业务线程写入 -> Buffer A
日志线程刷盘 -> Buffer B
反复循环。
六、一个简化版例子
下面是一个简化版双缓冲日志模型,方便理解:
cpp
class DoubleBufferLogger {
private:
ostringstream bufferA;
ostringstream bufferB;
ostringstream* currentBuffer;
ostringstream* flushBuffer;
mutex mtx;
int fd;
public:
DoubleBufferLogger(int logFd)
: currentBuffer(&bufferA),
flushBuffer(&bufferB),
fd(logFd) {}
void append(const string& msg) {
lock_guard<mutex> lock(mtx);
(*currentBuffer) << msg << '\n';
}
void flush() {
string logData;
{
lock_guard<mutex> lock(mtx);
swap(currentBuffer, flushBuffer);
logData = flushBuffer->str();
flushBuffer->str("");
flushBuffer->clear();
}
if (!logData.empty()) {
write(fd, logData.c_str(), logData.size());
}
}
};
重点是这一段:
cpp
{
lock_guard<mutex> lock(mtx);
swap(currentBuffer, flushBuffer);
logData = flushBuffer->str();
flushBuffer->str("");
flushBuffer->clear();
}
它只在交换缓冲区和取数据的时候加锁。
而真正的磁盘写入:
cpp
write(fd, logData.c_str(), logData.size());
是在锁外完成的。
七、再举一个生活化例子
可以把双缓冲日志想象成餐厅洗碗。
有两个盘子筐:
text
筐 A
筐 B
服务员不断把脏盘子放进筐 A。
当筐 A 快满了,洗碗工说:
text
筐 A 给我洗,你们先用筐 B。
于是:
text
服务员继续往筐 B 放盘子;
洗碗工同时清洗筐 A。
等筐 B 快满了,再交换:
text
筐 B 给洗碗工;
服务员继续用筐 A。
这样服务员不会因为洗碗工正在洗盘子而停下来。
对应到日志系统:
| 餐厅场景 | 日志系统 |
|---|---|
| 服务员 | 业务线程 |
| 脏盘子 | 日志消息 |
| 盘子筐 A/B | 日志缓冲区 A/B |
| 洗碗工 | 日志线程 |
| 洗盘子 | 写入日志文件 |
| 交换盘子筐 | switchStream() |
八、再举一个网络发送例子
双缓冲不仅能用于日志,也常用于网络发送。
比如游戏服务器要给客户端发送数据。
如果每产生一条消息就立刻发送:
cpp
send(socketFd, msg.c_str(), msg.size(), 0);
性能会很差。
可以设计两个发送缓冲区:
text
sendBufferA
sendBufferB
业务线程把待发送消息放到 sendBufferA:
cpp
sendBufferA.append(packet);
网络线程准备发送时,交换缓冲区:
cpp
swap(activeSendBuffer, pendingSendBuffer);
然后网络线程批量发送:
cpp
send(socketFd, pendingSendBuffer.data(), pendingSendBuffer.size(), 0);
这样业务线程不用等待网络 I/O。
九、再举一个图形渲染例子
双缓冲在图形渲染里也非常常见。
例如屏幕显示一帧画面时,如果直接在当前屏幕缓冲上画图,用户可能看到画面撕裂或闪烁。
所以图形系统通常有:
text
前台缓冲区 front buffer
后台缓冲区 back buffer
程序先在后台缓冲区画完整的一帧:
text
back buffer: 正在绘制
front buffer: 正在显示
画完之后交换:
text
swap(front buffer, back buffer)
这样用户看到的是完整画面,而不是正在绘制一半的画面。
这个思想和双缓冲日志非常类似:
| 图形渲染 | 日志系统 |
|---|---|
| back buffer | 当前写入日志的缓冲区 |
| front buffer | 准备输出的缓冲区 |
| 绘图线程 | 业务线程 |
| 显示器刷新 | 日志线程写文件 |
| buffer swap | switchStream() |
十、双缓冲日志的优点
1. 减少系统调用次数
不用每条日志都 write()。
可以把很多日志合并成一次写入:
cpp
write(fd, logstr.c_str(), logstr.length());
这是批量写入。
2. 减少锁持有时间
你的代码中锁的范围是:
cpp
lock();
ostringstream *stream = swwapStream();
string logstr = stream->str();
stream->str("");
unlock();
写文件不在锁里面:
cpp
write(fd, logstr.c_str(), logstr.length());
这很好。
因为磁盘 I/O 可能很慢,如果持锁写磁盘,其他线程写日志就会被卡住。
3. 提高业务线程性能
业务线程只负责写内存:
cpp
*currentStream << msg;
内存操作很快。
真正慢的文件写入交给后台线程处理。
4. 更适合高并发日志
多个业务线程可以快速把日志放到缓冲区里,然后让日志线程统一刷盘。
这比所有线程直接抢日志文件要好很多。
十一、需要注意的问题
双缓冲日志虽然好,但也有一些坑。
1. 程序崩溃时可能丢日志
因为日志先写进内存缓冲区,不是马上落盘。
如果程序突然崩溃,缓冲区里的日志可能还没写入文件。
解决办法:
- 重要错误日志立即刷盘;
- 定时刷盘;
- 程序退出前强制 flush;
- 对严重日志调用
fsync(),但性能会下降。
2. 日志量太大时缓冲区可能撑爆
如果业务线程写得太快,而日志线程刷盘太慢,缓冲区可能不断变大。
解决办法:
- 设置缓冲区最大容量;
- 超过容量后丢弃低级别日志;
- 阻塞业务线程;
- 使用多缓冲区队列;
- 按日志级别限流。
3. ostringstream 清理要注意
清空内容时常见写法是:
cpp
stream->str("");
stream->clear();
str("") 是清空内部字符串内容。
clear() 是清除流状态,比如错误状态。
更稳妥的写法是两个都调用。
4. write() 不一定一次写完
你代码里这一句:
cpp
int retval = write(fd, logstr.c_str(), logstr.length());
需要注意:write() 返回值可能小于 logstr.length()。
也就是说,它可能只写入了一部分。
更稳妥的写法应该循环写:
cpp
ssize_t writeAll(int fd, const char* data, size_t len) {
size_t total = 0;
while (total < len) {
ssize_t n = write(fd, data + total, len - total);
if (n < 0) {
if (errno == EINTR) {
continue;
}
return -1;
}
if (n == 0) {
break;
}
total += n;
}
return total;
}
然后:
cpp
ssize_t ret = writeAll(m_ltLogFD, logstr.c_str(), logstr.length());
if (ret < 0) {
coutLog(__FILE__, __LINE__, LL_ERROR,
"write to log file failed. errno=%d", errno);
return ret;
}
十二、适合写到 blog 的总结
你可以在 blog 里这样描述:
双缓冲日志模式是一种常见的异步日志优化方案。它通过维护两个内存缓冲区,让业务线程和日志写入线程分别工作在不同的缓冲区上。当日志线程需要刷盘时,只需要短暂加锁并交换两个缓冲区,随后在锁外将旧缓冲区中的日志批量写入文件。这样既减少了磁盘 I/O 次数,又缩短了锁持有时间,从而提高多线程程序中的日志写入性能。
也可以再精简成一句:
双缓冲日志的核心思想是:业务线程只写内存,日志线程批量刷盘,二者通过交换缓冲区降低锁竞争和 I/O 开销。
你的那段代码里,最关键的双缓冲逻辑就是:
cpp
lock();
ostringstream *stream = swapStream();
string logstr = stream->str();
stream->str("");
unlock();
其中 swapStream() 很可能就是将当前写入缓冲区和待刷盘缓冲区进行交换。
这就是双缓冲日志模式的核心。