1242 字
3 分钟
双缓冲(Double Buffering)
一、为什么需要双缓冲
你玩过画面撕裂的游戏吗?屏幕上半部分显示的是前一帧,下半部分是当前帧,中间一条横线把画面劈成两半。这是因为显示器正在读取帧缓冲区的同时,GPU 也在往同一个缓冲区写入新像素——读写撞车了。
这个问题不止出现在图形渲染中。任何一个「一边在读、一边在写」的场景都可能出问题:配置热更新时,读线程可能看到写了一半的配置;指标采集时,采集器可能读到正在被修改的统计数据。加锁可以解决,但锁意味着读线程要等写线程,写线程要等读线程——在追求低延迟的场景下,这种等待不可接受。
双缓冲的解法很巧妙:准备两份副本,一份给读者用,一份给写者用。写完之后,原子地交换两个指针。读者永远看到完整一致的状态,写者永远有独立的空间准备新版本,两者互不干扰。
二、现实类比
有两个出餐窗口的餐厅厨房。厨师在一边准备下一单,服务员从另一边取走当前的单。新的做好了就交换窗口——顾客永远看不到做了一半的菜。
三、核心思想
双缓冲维护数据结构的两个版本:一个「当前」(正在被读取)和一个「工作中」(正在被写入)。写入完成后,两者原子交换。交换只是指针切换,O(1) 完成。
stateDiagram-v2
state "Buffer A(当前/读取)" as A
state "Buffer B(工作中/写入)" as B
[*] --> A
[*] --> B
A --> B : 交换后 B 变为当前
B --> A : 交换后 A 变为工作中
交换后,旧的「当前」变为新的「工作中」(被复用,不会被 GC)。同样的两个对象永远被回收利用——热路径上零分配。
| 属性 | 值 |
|---|---|
| 交换 | O(1)——指针/引用交换 |
| 热路径分配 | 零——两个缓冲区预分配并循环使用 |
| 内存 | 2 倍单缓冲区——恰好两份拷贝 |
| 撕裂 | 不可能——读者始终看到一致的快照 |
四、变体与对比
| 模式 | 关系 | 区别 |
|---|---|---|
| 写时复制(CoW) | 都延迟变更成本 | 双缓冲交换整份副本;CoW 在写入时才复制被修改的部分 |
| 环形缓冲区 | 环形缓冲区可视为多槽位泛化 | 双缓冲只有两个槽位;环形缓冲区有 N 个 |
| 脏标记 | 脏标记追踪哪个缓冲区已变更 | 脏标记是双缓冲的辅助机制 |
| MVCC | MVCC 是多版本的双缓冲 | 双缓冲只有 2 个版本;MVCC 保留多个带时间戳的版本 |
Note
如果双缓冲消除了撕裂,为什么 GPU 还要用三缓冲?双缓冲开启 vsync 时,GPU 提前完成一帧也得等下一个 vsync 才能交换——管线停滞。第三个缓冲区让 GPU 可以继续渲染,显示器始终拿到最近完成的帧,降低输入延迟而不会重新引入撕裂。
五、多语言实现
Go:通用双缓冲
type DoubleBuffer[T any] struct { buffers [2]T current int}
func NewDoubleBuffer[T any](init T, clone func(T) T) *DoubleBuffer[T] { return &DoubleBuffer[T]{ buffers: [2]T{clone(init), init}, current: 0, }}
func (db *DoubleBuffer[T]) Current() *T { return &db.buffers[db.current]}
func (db *DoubleBuffer[T]) Next() *T { return &db.buffers[1-db.current]}
func (db *DoubleBuffer[T]) Swap() { db.current = 1 - db.current}TypeScript:React Fiber 风格双缓冲
class DoubleBuffer<T> { private buffers: [T, T]; private currentIndex: 0 | 1 = 0;
constructor(createBuffer: () => T) { this.buffers = [createBuffer(), createBuffer()]; }
current(): T { return this.buffers[this.currentIndex]; } next(): T { return this.buffers[this.currentIndex === 0 ? 1 : 0]; } swap(): void { this.currentIndex = this.currentIndex === 0 ? 1 : 0; }}
// React Fiber 中的实际用法interface Fiber { tag: string; memoizedState: unknown; alternate: Fiber | null;}
function createWorkInProgress(current: Fiber): Fiber { let wip = current.alternate; if (wip === null) { // 首次渲染:创建 alternate wip = { tag: current.tag, memoizedState: current.memoizedState, alternate: current }; current.alternate = wip; } else { // 后续渲染:复用 alternate(零分配) wip.memoizedState = current.memoizedState; } return wip;}六、生产验证
- React — ReactFiber.js#L327-L355:
createWorkInProgress创建或复用 alternate fiber,注释写道「We use a double buffering pooling technique because we know we’ll only ever need at most two versions of a tree」 - SDL — SDL_render.c#L5535-L5570:
SDL_RenderPresent刷新渲染命令后调用后端RenderPresent交换前后缓冲区,实现无撕裂帧呈现 - PostgreSQL — MVCC 的快照隔离本质上也是双缓冲思想:读者看到一致快照,写者准备新版本,提交时原子切换可见性
七、小结
何时使用:
- 渲染管线——GPU 前后 buffer、游戏帧渲染
- 并发读写——读取方看到一致状态,写入方准备下一版本
- 树协调——React 的 fiber 架构用此来 diff 新旧树
- 零分配热路径——永远复用两个 buffer 而非分配新的
何时不用:
- 简单状态更新——单值且更新是原子的,双缓冲增加不必要的复杂性
- 内存受限环境——2 倍内存开销不可接受
- 需要实时读取进行中的写入——双缓冲会隐藏更新直到交换完成
- 写入中途不应交换——半完成的写入被读者看到会重新引入撕裂
八、参考资料
- React Fiber 架构 - React 双缓冲 fiber 树的设计文档
- SDL 渲染源码 - SDL 双缓冲渲染实现
- OpenGL Swap Chains - Khronos 对双缓冲和三缓冲的说明
- PostgreSQL MVCC - PostgreSQL 多版本并发控制文档
- GPU 三缓冲 - NVIDIA 对双缓冲 vs 三缓冲的解释
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
双缓冲(Double Buffering)
https://blog.souloss.com/posts/programming/concurrency/concurrency-double-buffering/ 部分信息可能已经过时
相关文章 智能推荐






