当前位置: 首页 > 科技观察

std--string的Copy-on-Write:没有想象中的好

时间:2023-03-12 03:30:36 科技观察

Copy-on-write(以下简称COW)是一种非常重要的优化手段。其核心思想是懒惰地处理多个实体的资源请求,在多个实体之间共享某些资源,直到一个实体需要修改资源时才将私有资源分配给该实体。COW技术的一个经典应用在于进程fork时Linux内核对进程地址空间的处理。由于fork产生的子进程需要一个完全独立的地址空间,与父进程内容相同,所以一种方式是完全复制父进程的地址空间,另一种方式是在地址空间中标记page父进程是“共享”的(引用计数+1),这样子进程和父进程共享地址空间,但是当一方需要修改内存中的一页时,重新分配一个新的页(复制原始内容),并使修改后的进程的虚拟地址重定向到一个新的页面。COW技术有哪些优势?1.一方面减少了分配(和复制)大量资源造成的瞬时延迟(注意只是latency,但实际上延迟是分配给后续操作的,其累计耗时很可能to比统一处理的延迟要高,有可能造成吞吐量下降)2.另一方面,减少不必要的资源分配。(比如fork的例子中,并不是所有的page都需要复制,比如父进程的代码段(.code)和只读数据(.rodata)段。由于不允许修改,所以有根本不需要复制。而且如果fork后面跟着exec的话,之前的地址空间会被丢弃,好不容易分配和复制就白费了。)COW的思想在resource中被广泛使用管理,甚至STL中std::string的实现也不得不涉及。陈硕的博客《C++工程实践(10):再探std::string》充分讨论了std::string在各种STL实现中的实现,其中g++std::string和Apachestdcxx使用了COW技术。(std::string的其他实现包括eagercopy和smallstringoptimization,推荐参考原博客,图文并茂很清楚)很简单的一段代码就可以查看当前std::字符串实现使用COW:std::stringa="Amedium-sizedstringtoavoidSSO";std::stringb=a;//a.data()==b.data()?b.append('A');//a.data()==b.data()?如果实现使用COW,那么第一次比较将返回true,第二次比较将返回false。经测试,libstdc++(gcc4.5)确实使用了COW,查看STL中string的源码,确实使用了引用计数。但需要注意的是,std::string的惰性复制行为只发生在两个字符串对象之间的复制构造、赋值和assign()操作中。如果一个字符串是由(const)char*构造出来的,那么必然会Allocatememory和copy,因为string对象不知道也无权控制char*指向的内存的生命周期。std::stringa="Hello";std::stringb="Hello";//NeverCOW!assert(b.data()!=a.data());std::stringc=a.data();//NeverCOW!assert(c.data()!=a.data());事实上,std::stringc=a.data()确实是一种在字符串赋值中禁止COW行为的方法。看来用COW来管理字符串,减少不必要的拷贝似乎很有效。然而,在大多数C++STL实现中,只有少数使用了COW,同样著名的VisualC++(2010)和clanglibc++都放弃了COW。我选择了SSO(smallstringoptimization,足够小的字符串直接放在对象本身的栈内存中,避免了动态向Heap申请内存的开销)。SSO对小字符串的高效是原因之一(程序中通常会有大量的短字符串),COW本身的缺陷也是原因之一。1.性能:为了线程安全!如果要实现COW,就必须要有引用计数。当字符串初始化时,rc=1,每当字符串被赋值给其他sring时,rc++。当字符串需要修改时,如果rc>1,重新申请空间,复制原字符串,rc--。当rc减为0时,原来的内存就被释放了。基于“共享”和“引用”计数的COW在多线程环境下必然面临线程安全问题。那么:std::string线程安全吗?在stackoverflow上对这个问题的一个很好的回答:是和否。从多线程环境下对共享字符串对象的并发操作来看,std::string不是线程安全的,也不能像其他STL容器一样是线程安全的。c++11之前的标准并没有对STL容器和字符串的线程安全特性做任何要求,甚至根本没有线程相关的内容。即使是引入多线程编程模型的C++11,也不能要求STL容器的线程安全:线程安全就意味着同步,而同步就意味着性能损失。贸然确保线程安全必然违背C++的哲学:不要为你不用的东西买单。但是从在不同线程中操作“独立”的字符串对象的角度来看,std::string必须是线程安全的。乍一看,这似乎不是一个要求,但是COW的实现使得两个逻辑上独立的字符串对象在物理上共享同一块内存,所以必须做到逻辑隔离。C++0x草案(N2960)中有这样一段话:C++0x草案(N2960)包含“避免数据竞争”部分,它基本上说库组件可以访问对用户隐藏的共享数据,如果并且只有当它主动避免可能的数据竞争时。简单来说:你可以在不告诉用户的情况下使用共享内存(比如使用COW实现string),但是你必须负责处理可能的竞争条件。COW实现中避免竞争条件的关键是:1.仅原子地增加或减少引用计数。谈谈原子操作:不同的架构通常有不同的底层原语来支持原子操作。例如IntelCPU本身就引入了#LOCK命令前缀,允许在指定操作(如算术指令、逻辑指令、位指令、交换指令等)之前使用,如lockinc,会锁住总线在执行inc指令时(锁定一个包含目标地址的内存区域,以防止在此期间被其他CPU并发访问),从而串行化对同一地址的访问。与mutex等同步方式相比,原子操作自然要轻很多,但与普通的算术指令相比,还是完全重量级的:1.系统通常会锁定一个大于目标地址的区域,影响逻辑上无关的地址访问。2.lock指令具有“同步”语义,会防止CPU本身的乱序执行优化。IntelDeveloper'sManualvol3:Multiple-ProcessorManagement第8章提到:“Lockedinstructionscanbeusedtosynchronizedatabyaprocessorwrittenandreadbyanotherprocessor”。即会等待之前发出的load和store指令(由于CPUstorebuffer的存在,如果之前的数据没有依赖关系,则不需要等待load和store的结果)3.二CPU对同一个地址进行原子操作,必然会导致cache-bounce。由于SMP系统中Cache一致性协议的存在,一个CPU对共享内存的修改必然会使另一个CPU地址的缓存失效,最终导致两个CPU不断“竞争”同一块ofmemory(缓存不断被对方作废,需要重新从内存中读取),这是多线程编程中经典的FalseSharing问题。归根结底,COW是通过原子操作来保证“线程安全”的,而原子操作本身的效率并不高。而且,在多线程环境下,多个CPU对同一个地址的原子操作,开销更大。COW中“共享”的实现会影响多线程环境中字符串“复制”的性能,无法扩展。先说操作顺序:为了避免竞争条件,当需要修改字符串时,如果引用计数>1,必须先分配复制,然后引用计数减1。(而不是先减1再复制)在一定条件下,这样的操作顺序会导致不必要的额外操作:字符串A在线程1中访问,字符串B在线程2中访问,字符串A和字符串B共享同一个切片Content(rc=2)假设当线程1在操作字符串A时,线程2也在操作字符串B,双方发现字符串的内容是共享的,都遵循先赋值复制,再复制的执行顺序减少引用计数。(最终一方会发现rc=0并破坏原来的字符串内容)。到目前为止,COW一共进行了3次内存分配和拷贝(1次初始化,2次修改)和1次内存释放。实际上,如果不使用COW技术,string的初始化也就到此为止了。2次内存分配和拷贝(都是在初始化时)2.“失效”问题:一草一木都死了!假设当前字符串实现是COW,考虑如下代码:std::stringa="somestring";std::stringb=a;assert(b.data()==a.data());//Weassumestd::stringisCOW-edstd::cout<