剑客
关注科技互联网

写时拷贝的真相

作者简介

梁少华 ,QQ动漫后台开发,腾讯高级工程师。从事后台开发4年多,参与过QQ秀、手Q红点系统、手Q游戏公会、QQ动漫等项目,有丰富的后台架构经验,擅长海量服务设计

1. 什么是写时拷贝

写时拷贝( copy-on-write COW 就是等到修改数据时才真正分配内存空间,这是对程序性能的优化,可以延迟甚至是避免内存拷贝,当然目的就是避免不必要的内存拷贝。

写时拷贝其实我们并不陌生的, Linux forkstl string 是比较典型的写时拷贝应用,本文只讨论 stl string 的写时拷贝。

string类的实现必然有个 char* 成员变量,用以存放 string 的内容,写时拷贝针对的对象就是这个 char* 成员变量。通过赋值或拷贝构造类操作,不管派生多少份 string” 副本“,每个”副本“的 char* 成员都是指向相同的地址,也就是共享同一块内存,直到某个”副本“执行 string 写操作时,才会触发写时拷贝,拷贝一份新的内存空间出来,然后在新空间上执行写操作。显然,那些只读的 副本“节省了内存分配的时间和空间。

听起来有点懵,对于没了解过写时拷贝的同学,会感觉完全颠覆平常对 string 的认知,下面我们来看一下实际例子。

2. 写时拷贝例子

写时拷贝的真相

如上代码所示,调用拷贝构造函数生成 str2 ,调用赋值操作符生成 str3 ,那么 str2str3 是否有分配内存空间来存储内容“ abc “呢?

运行结果告诉我们, str1str2str3 是共享内存空间的( char* 成员指向相同的地址)。那么问题来了,对 str1str2str3 内容的修改是否会互相影响呢?答案是, 只要遵守 stl 的约定来修改,是会触发写时拷贝的,不会互相影响 ( 毕竟平时一直这样用也没有问题 ^-^)

写时拷贝的真相

写时拷贝的真相

可以看到,对 str1 重新复制,修改 str3 的值,都会触发写时拷贝,分配了新的空间。由于 str1str3 都分配了新的空间, str2 就可以继续使用原来的空间了。

3. 写时拷贝原理

看了上面的例子,相信大家都已明白写时拷贝的表象了。但我们不能满足于现象,还要知道实现原理。应该很多同学都能猜到, string 肯定是使用计数器来记录引用数,当有新的 string 对象共享内存块时,计数器 +1 ,当有对象触发写时拷贝或析构时,计数器 -1

那么计数器存放在哪里呢?这是对象级别的计数器,由若干个对象共享, string 类成员变量、静态变量或全局变量都不能满足要求。最合适的就是在堆里分配空间专门存储这个计数器,由第一个创建的对象分配并初始化计数器,其他对象按照约定引用计数器。我们知道 string 的内存空间就在堆上,那么直接在这块区上多分配一个空间来存储计数器是最方便的,所有共享这块内存的 string 对象都能访问计数器。事实上 stl 就是这么实现的,在 string 内存空间的最前面分配了空间存储计数器,如下图所示:

写时拷贝的真相

图片摘自引文

string的所有赋值、拷贝构造操作,计数器都会 +1 ;修改 string 数据时,先判断计数器是否为 00 代表没有其他对象共享内存空间),为 0 则可以直接使用内存空间(如例子中的 str2 ),否则触发写时拷贝,计数器 -1 ,拷贝一份数据出来修改,并且新的内存计数器置 0string 对象析构时,如果计数器为 0 则释放内存空间,否则计数器也要 -1

4. stl源码分析

我们稍微走读下 stl 源码,看看写时拷贝的实现,以赋值操作符为例 ( 拷贝构造函数类似 )

(1) 赋值操作符事实上是调用 assign 函数

写时拷贝的真相

(2) _M_grab完成引用计数器更新,返回 string 数据内存地址

写时拷贝的真相

(3) _M_rep返回 Rep 指针, Rep 保存在 string 数据内存前面,所以使用 -1 下标索引。计数器 _M_refcount 就在 Rep 中。

写时拷贝的真相

(4) 实际执行 _M_refcopy

写时拷贝的真相

(5) 引用计数器 +1 ,返回数据内存地址 ( 因为 rep 在数据前面,所以指针 +1)

写时拷贝的真相

写时拷贝的真相

5. 写时拷贝是一把双刃剑

写时拷贝能减少不必要的内存操作,提高程序性能,但同时也是一把双刃剑,如果没按 stl 约定使用 string ,可能会导致极其严重的 bug ,而且通常是很隐蔽的,因为一般不会把注意力放到一个赋值语句。

那么 stl 的约定用法是怎样呢?可以概括为两点:一,使用 string 提供的写操作,包括操作符与成员函数修改内容,都能正常触发写时拷贝,不会有 坑“;二, c_str()data() 返回 const char* 指针,只用来读取数据,不要强制转成 char* 指针直接修改内存。写时拷贝惹的祸都是因第二点使用不当导致的,”有经验“的程序员喜欢直接操作内存,硬是把 const 指针改成非 const ,殊不知这样修改内存, string 对象是不感知的,没有办法触发写时拷贝,后果就是所有共享同一内存的 string 对象内容都被篡改了。

写时拷贝的真相

所以, 应该从来都不把 c_str()data() 返回的指针转换成非 const ,从源头上杜绝写时拷贝惹的祸。

但是有时却不得不应付已弄脏的源头,比如底层库实现有问题,传 string 对象进去,里面却通过指针修改 string 内容,导致写时拷贝机制失效。举个列子:

写时拷贝的真相

假设有上面一个 Decode 函数(为了方便描述, str 默认空间够大),通过指针操作把 data 的数据拷贝到 str 。如果只调用一次,通常不会有什么问题,但是如果多次调用 Decode ,并且把 str 结果保存下来,那就出大 bug ,看下面代码:

写时拷贝的真相

写时拷贝的真相

可以看到,每次调用 Decode 后,之前保存的结果 (str1str2) 都会“被覆盖“了。那么该如何应对这种已经有问题的底层函数呢?可以强制触发写时拷贝,下面继续分析。

6. 强制触发写时拷贝

下面这些方法都可以强制触发写时拷贝:

(1) 调用 reserve 函数

写时拷贝的真相

写时拷贝的真相

注意: reserve 一定是在赋值后调用,不然提前触发写时拷贝是没用的

(2) 调用 resize 函数

写时拷贝的真相

写时拷贝的真相

注意: resize 大小一定要跟原来不一样,不然 string 会认为无需重新分配空间,请看下面 resize 源码。

写时拷贝的真相

另外, resize 也要在赋值后调用。

(3) 调用 [] 操作符

写时拷贝的真相

写时拷贝的真相

string[]操作符返回 char& ,允许调用者修改数据,所以会触发写时拷贝。

(4) 调用 char* 参数版本 assign

写时拷贝的真相

写时拷贝的真相

还要重点提醒, string 参数版本的 assign 等价于赋值,不会触发写时拷贝的。

写时拷贝的真相

写时拷贝的真相

7. 相关参考资料

http://blog.csdn.net/haoel/article/details/24058

http://blog.csdn.net/haoel/article/details/24065

http://blog.csdn.net/haoel/article/details/24077

http://blogs.360.cn/360cloud/2012/11/26/linux-gcc-stl-string-in-depth/

分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址