写时复制: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++ 共享缓冲区 读操作:直接返回 写操作:检查 refcount refcount > 1? 是:分配新内存,复制数据 否:直接修改(唯一拥有者) 写操作完成

从流程图里你能看到,COW 的核心就是那个判断——refcount 是否大于 1。只有大于 1 才需要复制,否则直接写。这个判断虽然简单,但在多线程环境下,refcount 的原子操作就成了性能的关键。

一句话总结:COW 是一种用引用计数换取内存拷贝的优化策略。它适合单线程、读多写少的场景。但在多线程和现代 C++ 中,SSO 是更稳妥的选择。

好了,关于 COW 和 std::string 的实现,我们就聊到这里。如果你在实际项目中遇到过 COW 的坑,或者对 SSO 的实现细节感兴趣,欢迎一起讨论。


公众号:蓝海资料掘金营,微信 deep3321