写时拷贝技术:Copy-On-Write(转载)

原文出处:http://blog.csdn.net/yangshuangtao/article/details/51496615

  1. 写时拷贝技术  
  2. 1、概念  
  3. Scott Meyers在《More Effective  
  4. C++》中举了个例子,不知你是否还记得?在你还在上学的时候,你的父母要你不要看电视,而去复习功课,于是你把自己关在房间里,做出一副正在复习功课的  
  5. 样子,其实你在干着别的诸如给班上的某位女生写情书之类的事,而一旦你的父母出来在你房间要检查你是否在复习时,你才真正捡起课本看书。这就是“拖延战  
  6. 术”,直到你非要做的时候才去做。  
  7.   
  8. 当然,这种事情在现实生活中时往往会出事,但其在编程世界中摇身一变,就成为了最有用的技术,正如C++中的可以随处声明变量的特点一样,Scott  
  9. Meyers推荐我们,在真正需要一个存储空间时才去声明变量(分配内存),这样会得到程序在运行时最小的内存花销。执行到那才会去做分配内存这种比较耗  
  10. 时的工作,这会给我们的程序在运行时有比较好的性能。必竟,20%的程序运行了80%的时间。  
  11.   
  12. 当然,拖延战术还并不只是这样一种类型,这种技术被我们广泛地应用着,特别是在操作系统当中,当一个程序运行结束时,操作系统并不会急着把其清除出内存,  
  13. 原因是有可能程序还会马上再运行一次(从磁盘把程序装入到内存是个很慢的过程),而只有当内存不够用了,才会把这些还驻留内存的程序清出。  
  14.   
  15. 写时才拷贝(Copy-On-Write)技术,就是编程界“懒惰行为”——拖延战术的产物。举个例子,比如我们有个程序要写文件,不断地根据网络传来的  
  16. 数据写,如果每一次fwrite或是fprintf都要进行一个磁盘的I/O操作的话,都简直就是性能上巨大的损失,因此通常的做法是,每次写文件操作都  
  17. 写在特定大小的一块内存中(磁盘缓存),只有当我们关闭文件时,才写到磁盘上(这就是为什么如果文件不关闭,所写的东西会丢失的原因)。更有甚者是文件关  
  18. 闭时都不写磁盘,而一直等到关机或是内存不够时才写磁盘,Unix就是这样一个系统,如果非正常退出,那么数据就会丢失,文件就会损坏。  
  19.   
  20. 呵呵,为了性能我们需要冒这样大的风险,还好我们的程序是不会忙得忘了还有一块数据需要写到磁盘上的,所以这种做法,还是很有必要的。  
  21.   
  22. 2、标准C++类std::string的Copy-On-Write  
  23. 在我们经常使用的STL标准模板库中的string类,也是一个具有写时才拷贝技术的类。C++曾在性能问题上被广泛地质疑和指责过,为了提高性  
  24. 能,STL中的许多类都采用了Copy-On-Write技术。这种偷懒的行为的确使使用STL的程序有着比较高要性能。  
  25.   
  26. 这里,我想从C++类或是设计模式的角度为各位揭开Copy-On-Write技术在string中实现的面纱,以供各位在用C++进行类库设计时做一点  
  27. 参考。  
  28. 在讲述这项技术之前,我想简单地说明一下string类内存分配的概念。通过常,string类中必有一个私有成员,其是一个char*,用户记录从堆上  
  29. 分配内存的地址,其在构造时分配内存,在析构时释放内存。因为是从堆上分配内存,所以string类在维护这块内存上是格外小心的,string类在返回  
  30. 这块内存地址时,只返回const char*,也就是只读的,如果你要写,你只能通过string提供的方法进行数据的改写。  
  31.    
  32. 2.1、 特性  
  33.   
  34. 由表及里,由感性到理性,我们先来看一看string类的Copy-On-Write的表面特征。让我们写下下面的一段程序:  
  35.   
  36. #include <string>  
  37.   
  38. #include  
  39. <stdio.h>  
  40. using namespace std;  
  41.   
  42. int main()  
  43.   
  44. {  
  45.   
  46.    string str1 = "hello world";  
  47.   
  48.    string str2 = str1;  
  49.   
  50.    printf ("Sharing the memory:/n");  
  51.   
  52.    printf ("/tstr1's address: %x/n", str1.c_str() );  
  53.   
  54.    printf ("/tstr2's address: %x/n", str2.c_str() );  
  55.   
  56.    str1[1]='q';  
  57.   
  58.    str2[1]='w';  
  59.   
  60.    printf ("After Copy-On-Write:/n");  
  61.   
  62.    printf ("/tstr1's address: %x/n", str1.c_str() );  
  63.   
  64.    printf ("/tstr2's address: %x/n", str2.c_str() );  
  65.   
  66.    return 0;  
  67. }  
  68.   
  69. 这个程序的意图就是让第二个string通过第一个string构造,然后打印出其存放数据的内存地址,然后分别修改str1和str2的内容,再查一下  
  70. 其存放内存的地址。程序的输出是这样的(我在VC6.0和g++ 2.95都得到了同样的结果):  
  71.   
  72. > g++ -o stringTest stringTest.cpp  
  73.   
  74. > ./stringTest  
  75. Sharing the memory:  
  76.   
  77.     str1's address: 343be9  
  78.   
  79.     str2's address: 343be9  
  80.   
  81.     After Copy-On-Write:  
  82.   
  83.     str1's address: 3407a9  
  84.   
  85.     str2's address: 343be9  
  86.   
  87. 从结果中我们可以看到,在开始的两个语句后,str1和str2存放数据的地址是一样的,而在修改内容后,str1的地址发生了变化,而str2的地址还  
  88. 是原来的。从这个例子,我们可以看到string类的Copy-On-Write技术。  
  89.   
  90. 2.2、 深入  
