C++ 代码之性能空间极限拉扯:「COW」 真乃神助攻(上)

以下内容为本人的学习笔记,如需要转载,请声明原文链接 微信公众号「ENG八戒」mp.weixin.qq.com/s/Rk2-tjIIb...

什么是「COW」?它和代码性能、空间利用有什么关系?

设想,有个数据缓存,我们创建它用于存放数据。有数据更新时,我们就写入或者修改其中的数据,但是大部分时间我们都是在读其中的数据,即使拷贝数据也仅是为了单纯的读取。

如果每次从缓冲中读取数据,都要将数据完整拷贝一份过来,并存放在独享的另存空间里。据为己有,仅仅为了方便于后边可能会出现的修改数据的场景,这样岂不是既浪费存储空间,又浪费算力做无用功,占用 CPU 资源进而降低程序的运行效率。

可见,一般思路的读数据方法是有很大优化空间的。其实,在修改数据之前,被读取的内容大可不必真的另存,读数据只是为了了解状态,那么继续和数据的源有者共享不好吗?

这时就出现了一种需求,拷贝数据不需要真的复制数据,而只需要复制数据地址,达到数据共享,直到有一方需要修改其中的数据,在修改之前触发真正的复制,包括重新分配空间然后填充拷贝内容,这就是所谓的写时复制 (Copy-On-write),简称 COW。

巧的是,浅拷贝(shallow copy)和深拷贝(deep copy)说的也是类似意思。深拷贝和浅拷贝的区别就是,是否拷贝堆内存空间的内容,意味着浅拷贝是高效率的行为,而且对空间友好(省空间)。

假设对象内部包含指针成员,该指针成员指向堆中已分配的内存空间。拷贝对象时,除了一般成员的存储值需要被复制之外,如果指针成员存储的地址也仅被直接复制,那么这种拷贝被认为是浅拷贝。相对地,如果指针成员所指向的内存空间也需要被复制一份,就是深拷贝。

根据「写时复制」的不同状态,普通只读拷贝对应浅拷贝,需要修改内容的拷贝对应深拷贝。

下面一起动手撸一遍 COW 特性的实现过程。

简单实现

首先,COW 操作的是对象,所以定义一个类表示被操作的对象,这个类具备一般类的基本特征,包括默认构造函数、一般构造函数、拷贝构造函数、拷贝赋值操作符、析构函数等。

csharp 复制代码
class SmartRef
{
public:
    SmartRef();
    SmartRef(int i, int j);
    SmartRef(const SmartRef& ref);
    SmartRef& operator=(const SmartRef& ref);
    ~SmartRef();
    // ...
};

类取名为 SmartRef 的原因后边会提到,且看下去。

一般访问接口,无非就是读或者写数据,COW 特性作用于写相关的接口。为了实现 COW 特性,可借助浅拷贝和深拷贝的做法,写相关接口需要包含深拷贝的逻辑,其它需要拷贝数据的接口都属于浅拷贝。

鉴于浅拷贝和深拷贝的区别,SmartRef 类需要定义一个指向被拷贝数据集的指针成员,该数据集实际位于堆内存,访问时应该具有私有属性。同时为了方便管理这块数据集,需要将被拷贝的数据集单独封装并抽象为 SmartRef 的成员类 Data。

arduino 复制代码
class SmartRef
{
    // ...
private:
    class Data
    {
    public:
        Data();
        Data(int i, int j);
        Data(const Data& d);
    };
    Data* data_;
};

一般情况下,拷贝 SmartRef 实例对象都是用浅拷贝,那么如何判断什么时候应该触发深拷贝?

可以通过引用计数的方法,引用计数器用于记录有多少个 SmartRef 实例对象指向同一个实际的数据集。当有多个 SmartRef 实例对象指向同一个实际的数据集时,如果需要修改数据集,那么深拷贝就自动触发。

笔者在上一篇文章《仿照现代 C++ 智能指针实现自己的引用计数》中提到使用「指针语义」来实现类似智能指针的引用计数,如有兴趣可点击阅读。

而本文所介绍的就不是指针语义了,因为被拷贝的数据集应该被看待为内部私有属性,SmartRef 实例对象操作数据集时无需向外表现为指针行为,所以可以使用「引用语义」来实现引用计数(这就是本文将类取名为 SmartRef 的原因,当然这不是必须的,仅为了表达语义)。

怎么理解「指针语义」?智能指针实例是对象,但是会通过重写 -> 成员访问运算符来实现类似指针式的成员访问,被访问的成员是托管对象的成员,并非智能指针对象自身的成员,所以智能指针看起来就像个托管对象的指针。

kotlin 复制代码
class SmartRef
{
    // ...
private:
    class Data
    {
        // ...
    private:
        unsigned count_;
        friend class SmartRef;
    };
    // ...
};

需要声明 SmartRef 为友元类,否则 SmartRef 实例对象无法访问 Data 的私有成员 count_,这里的私有成员 count_ 就是上面说的引用计数器。

