探秘写时拷贝的真相

声明:本文来自腾讯增值产品部官方公众号小时光茶社,为CSDN原创投稿,未经许可,禁止任何形式的转载。
作者:梁少华,QQ动漫后台开发,腾讯高级工程师。从事后台开发4年多,参与过QQ秀、手Q红点系统、手Q游戏公会、QQ动漫等项目,有丰富的后台架构经验,擅长海量服务设计。
责编:钱曙光,关注架构和算法领域,寻求报道或者投稿请发邮件[email protected],另有「CSDN 高级架构师群」,内有诸多知名互联网公司的大牛架构师,欢迎架构师加微信qianshuguangarch申请入群,备注姓名+公司+职位。

什么是写时拷贝

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

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

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

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

写时拷贝例子

图片描述

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

图片描述

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

图片描述

图片描述

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

写时拷贝原理

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

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

图片描述

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

STL源码分析

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

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

图片描述

  1. _M_grab完成引用计数器更新,返回string数据内存地址

图片描述

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

图片描述

图片描述

  1. 实际执行_M_refcopy

图片描述

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

图片描述

图片描述

写时拷贝是一把双刃剑

写时拷贝能减少不必要的内存操作,提高程序性能,但同时也是一把双刃剑,如果没按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后,之前保存的结果(str1、str2)都会“被覆盖”了。那么该如何应对这种已经有问题的底层函数呢?可以强制触发写时拷贝,下面继续分析。

强制触发写时拷贝

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

1. 调用reserve函数

图片描述

图片描述

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

2. 调用resize函数

图片描述

图片描述

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

图片描述

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

3. 调用[]操作符

图片描述

图片描述

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

4. 调用char*参数版本assign

图片描述

图片描述

图片描述

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

图片描述

图片描述

图片描述

相关参考资料

  • 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/

编辑推荐:架构技术实践系列文章(部分):

  • 梁少华:探秘写时拷贝的真相
  • 章耿:服务化框架技术选型实践
  • 赵琨:视频直播早期创业团队的技术架构与选型
  • 卢誉声:分布式实时处理系统架构设计与机器学习实践
  • 陈斌:架构师的必备素质和成长途径
  • 林伟:高可用的大数据计算平台如何持续发布和演进
  • 柳宗扬:蘑菇街直播实战技巧带你解决直播开发难题
  • 胡骏:详解自动化运维平台的构建过程
  • 邓木琴:剖析Vue原理&实现双向绑定MVVM
  • 黄日成:从UDP的连接性说起——告知你不为人知的UDP
  • 林昊:阿里超大规模Docker化之路
  • 罗金鹏:双11媒体大屏背后的数据技术与产品
  • 袁岳峰:手机端创新体验——手把手教你搭建VR&AR架构
  • 张铭:双11背后的网络自动化技术
  • 王鹤:Vue.js 2.0源码解析之前端渲染篇
  • 黄日成:从TCP三次握手说起–浅析TCP协议中的疑难杂症
  • 厉心刚:JavaScript引擎分析
  • 蓝邦珏:来看看机智的前端童鞋怎么防盗
  • 陈志兴:让页面滑动流畅得飞起的新特性:Passive Event Listeners
  • 唐聪:大规模排行榜系统实践及挑战
  • 左明:半小时深刻理解React
  • 王照辉:魅族自动化测试架构之路
  • 翁宁龙:美团数据库运维自动化系统构建之路
  • 何轼:美团外卖订单中心的演进
  • 申政:唯品会多线程Redis设计与实现
  • 阿刘:千万级用户的Android客户端是如何养成的
  • 卜赫:大道至简——React Native在直播应用中的实践
  • 陈爱珍:从运维的角度看微服务和容器
  • 孙其瑞:VR应用在直播领域上的实践与探索
  • 刘丁:bilibili高并发实时弹幕系统的实战之路
  • 秦鹏:从应用到平台,云服务架构的演进过程
  • 郭炜:从0到N建立高性价比的大数据平台
  • 李智慧:宅米网技术变迁——初创互联网公司的技术发展之路
  • 陶文质:分布式系统设计的求生之路
  • 魏晓军:React Native实践之携程Moles框架
  • 学霸君姜波:耳目一新的在线答疑服务背后的核心技术
  • 爱乐奇麦凯臻:在线教育的内容研发和技术的迭代创新
  • 长虹李玮:老牌消费电子企业如何拥抱Docker
  • 徐汉彬:日请求过亿的Web系统PHP7升级实践
  • 窦威:AcFun的视频架构演化实践
  • 傅鸿城:QQ亿级日活跃业务后台核心技术揭秘
  • 宁峰峰:尖峰日96万订单,59校园狂欢节技术架构剖析
  • 梁阳鹤:每秒处理10万订单乐视集团支付架构
  • 沈辉煌:亿级日PV的魅族云同步的核心协议与架构实践
  • 李任:携程Docker最佳实践
  • 王海军:游戏研发与运营环境Docker化
  • 史海峰:当当网高可用架构之道
  • 黄哲铿:应对电商大促峰值的九个方法
  • 1号店交易系统架构如何向「高并发高可用」演进
  • 京东闫国旗:从C10K到C10M高性能网络的探索与实践
  • 李林锋:服务化架构的演进与实践
  • 1号店架构师王富平:一号店用户画像系统实践
  • 唯品会官华:实现电商平台从业务到架构的治理体系
  • 沈剑:58同城数据库架构最佳实践
  • 荔枝FM架构师刘耀华:异地多活IDC机房架构
  • UPYUN的云CDN技术架构演进之路
  • 初页CTO丁乐:分布式以后还能敏捷吗?
  • 陈科:河狸家运维系统监控系统的实现方案
  • 途牛谭俊青:多数据中心状态同步&两地三中心的理论
  • 云运维的启示与架构设计
  • 魅族多机房部署方案
  • 艺龙十万级服务器监控系统开发的架构和心得
  • 京东商品详情页应对“双11”大流量的技术实践
  • 架构师于小波:魅族实时消息推送架构

你可能感兴趣的:(探秘写时拷贝的真相)