欢迎光临
我们一直在努力

双缓冲队列异步日志系统(下):核心实现:环形缓冲与双缓冲

AsyncLogger 核心实现:环形缓冲与双缓冲

本文深入 AsyncLogger 的内存缓冲与后台消费机制:为何采用双缓冲、环形缓冲区如何与后台线程配合,以及 Sink 如何扩展。阅读前建议先看 《AsyncLogger 设计与使用指南》 建立整体印象。

  • 项目仓库: https://github.com/YW8862/AsyncLogger

一、为什么是「双缓冲」而不是「单队列」?

若只用一个队列:业务线程往队尾 Push,后台线程从队头 Pop,理论上也可以。但实现上会遇到:

  • 边界与锁:队列头尾指针的更新需要精细的同步,否则容易在「判空 / 判满」上出错。
  • 批量效率:后台若一条条 Pop,每次都要抢锁;若想「一次取一批」减少锁竞争,又要在「何时取、取多少」上做文章。

双缓冲的思路是:准备两块缓冲区 A 和 B。

  • 平时:业务线程只往 A 里追加(前台缓冲区);后台线程只读 B(后台缓冲区)。
  • 交换时刻:当「A 写满」或「定时到了」时,交换 A 与 B:业务接下来写的是「原来的 B」(已清空),后台处理的是「原来的 A」(一整块数据)。 交换后,业务和后台不再共享同一块内存,因此不存在「一边写一边读」的竞争;后台可以安心遍历、格式化、写文件。

可以类比成「双黑板」:一块写满就与另一块对调,老师在一块上写字,助教在另一块上擦写/抄录,互不干扰。


二、整体流程(从 Push 到落盘)

下面这张图概括了「业务线程写一条日志」到「后台线程写盘」的时序关系(省略了格式化与 Sink 细节)。

Sink(文件/控制台)

后台线程

RingBuffer

业务线程

Sink(文件/控制台)

后台线程

RingBuffer

业务线程

#mermaid-svg-R2vvKEwx838TkHXM{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-R2vvKEwx838TkHXM .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-R2vvKEwx838TkHXM .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-R2vvKEwx838TkHXM .error-icon{fill:#552222;}#mermaid-svg-R2vvKEwx838TkHXM .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-R2vvKEwx838TkHXM .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-R2vvKEwx838TkHXM .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-R2vvKEwx838TkHXM .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-R2vvKEwx838TkHXM .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-R2vvKEwx838TkHXM .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-R2vvKEwx838TkHXM .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-R2vvKEwx838TkHXM .marker{fill:#333333;stroke:#333333;}#mermaid-svg-R2vvKEwx838TkHXM .marker.cross{stroke:#333333;}#mermaid-svg-R2vvKEwx838TkHXM svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-R2vvKEwx838TkHXM p{margin:0;}#mermaid-svg-R2vvKEwx838TkHXM .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-R2vvKEwx838TkHXM text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-R2vvKEwx838TkHXM .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-R2vvKEwx838TkHXM .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-R2vvKEwx838TkHXM .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-R2vvKEwx838TkHXM .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-R2vvKEwx838TkHXM #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-R2vvKEwx838TkHXM .sequenceNumber{fill:white;}#mermaid-svg-R2vvKEwx838TkHXM #sequencenumber{fill:#333;}#mermaid-svg-R2vvKEwx838TkHXM #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-R2vvKEwx838TkHXM .messageText{fill:#333;stroke:none;}#mermaid-svg-R2vvKEwx838TkHXM .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-R2vvKEwx838TkHXM .labelText,#mermaid-svg-R2vvKEwx838TkHXM .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-R2vvKEwx838TkHXM .loopText,#mermaid-svg-R2vvKEwx838TkHXM .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-R2vvKEwx838TkHXM .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-R2vvKEwx838TkHXM .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-R2vvKEwx838TkHXM .noteText,#mermaid-svg-R2vvKEwx838TkHXM .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-R2vvKEwx838TkHXM .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-R2vvKEwx838TkHXM .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-R2vvKEwx838TkHXM .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-R2vvKEwx838TkHXM .actorPopupMenu{position:absolute;}#mermaid-svg-R2vvKEwx838TkHXM .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-R2vvKEwx838TkHXM .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-R2vvKEwx838TkHXM .actor-man circle,#mermaid-svg-R2vvKEwx838TkHXM line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-R2vvKEwx838TkHXM :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