当引用计数器等于 1 时,表明只有一个 SmartRef 实例对象指向数据集。引用计数器大于 1 时,表明有多个 SmartRef 实例对象同时指向同一个数据集。

因为只要存在 SmartRef 实例,就表明至少有一个指针指向实际的数据集。所以,在首次创建 SmartRef 实例时,同时也需要分配一个数据集空间,SmartRef::Data 的任何构造函数都应该将引用计数器初始化为 1。

css 复制代码
// ...
SmartRef::Data::Data() : count_(1) { }
SmartRef::Data::Data(int i, int j) : count_(1) { }
SmartRef::Data::Data(const Data& d) : count_(1) { }

SmartRef::SmartRef()
        : data_(new Data()) { }
SmartRef::SmartRef(int i, int j)
        : data_(new Data(i, j)) { }

针对 SmartRef 实例,如果只是浅拷贝,比如通过拷贝构造函数或者拷贝赋值操作符对 SmartRef 实例执行拷贝,那么只需复制新传入的数据集地址,以及对引用计数器加 1 即可,数据集无需重新分配空间和备份数据。由此可见,浅拷贝极为高效和节省资源。

这里边调用拷贝赋值操作符时,原有的数据集将要被解除绑定(新传入的数据集地址替换原有的数据集地址),SmartRef 实例原来指向的数据集对应的引用计数器也要自减 1,如果减 1 后归零则还需要释放原来数据集的内存空间。

无论调用拷贝构造函数或者拷贝赋值操作符对 SmartRef 实例执行拷贝,新传入的数据集的引用计数都应该自加 1,表明指向(绑定)新数据集的 SmartRef 实例增多一个。

ini 复制代码
// ...
SmartRef::SmartRef(const SmartRef& ref)
        : data_(ref.data_)
{
    ++ data_->count_;
}

SmartRef& SmartRef::operator=(const SmartRef& ref)
{
    Data* const old = data_;
    data_ = ref.data_;
    ++ data_->count_;
    if (0 == (-- old->count_)) delete old;
    return *this;
}

当 SmartRef 实例对象被释放时,计数器也需要自减 1,减 1 后如果归零则同样还需要释放对应数据集的内存空间。

arduino 复制代码
// ...
SmartRef::~SmartRef()
{
    if (0 == (-- data_->count_)) delete data_;
}

演示需要,特为 SmartRef 设计两个访问数据集的接口,一个只读接口 readOnlyMethod(),一个写接口 readWriteMethod()。只读接口无非就是调用 SmartRef 的拷贝构造函数或者拷贝赋值操作符分享数据集,效率优先,所以非必要不允许深拷贝。

而写接口的内部应该在修改数据集内容之前,确认是否正在多个 SmartRef 实例对象间共享数据集,也就是判断 count_ 是否大于 1。如果是,则执行深拷贝,体现 COW 的特性,避免影响其它 SmartRef 实例对象的状态。否则,直接修改数据集内容即可。

深拷贝要求重新分配数据集空间,并且拷贝原有数据集内容,原有数据集的引用计数应该自减 1,由于触发深拷贝时原有数据集的引用计数必然大于 1,所以可以不用再检查减 1 后的引用计数值。

arduino 复制代码
class SmartRef
{
    // ...
public:
    void readOnlyMethod() const;
    void readWriteMethod();
    // ...
};

// ...
void SmartRef::readOnlyMethod() const
{
    // ...
}

void SmartRef::readWriteMethod()
{
    if (data_->count_ > 1) {
        Data* d = new Data(*data_);
        -- data_->count_;
        data_ = d;
    }
    assert(1 == data_->count_);
    // ...
}

未完待续...

相关推荐
Trouvaille ~13 分钟前
【C++篇】C++类与对象深度解析(六):全面剖析拷贝省略、RVO、NRVO优化策略
c++·c++20·编译原理·编译器·类和对象·rvo·nrvo
little redcap14 分钟前
第十九次CCF计算机软件能力认证-乔乔和牛牛逛超市
数据结构·c++·算法
机器视觉知识推荐、就业指导43 分钟前
Qt/C++事件过滤器与控件响应重写的使用、场景的不同
开发语言·数据库·c++·qt
孤寂大仙v1 小时前
【C++】STL----list常见用法
开发语言·c++·list
咩咩大主教2 小时前
C++基于select和epoll的TCP服务器
linux·服务器·c语言·开发语言·c++·tcp/ip·io多路复用
Ylucius3 小时前
动态语言? 静态语言? ------区别何在?java,js,c,c++,python分给是静态or动态语言?
java·c语言·javascript·c++·python·学习
是店小二呀4 小时前
【C++】C++ STL探索:Priority Queue与仿函数的深入解析
开发语言·c++·后端
ephemerals__4 小时前
【c++】动态内存管理
开发语言·c++
CVer儿4 小时前
条件编译代码记录
开发语言·c++
程序猿练习生4 小时前
C++速通LeetCode简单第18题-杨辉三角(全网唯一递归法)
c++·算法·leetcode