copy-on-write 在c++ std::string中的应用

一 简介   

      Copy-On-Write简称COW,是一种用于程序设计中的优化策略。其基本思路是,从一开始大家都在共享同一个内容,当某个人想要修改这个内容的时候,才会真正把内容Copy出去形成一个新的内容然后再改,这是一种延时懒惰策略.总的来说,COW通过浅拷贝(shallow copy)只复制引用而避免复制值;当的确需要进行写入操作时,首先进行值拷贝,再对拷贝后的值执行写入操作,这样减少了无谓的复制耗时。

     CopyOnWrite并发容器用于读多写少的并发场景。比如白名单,黑名单,商品类目的访问和更新场景,假如我们有一个搜索网站,用户在这个网站的搜索框中,输入关键字搜索内容,但是某些关键字不允许被搜索。这些不能被搜索的关键字会被放在一个黑名单当中,黑名单每天晚上更新一次。当用户搜索时,会检查当前关键字在不在黑名单当中,如果在,则提示不能搜索。

     CopyOnWrite有很多优点,但是同时也存在两个问题,即内存占用问题和数据一致性问题。所以在开发的时候需要注意一下。
     内存占用问题。因为CopyOnWrite的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制对象的引用,只是在写的时候会创建新对象(之前对象的copy),而旧对象还在使用,所以有两份对象内存,这对于c++这种不需要gc的语言来说,无非是占用额外的内存,但是对于java等需要gc的语言来说,不光是占用内存这么简单,有可能会引起full gc,造成stw时间过长)。

    数据一致性问题。CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用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 的实现相当的复杂,更多的细节就不说了,最后说到CopyOnWrite容器,c++里并没有真正意义上CopyOnWrite容器,std::string对cow的应用和CopyOnWrite容器的应用完全不同,CopyOnWrite容器的应用更为复杂,通过上面的源码分析,std::string仅限于在string发生copy的时候只做浅copy,同时记录share这个字符串的单位个数,share这个字符串的莫格单位尝试修改字符串的时候如果发现share这个字符串的数量多于1个就copy一份然后修改,保证其他的不受影响,如果修改的时候share的只有自己一个就直接修改不会copy了,这种延时策略可以减少没有必要的深拷贝。

    但是CopyOnWrite容器的应用场景在多读少些的情况下是构建一个无锁的多线程安全的容器,就是假设容器很少会写,大部分都是读的情景下,读的时候完全不加锁,在写的时候先把原容器copy出来然后再写,关键是如何修改后的容器让读线程看到,原理就是volatile。volatile关键字的使用,核心就是让一个变量被写线程给修改之后,立马让其他线程可以读到这个变量引用的最近的值。说的简单一点就是,写线程对copy出来的容器修改完之后把原来的容器的volatile 类型的引用指向修改后的对象。注意这里仅限于java,java中的volatile语义和c++中的有很大的区别,jvm对volatile做了很多的额外内容,它本身是有内存屏障的语义的,c++里没有。如果在c++中实现这种容器不光要volatile类型的指针还需要手动的添加对用的内存哦屏障才能保证数据的正确性。

      CopyOnWrite的使用场景和应用

       java 中的Copy-On-Write容器

你可能感兴趣的:(c++学习)