以下内容为本人的学习笔记,如需要转载,请声明原文链接 微信公众号「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_);
// ...
}
未完待续...