[cpp] view plain copy
 
  1. 在深入这前,通过上述的演示,我们应该知道在string类中,要实现写时才拷贝,需要解决两个问题,一个是内存共享,一个是Copy-On-  
  2. Wirte,这两个主题会让我们产生许多疑问,还是让我们带着这样几个问题来学习吧:  
  3.   
  4.  1、 Copy-On-Write的原理是什么?  
  5.   
  6.  2、 string类在什么情况下才共享内存的?  
  7.   
  8.  3、 string类在什么情况下触发写时才拷贝(Copy-On-Write)?  
  9.   
  10.  4、 Copy-On-Write时,发生了什么?  
  11.   
  12.  5、 Copy-On-Write的具体实现是怎么样的?  
  13.   
  14.   
  15. 喔,你说只要看一看STL中stirng的源码你就可以找到答案了。当然,当然,我也是参考了string的父模板类basic_string的源码。但  
  16. 是,如果你感到看STL的源码就好像看机器码,并严重打击你对C++自信心,乃至产生了自己是否懂C++的疑问,如果你有这样的感觉,那么还是继续往下看  
  17. 我的这篇文章吧。  
  18. OK,让我们一个问题一个问题地探讨吧,慢慢地所有的技术细节都会浮出水面的。  
  19.   
  20. 2.3、Copy-On-Write的原理是什么?  
  21.   
  22. 有一定经验的程序员一定知道,Copy-On-Write一定使用了“引用计数”,是的,必然有一个变量类似于RefCnt。当第一个类构造时,string的构造函数会根据传入的参数从堆上分配内存,当有其它类需要这块内存时,这个计数为自动累加,当有类析构时,这个计数会减一,直到最后一  
  23. 个类析构时,此时的RefCnt为1或是0,此时,程序才会真正的Free这块从堆上分配的内存。  
  24.   
  25. 是的,引用计数就是string类中写时才拷贝的原理!  
  26.   
  27. 不过,问题又来了,这个RefCnt该存在在哪里呢?如果存放在string类中,那么每个string的实例都有各自的一套,根本不能共有一个  
  28. RefCnt,如果是声明成全局变量,或是静态成员,那就是所有的string类共享一个了,这也不行,我们需要的是一个“民主和集中”的一个解决方法。  
  29. 这是如何做到的呢?呵呵,人生就是一个糊涂后去探知,知道后和又糊涂的循环过程。别急别急,在后面我会给你一一道来的。  
  30.   
  31. 2.3.1、string类在什么情况下才共享内存的?  
  32.   
  33. 这个问题的答案应该是明显的,根据常理和逻辑,如果一个类要用另一个类的数据,那就可以共享被使用类的内存了。这是很合理的,如果你不用我的,那就不用共享,只有你使用我的,才发生共享。  
  34.   
  35. 使用别的类的数据时,无非有两种情况,1)以别的类构造自己,2)以别的类赋值。第一种情况时会触发拷贝构造函数,第二种情况会触发赋值操作符。这两种情况我们都可以在类中实现其对应的方法。对于第一种情况,只需要在string类的拷贝构造函数中做点处理,让其引用计数累加;同样,对于第二种情况,只需要重载string类的赋值操作符,同样在其中加上一点处理。  
  36.   
  37. 唠叨几句:  
  38.   
  39. 1)构造和赋值的差别  
  40. 对于前面那个例程中的这两句:  
  41. string str1 = "hello world";  
  42. string str2 = str1;  
  43. 不要以为有“=”就是赋值操作,其实,这两条语句等价于:  
  44. string str1 ("hello world"); //调用的是构造函数  
  45.   
  46. string str2 (str1); //调用的是拷贝构造函数  
  47.   
  48. 如果str2是下面的这样情况:  
  49. 1.string str2; //调用参数默认为空串的构造函数:string str2(“”);  
  50.   
  51. 2.str2 = str1; //调用str2的赋值操作:str2.operator=(str1);  
  52.   
  53. 2) 另一种情况  
  54. char tmp[]=”hello world”;  
  55. string str1 = tmp;  
  56. string str2 = tmp;  
  57.   
  58. 这种情况下会触发内存的共享吗?想当然的,应该要共享。可是根据我们前面所说的共享内存的情况,两个string类的声明和初始语句并不符合我前述的两种  
  59. 情况,所以其并不发生内存共享。而且,C++现有特性也无法让我们做到对这种情况进行类的内存共享。  
  60.   
  61. 2.3.2、string类在什么情况下触发写时才拷贝(Copy-On-Write)?  
  62.   
  63. 哦,什么时候会发现写时才拷贝?很显然,当然是在共享同一块内存的类发生内容改变时,才会发生Copy-On-Write。比如string类的  
  64. []、=、+=、+、操作符赋值,还有一些string类中诸如insert、replace、append等成员函数,包括类的析构时。  
  65.   
  66. 修改数据才会触发Copy-On-Write,不修改当然就不会改啦。这就是托延战术的真谛,非到要做的时候才去做。  
  67.   
  68. 2.3.3、Copy-On-Write时,发生了什么?  
  69.   
  70. 我们可能根据那个访问计数来决定是否需要拷贝,参看下面的代码:  
  71.   
  72. If ( RefCnt>0 )  
  73.   
  74. {  
  75.   
  76.    char* tmp = (char*) malloc(strlen(_Ptr)+1);  
  77.   
  78.    strcpy(tmp, _Ptr);  
  79.   
  80.    _Ptr = tmp;  
  81.   
  82. }  
  83.   
  84. 上面的代码是一个假想的拷贝方法,如果有别的类在引用(检查引用计数来获知)这块内存,那么就需要把更改类进行“拷贝”这个动作。  
  85.   
  86. 我们可以把这个拷的运行封装成一个函数,供那些改变内容的成员函数使用。  
  87.   
  88.   
  89. 2.3.4、Copy-On-Write的具体实现是怎么样的?  
  90.   
  91. 最后的这个问题,我们主要解决的是那个“民主集中”的难题。请先看下面的代码:  
  92.   
  93.   
  94. string h1 = “hello”;  
  95.   
  96. string h2= h1;  
  97.   
  98. string h3;  
  99.   
  100. h3 = h2;  
  101.   
  102. string w1 = “world”;  
  103. string w2(“”);  
  104.   
  105. w2=w1;  
  106.   
  107. 很明显,我们要让h1、h2、h3共享同一块内存,让w1、w2共享同一块内存。因为,在h1、h2、h3中,我们要维护一个引用计数,在w1、w2中我  
  108. 们又要维护一个引用计数。  
  109. 如何使用一个巧妙的方法产生这两个引用计数呢?我们想到了string类的内存是在堆上动态分配的,既然共享内存的各个类指向的是同一个内存区,我们为什么不在这块区上多分配一点空间来存放这个引用计数呢?这样一来,所有共享一块内存区的类都有同样的一个引用计数,而这个变量的地址既然是在共享区上的,那么所有共享这块内存的类都可以访问到,也就知道这块内存的引用者有多少了。  
  110.   
  111. 请看下图:  
  112.   
  113. 于是,有了这样一个机制,每当我们为string分配内存时,我们总是要多分配一个空间用来存放这个引用计数的值,只要发生拷贝构造可是赋值时,这个内存  
  114. 的值就会加一。而在内容修改时,string类为查看这个引用计数是否为0,如果不为零,表示有人在共享这块内存,那么自己需要先做一份拷贝,然后把引用  
  115. 计数减去一,再把数据拷贝过来。下面的几个程序片段说明了这两个动作:  
  116.   
  117. //构造函数(分存内存)  
  118.   
  119. string::string(const char* tmp)  
  120.   
  121. {  
  122.   
  123.    _Len = strlen(tmp);  
  124.   
  125.    _Ptr = new char[_Len+1+1];  
  126.   
  127.    strcpy( _Ptr, tmp );  
  128.   
  129.    _Ptr[_Len+1]=0; // 设置引用计数  
  130. }  
  131.   
  132. //拷贝构造(共享内存)  
  133.   
  134. string::string(const string& str)  
  135.   
  136. {  
  137.   
  138.    if (*this != str)  
  139.   
  140.         {    
  141.   
  142.             this->_Ptr = str.c_str(); //共享内存  
  143.   
  144.             this->_Len = str.szie();  
  145.   
  146.             this->_Ptr[_Len+1] ++; //引用计数加一  
  147.   
  148.          }  
  149. }  
  150.   
  151. //写时才拷贝Copy-On-Write  
  152.   
  153. char& string::operator[](unsigned int idx)  
  154.   
  155. {  
  156.   
  157.     if (idx > _Len || _Ptr == 0 )  
  158.   
  159.          {  
  160.   
  161.              static char nullchar = 0;  
  162.   
  163.              return nullchar;  
  164.   
  165.          }  
  166.   
  167.     _Ptr[_Len+1]--; //引用计数减一  
  168.   
  169.     char* tmp = new char[_Len+1+1];  
  170.   
  171.     strncpy( tmp, _Ptr, _Len+1);  
  172.   
  173.     _Ptr = tmp;  
  174.   
  175.     _Ptr[_Len+1]=0; // 设置新的共享内存的引用计数  
  176.   
  177.     return _Ptr[idx];  
  178.   
  179.  }  
  180.   
  181. //析构函数的一些处理  
  182.   
  183.   
  184. ~string()  
  185. {  
  186.   
  187.    _Ptr[_Len+1]--; //引用计数减一  
  188.   
  189.   // 引用计数为0时,释放内存  
  190.   
  191.    if (_Ptr[_Len+1]==0)  
  192.   
  193.        {  
  194.   
  195.           delete[] _Ptr;  
  196.   
  197.         }  
  198.   
  199.  }  
  200.   
  201. 哈哈,整个技术细节完全浮出水面。  
  202.   
  203. 不过,这和STL中basic_string的实现细节还有一点点差别,在你打开STL的源码时,你会发现其取引用计数是通过这样的访问:_Ptr[-1],标准库中,把这个引用计数的内存分配在了前面(我给出来的代码是把引用计数分配以了后面,这很不好),分配在前的好处是当  
  204. string的长度扩展时,只需要在后面扩展其内存,而不需要移动引用计数的内存存放位置,这又节省了一点时间。  
  205.   
  206. STL中的string的内存结构就像我前面画的那个图一样,_Ptr指着是数据区,而RefCnt则在_Ptr-1 或是 _Ptr[-1]处。  
  207.   
  208. 2.4、臭虫  
  209.   
  210.   Bug  
  211.   
  212. 是谁说的“有太阳的地方就会有黑暗”?或许我们中的许多人都很迷信标准的东西,认为其是久经考验,不可能出错的。呵呵,千万不要有这种迷信,因为任何设计再好,编码再好的代码在某一特定的情况下都会有Bug,STL同样如此,string类的这个共享内存/写时才拷贝技术也不例外,而且这个Bug或许还会  
  213. 让你的整个程序crash掉!不信?!那么让我们来看一个测试案例  
  214. 假设有一个动态链接库(叫myNet.dll或myNet.so)中有这样一个函数返回的是string类:  
  215.   
  216. string GetIPAddress(string hostname)  
  217.   
  218. {  
  219.   
  220.    static string ip;  
  221.   
  222.    ……  
  223.   
  224.    ……  
  225.   
  226.    return ip;  
  227.   
  228. }  
  229.   
  230. 而你的主程序中动态地载入这个动态链接库,并调用其中的这个函数:  
  231.   
  232. main()  
  233. {  
  234.   
  235.  //载入动态链接库中的函数  
  236.   
  237.    hDll = LoadLibraray(…..);  
  238.   
  239.    pFun = GetModule(hDll, “GetIPAddress”);  
  240.   
  241. //调用动态链接库中的函数  
  242.    string ip = (*pFun)(“host1”);  
  243.   
  244.    ……  
  245.   
  246.    ……  
  247.   
  248.  //释放动态链接库  
  249.   
  250.    FreeLibrary(hDll);  
  251.   
  252.    ……  
  253.   
  254.    cout << ip << endl;  
  255.   
  256. }  
  257.   
  258. 让我们来看看这段代码,程序以动态方式载入动态链接库中的函数,然后以函数指针的方式调用动态链接库中的函数,并把返回值放在一个string类中,然后  
  259. 释放了这个动态链接库。释放后,输入ip的内容。  
  260. 根据函数的定义,我们知道函数是“值返回”的,所以,函数返回时,一定会调用拷贝构造函数,又根据string类的内存共享机制,在主程序中变量ip是和  
  261. 函数内部的那个静态string变量共享内存(这块内存区是在动态链接库的地址空间的)。而我们假设在整个主程序中都没有对ip的值进行修改过。那么在当  
  262. 主程序释放了动态链接库后,那个共享的内存区也随之释放。所以,以后对ip的访问,必然做造成内存地址访问非法,造成程序crash。即使你在以后没有使  
  263. 用到ip这个变量,那么在主程序退出时也会发生内存访问异常,因为程序退出时,ip会析构,在析构时就会发生内存访问异常。  
  264.   
  265. 内存访问异常,意味着两件事:1)无论你的程序再漂亮,都会因为这个错误变得暗淡无光,你的声誉也会因为这个错误受到损失。2)未来的一段时间,你会被这  
  266. 个系统级错误所煎熬(在C++世界中,找到并排除这种内存错误并不是一件容易的事情)。这是C/C++程序员永远的心头之痛,千里之堤,溃于蚁穴。而如果  
  267. 你不清楚string类的这种特征,在成千上万行代码中找这样一个内存异常,简直就是一场噩梦。  
  268.   
  269. 备注:要改正上述的Bug,有很多种方法,这里提供一种仅供参考:  
  270. string ip = (*pFun)(“host1”).cstr();  
posted @ 2017-09-22 14:17  青青子衿J  阅读(284)  评论(0)    收藏  举报