商城首页欢迎来到中国正版软件门户

您的位置: 首页 > 文章列表 > 编程开发 > C++之写时复制(CopyOnWrite)原理详解

C++之写时复制(CopyOnWrite)原理详解

  发布于2026-07-04 阅读(0)

扫一扫,手机访问

1.简介

先聊一个非常经典的编程思想:CopyOnWrite(COW)。它的核心思路其实很简单——如果多个对象或变量共享同一份数据,那大家就先共用着,没必要每个对象都单独拷贝一份。只有到真正需要修改数据的时候,才去创建一个副本,在副本上动手脚,原始数据则保持不动。

这种思路在处理那些很少变动、或者天生不可变的数据结构时,效果尤其显著。通过共享来避免不必要的复制,既省内存又提性能。举个典型的例子:Linux 系统中,fork 系统调用创建子进程时,并不会一股脑儿把父进程的所有内存页都复制一份。父子进程先共用同一块内存页,只有当其中一方真的要去修改某个内存页时,才触发真正的复制。如果两方都只是读,那性能优势就体现出来了。

Qt 里的 QString,也正是这一思想的忠实实践者。日常操作中,读操作随意进行,完全不需要加锁。只有到了写操作的关键时刻,它才会从原始数据中复制出一个副本,修改完毕后再将原数据替换掉。更有意思的是,在修改进行的过程中,读操作并不会被阻塞,而是继续读取旧的数据——这一点对于并发场景来说非常友好。

2.实现原理

写时复制的本质,说白了就是“浅拷贝 + 引用计数”。读操作时走浅拷贝,写操作时才触发深拷贝。而引用计数的作用,就是记录当前有多少个指针共享同一块内存空间。

2.1 QString之写时复制

当两个QString发生复制赋值时,并不会真的拷贝字符串内容。它只是增加一次引用计数,然后让字符串指针做一次浅拷贝。这一步的时间复杂度是O(1)。只有后续真正要去修改某个字符串内容时,才会执行那个代价较高的实际复制。

C++之写时复制(CopyOnWrite)原理详解

2.2 引用计数

引用计数本身放在堆区,通常和数据紧挨着放在一起。而且最佳实践是把它放在数据的前面——这样数据内容发生变化时,不会影响到引用计数的位置。

C++之写时复制(CopyOnWrite)原理详解

3.QString的实现分析

3.1 内部结构

QString 的底层数据结构是 QTypedArrayData,这一点从源码中可以直接看到:

template 
struct QTypedArrayData
    : QArrayData
{
    ...
};

typedef QTypedArrayData QStringData;

class Q_CORE_EXPORT QString
{
public:
    typedef QStringData Data;
    ...
    Data*  d;  //真正存储QString数据的对象
};

QTypedArrayData 继承自 QArrayData

struct Q_CORE_EXPORT QArrayData
{
    QtPrivate::RefCount ref;  //引用计数
    int size;
    uint alloc : 31;
    uint capacityReserved : 1;

    qptrdiff offset; // in bytes from beginning of header

    void *data()
    {
        Q_ASSERT(size == 0
                || offset < 0 || size_t(offset) >= sizeof(QArrayData));
        return reinterpret_cast(this) + offset;
    }

    const void *data() const
    {
        Q_ASSERT(size == 0
                || offset < 0 || size_t(offset) >= sizeof(QArrayData));
        return reinterpret_cast(this) + offset;
    }
    ...
};

可以看到,QArrayData 里有一个 QtPrivate::RefCount 类型的成员变量 ref,它记录这块内存被引用的次数。正因为这个设计,QString 才能用 Copy On Write 技术来优化字符串内存的管理。

3.2 写入时复制

QtPrivate::RefCount 的核心作用就是保存并管理引用计数,源码走一波:

class RefCount
{
public:
    inline bool ref() Q_DECL_NOTHROW {  //增加引用计数
        int count = atomic.load();
#if !defined(QT_NO_UNSHARABLE_CONTAINERS)
        if (count == 0) // !isSharable
            return false;
#endif
        if (count != -1) // !isStatic
            atomic.ref();
        return true;
    }

    inline bool deref() Q_DECL_NOTHROW { //减少引用计数
        int count = atomic.load();
#if !defined(QT_NO_UNSHARABLE_CONTAINERS)
        if (count == 0) // !isSharable
            return false;
#endif
        if (count == -1) // isStatic
            return true;
        return atomic.deref();
    }
    ...
    QBasicAtomicInt atomic;  //原子变量
};

再看赋值操作符的源码:

QString &QString::operator=(const QString &other) Q_DECL_NOTHROW
{
    other.d->ref.ref();    //other增加引用计数 [1]
    if (!d->ref.deref())    //自己减少引用计数 [2]
        Data::deallocate(d); //如果自己计数为0则释放内存 [3]
    d = other.d;            //直接指针赋值 [4]
    return *this;
}

通过这四步,赋值操作就完成了。对比一下传统的深拷贝:

class String{
public:
   String(const String &rhs):m_pstr(new char[strlen(rhs) + 1]()){
   }
private:
   char* m_pstr;
};

显然,COW 省去了内存申请和实际拷贝的开销,运行效率的提升是肉眼可见的。

再看 QString 的 append 方法,同样遵循了 COW 的套路:

QString &QString::append(const QString &str)
{
    if (str.d != Data::sharedNull()) {
        if (d == Data::sharedNull()) {
            operator=(str);
        } else {
            if (d->ref.isShared() || uint(d->size + str.d->size) + 1u > d->alloc)
                reallocData(uint(d->size + str.d->size) + 1u, true);
            memcpy(d->data() + d->size, str.d->data(), str.d->size * sizeof(QChar));
            d->size += str.d->size;
            d->data()[d->size] = '';
        }
    }
    return *this;
}

reallocData 函数则是 COW 执行的关键入口——在需要写入数据时,它会重新分配内存,在新内存上增加计数,并减少原内存的引用计数。这才是 COW 思想的精髓所在。

void QString::reallocData(uint alloc, bool grow)
{
    auto allocOptions = d->detachFlags();
    if (grow)
        allocOptions |= QArrayData::Grow;

    if (d->ref.isShared() || IS_RAW_DATA(d)) {
        Data *x = Data::allocate(alloc, allocOptions);
        Q_CHECK_PTR(x);
        x->size = qMin(int(alloc) - 1, d->size);
        ::memcpy(x->data(), d->data(), x->size * sizeof(QChar));
        x->data()[x->size] = 0;
        if (!d->ref.deref())
            Data::deallocate(d);
        d = x;
    } else {
        Data *p = Data::reallocateUnaligned(d, alloc, allocOptions);
        Q_CHECK_PTR(p);
        d = p;
    }
}

4.QImage中的写时拷贝分析

对于像 QImage 这样通常占用内存较大的类型,写时拷贝的意义就更大了。整个流程可以用这个图来理解:

C++之写时复制(CopyOnWrite)原理详解

  • 第一次创建 imgA 时,会生成一个 QImage 外壳和一份真正的 QImageData。外壳就是 QImage 类本身,数据则是 QImageData 类型,通过 d_ptr 指向。
  • imgB 要拷贝 imgA 时,并不会复制 imagedata。它只是创建一个新的外壳 wrapperimgB,然后让 d_ptr 指向 imgAimagedata,并把引用计数 ref 加 1。此时两个外壳共享同一份数据。
  • 而当 imgB 真正要进行写操作时,会先触发 detach 操作:把 imgdata 拷贝一份,让 wrapperimgBd_ptr 指向新的那份数据,并调整引用计数。至此,两个外壳才算真正解耦,之后各改各的。

关键函数一览:

构造函数,第一次构造时通过 QImageData::create() 创建原生的 imgdata。

QImage::QImage(uchar* data, int width, int height, Format format, QImageCleanupFunction cleanupFunction, void *cleanupInfo)
    : QPaintDevice()
{
    d = QImageData::create(data, width, height, 0, format, false, cleanupFunction, cleanupInfo);
}

拷贝构造与赋值构造:先判断被拷贝的 QImage 是否处于绘制状态。如果正在绘制,那就意味着数据可能会变化,直接走深拷贝。否则让新外壳指向同一份 imgdata,共用一个数据。

赋值时还需要处理左值原来的引用计数——将之前指向的 imgdata 引用计数减一。如果减到零,就释放那块内存。

QImage::QImage(const QImage &image)
    : QPaintDevice()
{
    if (image.paintingActive() || isLocked(image.d)) {
        d = 0;
        image.copy().swap(*this);
    } else {
        d = image.d;
        if (d)
            d->ref.ref();
    }
}

QImage &QImage::operator=(const QImage &image)
{
    if (image.paintingActive() || isLocked(image.d)) {
        operator=(image.copy());
    } else {
        if (image.d)
            image.d->ref.ref();
        if (d && !d->ref.deref())
            delete d;
        d = image.d;
    }
    return *this;
}

析构时也是把引用计数减一,计数归零则释放内存。

QImage::~QImage()
{
    if (d && !d->ref.deref())
        delete d;
}

detach 函数则在写操作触发时被调用。核心流程就是拷贝一份 imgdata,让当前需要 detach 的 QImage 的 d_ptr 指向那份新数据。

void QImage::detach()
{
    if (d) {
        if (d->is_cached && d->ref.load() == 1)
            QImagePixmapCleanupHooks::executeImageHooks(cacheKey());

        if (d->ref.load() != 1 || d->ro_data)
            *this = copy();

        if (d)
            ++d->detach_no;
    }
}

setDevicePixelRatio 为例,它属于数据写操作,所以会先过 detach 生成独立数据,然后才去修改:

void QImage::setDevicePixelRatio(qreal scaleFactor)
{
    if (!d)
        return;

    if (scaleFactor == d->devicePixelRatio)
        return;

    detach();
    if (d)
        d->devicePixelRatio = scaleFactor;
}

如果未来需要设计类似的大内存数据类型,完全可以参考 QImage 的写时拷贝机制,自己做一套类似的实现。

5.示例分析

C++ 标准库本身并没有直接提供写入时复制的容器,但只要愿意,自定义实现并不复杂。下面是一个写入时复制的数组示例:

#include   
#include   
#include   
  
template   
class CopyOnWriteArray {  
private:  
    std::shared_ptr> data;  
  
public:  
    CopyOnWriteArray() : data(std::make_shared>()) {}  
  
    size_t size() const {  
        return data->size();  
    }  
  
    const T& operator[](size_t index) const {  
        return (*data)[index];  
    }  
  
    void set(size_t index, const T& value) {  
        if (data.unique()) {  
            // 当前是唯一持有者,无需复制  
        } else {  
            // 否则,创建新副本  
            data = std::make_shared>(*data);  
        }  
        (*data)[index] = value;  
    }  
  
    void push_back(const T& value) {  
        if (data.unique()) {  
            data->push_back(value);  
        } else {  
            data = std::make_shared>(*data);  
            data->push_back(value);  
        }  
    }  
};  
  
int main() {  
    CopyOnWriteArray array;  
    array.push_back(1);  
    array.push_back(2);  
    array.push_back(3);  
    std::cout << "Size: " << array.size() << std::endl;  
    std::cout << "Element at index 1: " << array[1] << std::endl;  
    array.set(1, 100); // 写入时复制发生在这里  
    std::cout << "Element at index 1 after modification: " << array[1] << std::endl;  
    return 0;  
}

这个 CopyOnWriteArray 类用 std::shared_ptr 来管理底层数据的生命周期。多个对象共享同一个 std::vector 时,如果其中一个要修改数据,就会触发写入时复制。背后的逻辑是:修改前检查 shared_ptr 的引用计数,如果大于1,就创建一个新的 std::vector 副本,然后在新副本上修改。这样一来,其他仍然引用原始数据的对象不会受到任何影响。

6.使用场景

写入时复制最适合的场景,当然是高并发读操作、写操作相对较少的场景。尤其是在“读远超于写”的情况下,它的优势非常突出。下面这些场景都很典型:

  • 并发容器:实现线程安全的容器时,如果读操作远多于写操作,可以用写入时复制。读取数据不需要加锁,并发读取效率很高。只有写入时才会复制数据并做修改,确保线程安全。
  • 共享不可变数据:多个线程或进程需要共享一些很少变化的数据。更新时可以用写入时复制创建新副本并修改,其他线程仍然可以安全访问原始数据。
  • 事件处理系统:事件驱动系统中,多个处理函数可能同时处理不同事件。如果事件数据在处理过程中不会被修改,用写入时复制的容器存储数据,每个处理函数都可以安全读取,不用担心数据竞争。
  • 日志记录:日志系统里,日志信息大多是写一次、读很多次。用写入时复制的容器存储日志,多个线程可以同时写入不同条目,复杂同步操作大大减少。

不过也得注意,如果写操作很频繁或者数据量特别大,写入时复制的内存开销和性能反而会下降。所以在决定使用前,一定要评估好读写比例、数据规模以及性能要求。另外,实现时务必把引用计数和内存管理搞对,避免出现内存泄漏之类的麻烦。

7.总结

CopyOnWrite 思想的核心价值在于:

  • 内存效率:多个对象共享相同数据,避免不必要的数据复制。对大型数据结构或者多个对象需要引用相同数据的情况,节省内存的效果非常可观。
  • 性能优化:数据很少修改时,性能提升相当明显。读操作不加锁,多个线程可以同时访问共享数据,并发效率很高。对于那些读多写少的数据(比如配置、黑名单、物流地址等),写入时复制几乎就是无锁的解决方案,可以帮程序实现更高的并发。
  • 减少数据拷贝:读时共享,写时才真正复制。不必要的拷贝开销被降到最低。

当然,Write 时复制也不是银弹:

  • 写操作开销:一旦有写操作,就要复制数据。复制大型结构体消耗的时间和内存都不容小觑,频繁的写操作会严重拖累性能。
  • 不适合频繁修改的场景:正因为每次写都要复制,写操作一多,性能就下来了。对于需要频繁修改数据的场景,还是另寻更合适的数据结构或算法吧。

总的来说,CopyOnWrite 非常适合“大量读取、少量写入”的场景,在内存效率和线程安全并发访问方面表现优异。只不过选择和权衡之间,需要针对具体的应用场景量体裁衣。

本文转载于:https://www.jb51.net/program/366658s4d.htm 如有侵犯,请联系zhengruancom@outlook.com删除。
免责声明:正软商城发布此文仅为传递信息,不代表正软商城认同其观点或证实其描述。

热门关注