C++ DoubleBuffer 双缓冲技术
一、背景:要解决什么问题?
在高并发服务中,经常会遇到这样的需求:
-
配置 / 策略 / 规则 需要动态更新
-
读请求 非常频繁
-
更新 相对较少
-
不能因为更新 阻塞或影响读请求
核心诉求:读要极快,最好无锁;写可以慢一点,但要安全。
1、最基础写法
std::string current_notice;
std::string getNotice() {
return current_notice;
}
void updateNotice(const std::string& n) {
current_notice = n;
}
问题:在多线程下:一个线程在读 current_notice,另一个线程正在改 current_notice,这就会造成
- 数据竞争
- 可能读到一半
- 未定义行为
2、加锁写法
std::mutex m;
std::string current_notice;
std::string getNotice() {
std::lock_guard<std::mutex> lk(m);
return current_notice;
}
void updateNotice(const std::string& n) {
std::lock_guard<std::mutex> lk(m);
current_notice = n;
}
问题:读请求非常多,每次都要抢锁,高并发下 性能下降明显。
3、读写锁优化
std::shared_mutex rw_lock;
std::string current_notice;
std::string getNotice() {
std::shared_lock<std::shared_mutex> lk(rw_lock);
return current_notice;
}
void updateNotice(const std::string& n) {
std::unique_lock<std::shared_mutex> lk(rw_lock);
current_notice = n;
}
改进:读锁可以并发,但仍然有锁开销。在极高并发场景下(如每秒百万级读请求),锁竞争依然会成为瓶颈。
二、DoubleBuffer 是什么?
DoubleBuffer(双缓冲) 的核心思想是:始终维护两份数据。
-
一份给读线程用(active)
-
一份给写线程改(inactive)
当写完后,将写线程数据切换给读线程。
基本流程:
初始状态:
buffer[0] ← 读线程在用(active)
buffer[1] ← 写线程可以改(inactive)
更新流程:
1. 写线程修改 buffer[1]
2. 切换指针:active = buffer[1]
3. 下次更新时改 buffer[0]
问题:谁负责"等读线程看完"再回收旧数据?
假设:
- 读线程 A 正在读 buffer[0]
- 写线程切换到 buffer[1]
- 读线程 B 开始读 buffer[1]
- 写线程想要修改 buffer[0]...
读线程 A 还没读完呢!如果直接改 buffer[0],就会出现数据竞争。
三、shared_ptr:自动“等所有读线程看完后再撤”
shared_ptr引用计数的方式可以确认当前是否有读操作。
- 没有读操作的时候引用计数清零自动析构,
- 再次读的时候直接从写操作拷贝一份来读
实现原理:
#include <memory>
#include <atomic>
#include <mutex>
template<typename T>
class DoubleBuffer {
public:
DoubleBuffer() {
// 初始化两个buffer
buffers_[0] = std::make_shared<T>();
buffers_[1] = std::make_shared<T>();
index_.store(0);
}
// 读操作:无锁,只有原子操作
std::shared_ptr<T> read() {
return std::atomic_load(&buffers_[index_.load()]);
}
// 写操作:需要加锁保证写写互斥
template<typename Func>
void modify(Func&& func) {
std::lock_guard<std::mutex> lock(write_mutex_);
// 获取当前inactive的buffer索引
int current_idx = index_.load();
int next_idx = 1 - current_idx;
// 复制当前数据到inactive buffer
auto new_data = std::make_shared<T>(*buffers_[current_idx]);
// 修改新数据
func(*new_data);
// 更新inactive buffer
buffers_[next_idx] = new_data;
// 原子切换索引
index_.store(next_idx);
// 旧的buffer会在所有shared_ptr释放后自动析构
}
private:
std::shared_ptr<T> buffers_[2];
std::atomic<int> index_{0};
std::mutex write_mutex_; // 保护写操作
};
为什么 shared_ptr 能解决问题?
1.读线程获取 shared_ptr
auto data = double_buffer.read(); // 引用计数 +1
// 使用 data...
2.写线程切换 buffer
// 即使切换了,旧的 buffer 因为还被读线程持有
// 引用计数不为 0,不会被析构
index_.store(next_idx);
3.读线程用完
} // data 离开作用域,引用计数 -1
// 如果降为 0,自动析构
四、完整使用示例
示例:热配置更新
#include <iostream>
#include <thread>
#include <chrono>
struct Config {
std::string server_url;
int timeout_ms;
bool debug_mode;
};
int main() {
DoubleBuffer<Config> config_buffer;
// 初始化配置
config_buffer.modify([](Config& cfg) {
cfg.server_url = "http://api.example.com";
cfg.timeout_ms = 3000;
cfg.debug_mode = false;
});
// 读线程:高频读取配置
auto reader = [&]() {
for (int i = 0; i < 1000000; ++i) {
auto cfg = config_buffer.read(); // 无锁读取
// 使用配置...
if (i % 100000 == 0) {
std::cout << "Read: " << cfg->server_url
<< ", timeout=" << cfg->timeout_ms << "\n";
}
}
};
// 写线程:定期更新配置
auto writer = [&]() {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
config_buffer.modify([](Config& cfg) {
cfg.server_url = "http://new-api.example.com";
cfg.timeout_ms = 5000;
cfg.debug_mode = true;
});
std::cout << "Config updated!\n";
};
std::thread t1(reader);
std::thread t2(reader);
std::thread t3(writer);
t1.join();
t2.join();
t3.join();
return 0;
}
五、性能对比
测试场景:100万次读,1次写
| 方案 | 读操作耗时 | 写操作耗时 |
|---|---|---|
| 互斥锁(mutex) | ~500ms | ~10μs |
| 读写锁(shared_mutex) | ~200ms | ~20μs |
| DoubleBuffer | ~50ms | ~100μs |
结论:DoubleBuffer 在读多写少的场景下,读性能提升 4-10 倍。
六、DoubleBuffer 的优缺点
优点
- 读操作几乎无锁:只有一次原子加载 + shared_ptr拷贝
- 读写不互斥:读线程不会被写线程阻塞
- 自动内存管理:shared_ptr 自动回收旧数据
- 线程安全:天然避免数据竞争
缺点
- 内存开销:始终维护两份数据
- 写操作需要拷贝:每次写都要复制整个对象
- 不适合大对象:如果数据结构很大(如GB级),拷贝开销很高
- 写写互斥:多个写线程之间仍需加锁

浙公网安备 33010602011771号