一 简介
Copy-On-Write简称COW,是一种用于程序设计中的优化策略。其基本思路是,从一开始大家都在共享同一个内容,当某个人想要修改这个内容的时候,才会真正把内容Copy出去形成一个新的内容然后再改,这是一种延时懒惰策略.总的来说,COW通过浅拷贝(shallow copy)只复制引用而避免复制值;当的确需要进行写入操作时,首先进行值拷贝,再对拷贝后的值执行写入操作,这样减少了无谓的复制耗时。
CopyOnWrite并发容器用于读多写少的并发场景。比如白名单,黑名单,商品类目的访问和更新场景,假如我们有一个搜索网站,用户在这个网站的搜索框中,输入关键字搜索内容,但是某些关键字不允许被搜索。这些不能被搜索的关键字会被放在一个黑名单当中,黑名单每天晚上更新一次。当用户搜索时,会检查当前关键字在不在黑名单当中,如果在,则提示不能搜索。
CopyOnWrite有很多优点,但是同时也存在两个问题,即内存占用问题和数据一致性问题。所以在开发的时候需要注意一下。
内存占用问题。因为CopyOnWrite的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制对象的引用,只是在写的时候会创建新对象(之前对象的copy),而旧对象还在使用,所以有两份对象内存,这对于c++这种不需要gc的语言来说,无非是占用额外的内存,但是对于java等需要gc的语言来说,不光是占用内存这么简单,有可能会引起full gc,造成stw时间过长)。
数据一致性问题。CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用CopyOnWrite容器。
二 标准C++类std::string的Copy-On-Write
在我们经常使用的STL标准模板库中的string类,也是一个具有写时才拷贝技术的类。C++曾在性能问题上被广泛地质疑和指责过,为了提高性能,STL中的许多类都采用了Copy-On-Write技术。这种偷懒的行为的确使使用STL的程序有着比较高要性能。
我们来看一段别人的代码。
#include
#include
#include
#include
using namespace std;
int main()
{
string str1 = "hello world";
string str2 = str1;
printf ("Sharing the memory:\n");
printf ("str1's address: %x\n", str1.c_str());
printf ("str2's address: %x\n", str2.c_str());
str1[1]='q';
str2[1]='w';
printf ("After Copy-On-Write:\n");
printf ("str1's address: %x\n",str1.c_str());
printf ("str2's address: %x\n",str2.c_str());
printf("Hello world!");
return 0;
}
运行结果
g++版本6.2 运行环境
我们可以看到,在修改两个string之前他们的地址是一样的,修改后地址就变了,这就是在write的时候发生了copy。
透过现象看本质,这种机制实际上就是对象之间的内存共享,那么如何管理这块共享的内存呢,最简单有效的方法就是引用计数了。
我们先看一下c++源码中的一个结构:
struct _Rep_base
{
size_type _M_length;
size_type _M_capacity;
_Atomic_word _M_refcount;
};
我们可以看到一个名为_M_refcount的变量,他就记录了当前引用该string所持有的字符数组实际内存的数量。根据我们的需求只要每当我们为string分配内存时,我们总是要多分配一个空间用来存放这个引用计数的值,只要发生拷贝构造可是赋值时,这个内存的值就会加一。而在内容修改时,string类为查看这个引用计数是否为0,如果不为零,表示有人在共享这块内存,那么自己需要先做一份拷贝,然后把引用 计数减去一,再把数据拷贝过来。首先我们来看操作这个引用值得方法。
bool _M_is_leaked() const _GLIBCXX_NOEXCEPT //所有引用者是否已释放
{
#if defined(__GTHREADS)
return __atomic_load_n(&this->_M_refcount, __ATOMIC_RELAXED) < 0;
#else
return this->_M_refcount < 0;
#endif
}
bool _M_is_shared() const _GLIBCXX_NOEXCEPT //当前是否有对象引用
{
#if defined(__GTHREADS)
return __atomic_load_n(&this->_M_refcount, __ATOMIC_ACQUIRE) > 0;
#else
return this->_M_refcount > 0;
#endif
}
//设置当前内存已释放(不可用)
void_M_set_leaked() _GLIBCXX_NOEXCEPT
{ this->_M_refcount = -1; }
//设置当前的内存可用(可共享当前引用数为0)
void _M_set_sharable() _GLIBCXX_NOEXCEPT
{ this->_M_refcount = 0; }
void _M_dispose(const _Alloc& __a) _GLIBCXX_NOEXCEPT //释放一个引用
{
#if _GLIBCXX_FULLY_DYNAMIC_STRING == 0
if (__builtin_expect(this != &_S_empty_rep(), false))
#endif
{
if (__gnu_cxx::__exchange_and_add_dispatch(&this->_M_refcount,
-1) <= 0) //如果当前的引用数小于等于0 书房
{
_M_destroy(__a);
}
}
} // XXX MT
_CharT* //copy后增加一个引用
_M_refcopy() throw()
{
#if _GLIBCXX_FULLY_DYNAMIC_STRING == 0
if (__builtin_expect(this != &_S_empty_rep(), false))
#endif
__gnu_cxx::__atomic_add_dispatch(&this->_M_refcount, 1);
return _M_refdata();
} // XXX MT
这里我们要注意所有改变引用计数的操作如果有必要都是原子操作(c++11),是线程安全的,copy on write在历史的c++版本里曾因为线程安全的问题被抛弃(所以如果你的c++版本运行第一段程序没有出现和我一样的结果升级c++版本后try again),但是c++11后标准库添加了原子操作问题自然就解决了。
接下来就是最关键的部分了,只要在拷贝,复制时改变引用值就可以了。
template //拷贝构造函数
basic_string<_CharT, _Traits, _Alloc>::
basic_string(const basic_string& __str)
: _M_dataplus(__str._M_rep()->_M_grab(_Alloc(__str.get_allocator()),
__str.get_allocator()),
__str.get_allocator())
{ }
_CharT* _M_grab(const _Alloc& __alloc1, const _Alloc& __alloc2)
{
//如果相等则增加引用,_M_refcopy函数见上,否则调用_M_clone函数copy目标
return (!_M_is_leaked() && __alloc1 == __alloc2) ? _M_refcopy() : _M_clone(__alloc1);
}
~basic_string() _GLIBCXX_NOEXCEPT
{ _M_rep()->_M_dispose(this->get_allocator()); } //析构函数释放引用
家下来看赋值操作符
basic_string&
operator=(const basic_string& __str)
{ return this->assign(__str); }
template
basic_string<_CharT, _Traits, _Alloc>&
basic_string<_CharT, _Traits, _Alloc>::
assign(const basic_string& __str)
{
if (_M_rep() != __str._M_rep()) //如果不是自赋值
{
// XXX MT
const allocator_type __a = this->get_allocator();
_CharT* __tmp = __str._M_rep()->_M_grab(__a, __str.get_allocator()); //拷贝目标
_M_rep()->_M_dispose(__a); //释放原来的引用
_M_data(__tmp);
}
return *this;
}
看到这里所有关于c++string copy on write的操作都清楚了,不过string的内存布局还是有一点意思的,我们知道除了string的char*其他string有关的信息存储在一个Rep对象中(集成了_Rep_base见上)为了一次申请string所有的内存,string把次对象和char*数组放在了同一个char*指针里,当要取此对象是就去该指针的-1索引。
struct _Alloc_hider : _Alloc //string实际的内部对象构造类型
{
_Alloc_hider(_CharT* __dat, const _Alloc& __a) _GLIBCXX_NOEXCEPT
: _Alloc(__a), _M_p(__dat) { }
_CharT* _M_p; // The actual data.
};
mutable _Alloc_hider _M_dataplus;
_Rep* _M_rep() const _GLIBCXX_NOEXCEPT //获取长度,引用数等信息对象
{ return &((reinterpret_cast<_Rep*> (_M_data()))[-1]); }
_CharT* _M_data() const _GLIBCXX_NOEXCEPT
{ return _M_dataplus._M_p; }
说到这里可以看到现在c++string 的实现相当的复杂,更多的细节就不说了,看源码是最直接有效的方法。现在想想面试的时候我们自己实现的string类简直弱爆了。