26. 写时复制(Copy-on-Write):智能指针实现共享语义
写时复制,英文叫 Copy-on-Write,简称 COW。这名字听着挺学术,其实说白了就是——「先凑合用,真要改了再复制」。
我刚开始接触这个概念时,是在做文本编辑器的一个底层模块。当时有个需求:多个用户同时查看同一份文档,大部分时间只是读,偶尔才有人改。如果每个人打开文档都复制一份,内存很快就炸了。嗯,这时候 COW 就派上了用场。
什么是写时复制?
写时复制的核心思想很简单:
- 读操作不复制——大家共享同一份数据
- 写操作才复制——谁要改,谁先复制一份再改
你想想看,如果 100 个线程都在读同一个字符串,却要维护 100 份副本,这得多浪费?COW 就是来解决这个问题的。
核心原则:共享读,独占写。读时不加锁,写时先复制。
COW 与智能指针的关系
智能指针天然适合实现 COW。为什么?因为智能指针本身就管理着引用计数和生命周期。我们只需要在写操作前检查引用计数,如果大于 1,就先复制一份。
我个人习惯用 std::shared_ptr 来承载 COW 的数据。但要注意,shared_ptr 本身不提供 COW 语义,你得自己封装一层。
一个简单的 COW 实现
下面这个例子,我封装了一个 COW 字符串类。它内部用 shared_ptr 管理数据,写操作时自动触发复制。
#include <iostream>
#include <memory>
#include <cstring>
class CowString {
public:
CowString(const char* str = "") {
size_t len = strlen(str) + 1;
data_ = std::make_shared<std::string>(str);
}
// 读操作——不复制
const char* c_str() const {
return data_->c_str();
}
// 写操作——先复制再改
void append(const char* suffix) {
if (data_.use_count() > 1) {
// 有人共享,先复制
data_ = std::make_shared<std::string>(*data_);
}
*data_ += suffix;
}
size_t use_count() const {
return data_.use_count();
}
private:
std::shared_ptr<std::string> data_;
};
int main() {
CowString s1("Hello");
CowString s2 = s1; // 共享同一份数据
std::cout << "s1 use_count: " << s1.use_count() << "\n"; // 2
std::cout << "s2 use_count: " << s2.use_count() << "\n"; // 2
s2.append(" World"); // 写操作,触发复制
std::cout << "s1: " << s1.c_str() << "\n"; // Hello
std::cout << "s2: " << s2.c_str() << "\n"; // Hello World
std::cout << "s1 use_count: " << s1.use_count() << "\n"; // 1
std::cout << "s2 use_count: " << s2.use_count() << "\n"; // 1
return 0;
}
看到没?s2.append 之前,两个对象共享同一份数据。一旦 s2 要修改,它先检查引用计数——发现是 2,说明有人共享——于是复制一份再改。这样 s1 完全不受影响。
COW 的适用场景
| 场景 | 是否适合 COW | 原因 |
|---|---|---|
| 大量读、少量写 | ✅ 非常适合 | 共享读节省内存,写时复制开销小 |
| 频繁写操作 | ❌ 不适合 | 每次写都要复制,性能反而更差 |
| 数据体积大 | ✅ 适合 | 复制成本高,共享收益明显 |
| 数据体积小 | ⚠️ 看情况 | 复制开销小,但 COW 本身有管理成本 |
我的经验:COW 最适合「读多写少、数据大」的场景。比如配置信息、模板数据、只读缓存。我曾经在一个日志分析工具里用 COW 管理大块元数据,内存占用直接降了 60%。
COW 的避坑指南
嗯,这里要注意几个坑。我曾经踩过,说出来都是泪。
坑一:多线程下的竞态条件
检查引用计数和复制操作不是原子的。如果两个线程同时检查到引用计数为 2,都去复制,就会产生两份副本。解决方案是加锁,或者用原子操作。
坑二:const 成员函数也可能触发写
如果你在 const 函数里返回了内部数据的非 const 引用或指针,调用者可能通过它修改数据,绕过 COW 机制。我建议 const 函数只返回 const 引用或值。
坑三:引用计数膨胀
如果大量对象共享同一份数据,引用计数会很高。每次写操作都要复制,性能会下降。我曾经遇到过引用计数达到 5000 的情况,写一次卡半天。后来加了阈值,超过一定数量就主动复制。
COW 的核心逻辑图
下面这张图,我用 SVG 画了 COW 的完整流程。你可以看到读操作和写操作走了完全不同的路径。
COW 与智能指针的深度结合
如果你想让 COW 更通用,可以把它封装成一个模板类。这样任何类型都能享受 COW 的好处。
template <typename T>
class CowPtr {
public:
CowPtr() : ptr_(std::make_shared<T>()) {}
explicit CowPtr(T* raw) : ptr_(raw) {}
// 读操作
const T& read() const {
return *ptr_;
}
// 写操作
T& write() {
if (ptr_.use_count() > 1) {
ptr_ = std::make_shared<T>(*ptr_);
}
return *ptr_;
}
size_t use_count() const {
return ptr_.use_count();
}
private:
std::shared_ptr<T> ptr_;
};
这个模板类用起来很简单:
CowPtr<std::vector<int>> cow_vec;
cow_vec.write().push_back(42); // 写操作
auto val = cow_vec.read()[0]; // 读操作
一个小建议:如果你在写多线程代码,记得在 write() 里加锁。或者用 std::atomic<size_t> 来管理引用计数。我一般会用 std::shared_ptr 配合 std::mutex,虽然有点重,但安全第一。
COW 的现代 C++ 替代方案
说实话,C++17 之后,COW 的使用场景变少了。因为移动语义和右值引用让「复制」的成本大大降低。但 COW 在以下场景仍然不可替代:
- 大块数据的共享读——比如几十 MB 的配置文件
- 不可变数据的延迟复制——比如函数式数据结构
- 跨模块的数据共享——比如 DLL 之间传递数据
我个人觉得,COW 是一种「思想」而不是「工具」。它教会我们一个道理:不要急着复制,先看看能不能共享。这个思路在内存管理、网络编程、甚至数据库设计里都通用。
好了,这一章就到这里。COW 看着简单,但用好了能省不少内存。下一章我们聊聊另一种共享语义的实现——引用计数与弱指针的配合。嗯,到时候见。