写时复制:COW技术、std::string实现
写时复制,英文叫 Copy-on-Write,简称 COW。这名字挺直白——只有在真正需要写的时候,才去复制。
我最早接触 COW,是在读 Linux 内核源码的时候。fork() 出来的子进程,并没有立刻复制父进程的整个地址空间,而是共享同一份物理内存。直到某一方要写数据了,内核才把那一页复制一份。嗯,这就是典型的 COW 思想。
后来在 C++ 里,std::string 也用过这个套路。说白了,就是多个 string 对象共享同一块字符缓冲区,只有当其中一个要修改内容时,才真正分配自己的内存。
COW 的核心思想
COW 的精髓就一句话:读时不复制,写时才复制。
你想想看,很多时候我们拷贝一个字符串,只是为了读取它,根本不会改。如果每次拷贝都老老实实分配内存、复制字符,那太浪费了。COW 的做法是:
- 拷贝时只增加一个引用计数,共享底层缓冲区
- 读操作直接走共享缓冲区,不触发复制
- 写操作前检查引用计数,如果大于 1,才真正复制一份
我在项目中遇到过这样一个场景:一个配置字符串被多个模块读取,但几乎从不修改。如果用普通的 string,每个模块都得自己存一份副本,内存开销翻了好几倍。换成 COW 风格的实现,大家共享同一份数据,内存一下子就降下来了。
COW 的适用场景:读多写少、字符串拷贝频繁、内存敏感的环境。
std::string 的 COW 实现
早期的 C++ 标准库(比如 GCC 的 libstdc++ 在 C++11 之前)确实用 COW 实现了 std::string。它的内部结构大致是这样的:
// 伪代码,展示 COW string 的核心结构
class String {
struct Rep {
size_t refcount; // 引用计数
size_t length;
size_t capacity;
char data[1]; // 柔性数组
};
Rep* rep_;
void ensure_unique() {
if (rep_->refcount > 1) {
// 有人共享,必须复制
Rep* new_rep = allocate(rep_->capacity);
memcpy(new_rep->data, rep_->data, rep_->length);
new_rep->refcount = 1;
release(); // 减少原引用计数
rep_ = new_rep;
}
}
};
每次调用 operator[] 或者 begin() 这种可能返回内部指针的操作,都得先调用 ensure_unique()。否则你拿到一个指针,然后通过它修改了内容,其他共享对象就遭殃了。
注意:COW 在 C++11 之后被 std::string 抛弃了。原因很复杂,但核心问题是多线程下的性能开销和语义歧义。
COW 的坑:多线程与引用计数
我曾经在一个多线程服务里吃过 COW 的亏。当时用的还是老版本的 GCC,std::string 默认就是 COW 实现。两个线程同时读取同一个 string,没问题。但一旦某个线程触发了写操作,引用计数的修改就需要原子操作。
你想想看,每次读操作都要原子地增加引用计数,写操作要原子地减少。在高并发场景下,这个原子操作的开销可不小。更麻烦的是,COW 的引用计数操作和 string 内部的锁机制交织在一起,很容易出现性能抖动。
我记得有一次线上排查,发现某个接口的 p99 延迟突然飙升。查了半天,最后定位到是 string 的 COW 机制在作祟——某个热点字符串被频繁拷贝和修改,引用计数的原子操作成了瓶颈。
我的建议:如果你还在用 C++11 之前的编译器,或者某些嵌入式环境里保留了 COW string,请务必注意多线程场景。最好用 std::shared_ptr<std::string> 手动管理共享,或者干脆禁用 COW。
COW 的替代方案:SSO 与 SBO
现代 std::string 普遍采用 Small String Optimization(SSO)。它的思路和 COW 正好相反——小字符串直接存在栈上,不分配堆内存。比如 GCC 的 std::string,长度小于 15 的字符串直接存在对象内部,连堆分配都省了。
SSO 的好处是:
- 小字符串零堆分配,零碎片
- 没有引用计数,没有原子操作
- 语义清晰,operator[] 返回的引用稳定可靠
大字符串怎么办?那就老老实实分配堆内存,但不再共享。拷贝就是深拷贝,简单粗暴。
说实话,我更喜欢 SSO 的设计。它把复杂问题简单化了。COW 看起来很美,但实际工程中坑太多。你想想看,为了省一点内存拷贝,引入引用计数、原子操作、写时复制逻辑,最后可能得不偿失。
自己实现一个简单的 COW 字符串
虽然标准库不用 COW 了,但理解它的实现思路还是很有价值的。下面是一个极简的 COW 字符串:
#include <atomic>
#include <cstring>
#include <algorithm>
class CowString {
struct Block {
std::atomic<int> refcount{1};
size_t len;
size_t cap;
char data[1]; // 柔性数组
};
Block* block_;
Block* create_block(size_t cap) {
Block* b = (Block*)malloc(sizeof(Block) + cap);
b->refcount.store(1);
b->len = 0;
b->cap = cap;
b->data[0] = '\0';
return b;
}
void detach() {
if (block_->refcount.load() == 1) return;
Block* new_block = create_block(block_->cap);
memcpy(new_block->data, block_->data, block_->len + 1);
new_block->len = block_->len;
release();
block_ = new_block;
}
void release() {
if (block_->refcount.fetch_sub(1) == 1) {
free(block_);
}
}
public:
CowString(const char* s = "") : block_(create_block(strlen(s))) {
memcpy(block_->data, s, strlen(s) + 1);
block_->len = strlen(s);
}
CowString(const CowString& other) : block_(other.block_) {
block_->refcount.fetch_add(1);
}
CowString& operator=(const CowString& other) {
if (this != &other) {
release();
block_ = other.block_;
block_->refcount.fetch_add(1);
}
return *this;
}
~CowString() { release(); }
const char* c_str() const { return block_->data; }
char& operator[](size_t idx) {
detach(); // 写时复制
return block_->data[idx];
}
const char& operator[](size_t idx) const {
return block_->data[idx];
}
size_t size() const { return block_->len; }
};
这个实现里,拷贝构造函数只增加引用计数,不复制数据。只有当你通过 operator[] 修改字符时,才会触发 detach(),真正复制一份。
关键点:const 版本的 operator[] 不会触发复制,因为只读。非 const 版本必须 detach,因为你无法保证调用者不会通过返回的引用修改内容。
COW 的适用场景总结
说了这么多,COW 到底该不该用?我的看法是:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 单线程,读多写少,字符串大 | COW 或 shared_ptr<string> | 节省内存,减少拷贝 |
| 多线程,频繁拷贝 | SSO string(现代 std::string) | 避免原子操作开销 |
| 小字符串为主 | SSO string | 零堆分配,性能极佳 |
| 需要稳定指针/引用 | 非 COW 方案 | COW 的 detach 会使已有指针失效 |
我个人习惯是:能用现代 std::string 就用它,SSO 已经覆盖了 90% 的场景。如果真的需要共享大字符串,我会用 std::shared_ptr<const std::string>,手动控制共享,语义更清晰。
COW 的流程图
下面这张图展示了 COW 字符串的核心流程:
从流程图里你能看到,COW 的核心就是那个判断——refcount 是否大于 1。只有大于 1 才需要复制,否则直接写。这个判断虽然简单,但在多线程环境下,refcount 的原子操作就成了性能的关键。
一句话总结:COW 是一种用引用计数换取内存拷贝的优化策略。它适合单线程、读多写少的场景。但在多线程和现代 C++ 中,SSO 是更稳妥的选择。
好了,关于 COW 和 std::string 的实现,我们就聊到这里。如果你在实际项目中遇到过 COW 的坑,或者对 SSO 的实现细节感兴趣,欢迎一起讨论。
公众号:蓝海资料掘金营,微信 deep3321