写入 front_buffer_

若超时或收到通知,交换 front/back

Push(LogEntry)

size >= capacity/2 ? notify_one()

SwapAndGet(entries, timeout_ms)

entries (原 front_buffer_)

ProcessEntries: 格式化每条

Write(formatted) / Flush

要点:

  • Push:只操作 front_buffer_,并在达到一定填充率时 notify_one() 唤醒后台。
  • SwapAndGet:在持有同一把锁的前提下「交换两块缓冲区」,把原来的前台整块交给调用方,自己保留空的那块作为新的前台。这样一次交换得到一批条目,后台批量处理,锁的粒度是「一批」而不是「一条」。

三、数据结构:LogEntry 与 RingBuffer

3.1 LogEntry

每条日志在内存中的表示是「未格式化」的 LogEntry,便于在缓冲里高效移动(值语义 + move):

struct LogEntry {
int64_t timestamp; // 微秒时间戳
int level; // 日志级别
int line; // 行号
std::string file; // 文件名
std::string message; // 消息体(已由宏侧 ostringstream 拼好)
std::string tags; // 格式化后的 Tag 字符串,如 "user_id=alice,action=login"
};

业务线程在宏里已经完成「流式拼接 → string」,这里只做拷贝/移动,不再在缓冲区内做格式化,格式化集中在后台线程,避免占用业务时间。

3.2 RingBuffer 的双缓冲

RingBuffer 的「环形」体现在逻辑上:有两块 vector<LogEntry>,通过 swap 轮转使用,而不是一块物理上的环形数组。类内核心成员可概括为:

// 容量(条数)
size_t capacity_;

// 双缓冲:前台写入,后台读出
std::vector<LogEntry> front_buffer_; // 业务线程 Push 的目标
std::vector<LogEntry> back_buffer_; // 交换时暂存,再交给调用方

std::mutex mutex_;
std::condition_variable cv_;
std::atomic<bool> stopped_;

  • Push(LogEntry&& entry):

    • 若 front_buffer_.size() >= capacity_,当前实现选择丢弃该条并返回 false,避免阻塞业务线程。
    • 否则 front_buffer_.push_back(std::move(entry)),并在 size >= capacity_/2 时 cv_.notify_one(),提示后台「可以来取一批了」。
  • SwapAndGet(entries, timeout_ms)(仅后台线程调用):

    • 若当前 front_buffer_ 为空且未 Stop,可 cv_.wait_for(…, timeout_ms) 等待。
    • 若 front_buffer_ 非空:清空 back_buffer_,然后 std::swap(front_buffer_, back_buffer_),再把 back_buffer_ 的内容 move 到 entries 并返回条数。
    • 这样前台立刻得到一块空 vector 继续接收新日志,后台拿到「一整块」历史数据,无需在 Push 侧做 pop 逻辑。

用流程图表示一次「交换」:

#mermaid-svg-mBPFNYDfelgc7uJe{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-mBPFNYDfelgc7uJe .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-mBPFNYDfelgc7uJe .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-mBPFNYDfelgc7uJe .error-icon{fill:#552222;}#mermaid-svg-mBPFNYDfelgc7uJe .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-mBPFNYDfelgc7uJe .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-mBPFNYDfelgc7uJe .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-mBPFNYDfelgc7uJe .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-mBPFNYDfelgc7uJe .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-mBPFNYDfelgc7uJe .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-mBPFNYDfelgc7uJe .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-mBPFNYDfelgc7uJe .marker{fill:#333333;stroke:#333333;}#mermaid-svg-mBPFNYDfelgc7uJe .marker.cross{stroke:#333333;}#mermaid-svg-mBPFNYDfelgc7uJe svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-mBPFNYDfelgc7uJe p{margin:0;}#mermaid-svg-mBPFNYDfelgc7uJe .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-mBPFNYDfelgc7uJe .cluster-label text{fill:#333;}#mermaid-svg-mBPFNYDfelgc7uJe .cluster-label span{color:#333;}#mermaid-svg-mBPFNYDfelgc7uJe .cluster-label span p{background-color:transparent;}#mermaid-svg-mBPFNYDfelgc7uJe .label text,#mermaid-svg-mBPFNYDfelgc7uJe span{fill:#333;color:#333;}#mermaid-svg-mBPFNYDfelgc7uJe .node rect,#mermaid-svg-mBPFNYDfelgc7uJe .node circle,#mermaid-svg-mBPFNYDfelgc7uJe .node ellipse,#mermaid-svg-mBPFNYDfelgc7uJe .node polygon,#mermaid-svg-mBPFNYDfelgc7uJe .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-mBPFNYDfelgc7uJe .rough-node .label text,#mermaid-svg-mBPFNYDfelgc7uJe .node .label text,#mermaid-svg-mBPFNYDfelgc7uJe .image-shape .label,#mermaid-svg-mBPFNYDfelgc7uJe .icon-shape .label{text-anchor:middle;}#mermaid-svg-mBPFNYDfelgc7uJe .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-mBPFNYDfelgc7uJe .rough-node .label,#mermaid-svg-mBPFNYDfelgc7uJe .node .label,#mermaid-svg-mBPFNYDfelgc7uJe .image-shape .label,#mermaid-svg-mBPFNYDfelgc7uJe .icon-shape .label{text-align:center;}#mermaid-svg-mBPFNYDfelgc7uJe .node.clickable{cursor:pointer;}#mermaid-svg-mBPFNYDfelgc7uJe .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-mBPFNYDfelgc7uJe .arrowheadPath{fill:#333333;}#mermaid-svg-mBPFNYDfelgc7uJe .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-mBPFNYDfelgc7uJe .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-mBPFNYDfelgc7uJe .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-mBPFNYDfelgc7uJe .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-mBPFNYDfelgc7uJe .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-mBPFNYDfelgc7uJe .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-mBPFNYDfelgc7uJe .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-mBPFNYDfelgc7uJe .cluster text{fill:#333;}#mermaid-svg-mBPFNYDfelgc7uJe .cluster span{color:#333;}#mermaid-svg-mBPFNYDfelgc7uJe div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-mBPFNYDfelgc7uJe .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-mBPFNYDfelgc7uJe rect.text{fill:none;stroke-width:0;}#mermaid-svg-mBPFNYDfelgc7uJe .icon-shape,#mermaid-svg-mBPFNYDfelgc7uJe .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-mBPFNYDfelgc7uJe .icon-shape p,#mermaid-svg-mBPFNYDfelgc7uJe .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-mBPFNYDfelgc7uJe .icon-shape rect,#mermaid-svg-mBPFNYDfelgc7uJe .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-mBPFNYDfelgc7uJe .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-mBPFNYDfelgc7uJe .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-mBPFNYDfelgc7uJe :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

交换后

交换

交换前

front_buffer_: 有 N 条

back_buffer_: 空

std::swap front_buffer_, back_buffer_

front_buffer_: 空,继续接新日志

back_buffer_: N 条 → 移给 entries


四、后台线程:何时取、如何写

4.1 主循环

后台线程 BackendThread::Run() 的核心逻辑是「循环取一批 → 格式化 → 写 Sink → 定期 Flush」:

void BackendThread::Run() {
std::vector<LogEntry> entries;
entries.reserve(1024);

while (running_.load() || !buffer_.Empty()) {
size_t count = buffer_.SwapAndGet(entries, config_.flush_interval_ms);
if (count > 0) {
ProcessEntries(entries);
}
Flush(); // 定期让各 Sink 刷盘
}
}

  • SwapAndGet(entries, flush_interval_ms):最多等待 flush_interval_ms 毫秒;若有数据则立即或到期后做一次交换,拿到一批 LogEntry。
  • ProcessEntries:对每条 LogEntry 调用 LogFormatter::Format 得到字符串,再写入所有已注册的 Sink(文件、控制台等)。
  • Flush():每次循环末尾调用,保证各 Sink 的缓冲区定期落盘,避免日志在进程崩溃时大量丢失。

退出时(running_ = false)会先 buffer_.Stop() 并 notify_all,再 SwapAndGet(…, 0) 把剩余条目取完并处理,最后关闭各 Sink,因此 Shutdown() 能保证缓冲被清空。

4.2 格式化与 Sink 写入

格式化只在后台做一次,避免在业务线程做字符串拼接和日期格式化:

void BackendThread::ProcessEntries(const std::vector<LogEntry>& entries) {
for (const auto& entry : entries) {
std::string formatted = formatter_.Format(entry);
for (auto& sink : sinks_) {
// ConsoleSink 可按级别着色
auto console_sink = std::dynamic_pointer_cast<ConsoleSink>(sink);
if (console_sink && config_.console_color) {
console_sink->WriteWithLevel(static_cast<LogLevel>(entry.level), formatted);
} else {
sink->Write(formatted);
}
}
}
}

这样,锁只出现在 RingBuffer 的 Push/SwapAndGet 和每个 Sink 的 Write/Flush,业务线程与后台线程在「缓冲」处解耦,在「Sink」处可各自加锁(如 FileSink 的 mutex_),互不干扰。


五、Sink 抽象与扩展

所有输出目标都实现统一的 Sink 接口:

class Sink {
public:
virtual void Write(const std::string& formatted_log) = 0;
virtual void WriteBatch(const std::vector<std::string>& logs); // 默认逐条 Write
virtual void Flush() = 0;
virtual void Close() {}
};

  • ConsoleSink:写到 std::clog(或标准错误),可带按级别的 ANSI 颜色。
  • FileSink:写文件,并在 Write 中根据当前文件大小判断是否 Roll(关闭当前文件、打开新文件、可选地删除最旧文件),实现「按大小滚动、保留最近 N 个文件」。

若需要输出到网络、MQ、或第三方日志服务,只需实现 Sink 并 AddSink 即可,无需改 RingBuffer 或 BackendThread。


六、关键设计小结

设计点作用
双缓冲 + swap 业务写一块、后台读另一块,交换时原子切换,无「边写边读」;批量取数,减少锁竞争。
LogEntry 在业务侧就定形 消息已在宏里拼成 string,缓冲里只存结构化数据,格式化集中在后台,业务路径极短。
容量满则丢弃 避免在 Push 路径上阻塞或扩容,保证业务线程延迟稳定;可通过调大 buffer_size 降低丢弃概率。
定时 + 阈值双触发 既有「每 flush_interval_ms 取一次」的定时刷盘,也有「写满一半就 notify」的及时性,兼顾吞吐与延迟。
Sink 抽象 文件/控制台/未来扩展统一接口,后台线程只依赖 Write/Flush/Close,易于测试和扩展。

理解上述几点后,再回头看 《设计与使用指南》 里的「数据流图」,就能把「为什么能到 170 万 logs/s」「为什么业务线程几乎不阻塞」和「如何保证退出时不丢日志」串起来。若你正在实现自己的异步日志或消息队列,双缓冲 + 批量交换是一个值得复用的模式。

赞(0)
未经允许不得转载:171主机测评 » 双缓冲队列异步日志系统(下):核心实现:环形缓冲与双缓冲
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址