mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1242 字
3 分钟
双缓冲(Double Buffering)
2026-06-13

一、为什么需要双缓冲#

你玩过画面撕裂的游戏吗?屏幕上半部分显示的是前一帧,下半部分是当前帧,中间一条横线把画面劈成两半。这是因为显示器正在读取帧缓冲区的同时,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 个
脏标记脏标记追踪哪个缓冲区已变更脏标记是双缓冲的辅助机制
MVCCMVCC 是多版本的双缓冲双缓冲只有 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;
}

六、生产验证#

  • ReactReactFiber.js#L327-L355createWorkInProgress 创建或复用 alternate fiber,注释写道「We use a double buffering pooling technique because we know we’ll only ever need at most two versions of a tree」
  • SDLSDL_render.c#L5535-L5570SDL_RenderPresent 刷新渲染命令后调用后端 RenderPresent 交换前后缓冲区,实现无撕裂帧呈现
  • PostgreSQL — MVCC 的快照隔离本质上也是双缓冲思想:读者看到一致快照,写者准备新版本,提交时原子切换可见性

七、小结#

何时使用:

  • 渲染管线——GPU 前后 buffer、游戏帧渲染
  • 并发读写——读取方看到一致状态,写入方准备下一版本
  • 树协调——React 的 fiber 架构用此来 diff 新旧树
  • 零分配热路径——永远复用两个 buffer 而非分配新的

何时不用:

  • 简单状态更新——单值且更新是原子的,双缓冲增加不必要的复杂性
  • 内存受限环境——2 倍内存开销不可接受
  • 需要实时读取进行中的写入——双缓冲会隐藏更新直到交换完成
  • 写入中途不应交换——半完成的写入被读者看到会重新引入撕裂

八、参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

双缓冲(Double Buffering)
https://blog.souloss.com/posts/programming/concurrency/concurrency-double-buffering/
作者
Tsukimi
发布于
2026-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时