Copy-on-write(以下简称COW)是一种很重要的优化手段。它的核心思想是懒惰处理多个实体的资源请求,在多个实体之间共享某些资源,直到有实体需要对资源进行修改时,才真正为该实体分配私有的资源。
COW技术的一个经典应用在于Linux内核在进程fork时对进程地址空间的处理。由于fork产生的子进程需要一份和父进程内容相同但完全独立的地址空间,一种做法是将父进程的地址空间完全复制一份,另一种做法是将父进程地址空间中的页面标记为”共享的“(引用计数+1),使子进程与父进程共享地址空间,但当有一方需要对内存中某个页面进行修改时,重新分配一个新的页面(拷贝原内容),并使修改进程的虚拟地址重定向到新的页面上。
COW技术有哪些优点呢?
1. 一方面减少了分配(和复制)大量资源带来的瞬间延迟(注意仅仅是latency,但实际上该延迟被分摊到后续的操作中,其累积耗时很可能比一次统一处理的延迟要高,造成throughput下降是有可能的)
2. 另一方面减少不必要的资源分配。(例如在fork的例子中,并不是所有的页面都需要复制,比如父进程的代码段(.code)和只读数据(.rodata)段,由于不允许修改,根本就无需复制。而如果fork后面紧跟exec的话,之前的地址空间都会废弃,花大力气的分配和复制只是徒劳无功。)
COW的思想在资源管理上被广泛使用,甚至连STL中的std::string的实现也要沾一下边。陈硕的这篇博客《C++工程实践(10):再探std::string》充分探讨了各个STL实现中对std::string的实现方式,其中g++ std::string和Apache stdcxx就使用了COW技术。(其他对std::string的实现包括eager copy和small string optimization,建议参考原博客,图文并茂十分清楚)
很简单一段代码,就能查看当前std::string实现是否使用了COW:
1 std::string a = "A medium-sized string to avoid SSO";
2 std::string b = a;
3 //a.data() == b.data()?
4
5 b.append('A');
6 //a.data() == b.data()?
如果实现使用了COW,那么第一个比较会返回true,第二个比较会返回false。经测试libstdc++(gcc 4.5)确实使用了COW,而查看STL中string的源码,也确实采用了引用计数的手段。
但要注意,std::string的lazy-copy行为只发生在两个string对象之间的拷贝构造,赋值和assign()操作上,如果一个string由(const)char*构造而来,则必然会分配内存和进行复制,因为string对象并不知道也无权控制char*所指内存的生命周期。
1 std::string a = "Hello";
2 std::string b = "Hello";//Never COW!
3 assert(b.data() != a.data());
4
5 std::string c = a.data();//Never COW!
6 assert(c.data() != a.data());
实际上,std::string c = a.data()确实是一种在字符串赋值时禁止COW行为的方法。
看起来使用COW管理string来减少不必要的拷贝似乎很有效,然而在多数C++ STL实现中,只有寥寥两种使用了COW,而同样著名的Visual C++(2010)和clang libc++却不约而同抛弃了COW,选择了SSO(small string optimization,足够小的字符串直接放在对象本身的栈内存中,避免了向Heap动态请求内存的开销)。
SSO对小字符串的高效是原因之一(程序中通常会有大量的短字符串),而COW本身的缺陷更是原因之一。
一、性能:for thread-safety!
想要实现COW,必须要有引用计数(reference count)。string初始化时rc=1,每当该string赋值给了其他sring,rc++。当需要对string做修改时,如果rc>1,则重新申请空间并复制一份原字符串的拷贝,rc--。当rc减为0时,释放原内存。
基于”共享“和”引用“计数的COW在多线程环境下必然面临线程安全的问题。那么:
std::string是线程安全的吗?
在*上对这个问题的一个很好的回答:是又不是。
从在多线程环境下对共享的string对象进行并发操作的角度来看,std::string不是线程安全的,也不可能是线程安全的,像其他STL容器一样。
c++11之前的标准对STL容器和string的线程安全属性不做任何要求,甚至根本没有线程相关的内容。即使是引入了多线程编程模型的C++11,也不可能要求STL容器的线程安全:线程安全意味着同步,同步意味着性能损失,贸然地保证线程安全必然违背了C++的哲学:
Don't pay for things you don't use.
但从不同线程中操作”独立“的string对象来看,std::string必须是线程安全的。咋一看这似乎不是要求,但COW的实现使两个逻辑上独立的string对象在物理上共享同一片内存,因此必须实现逻辑层面的隔离。C++0x草案(N2960)中就有这么一段:
The C++0x draft (N2960) contains the section "data race avoidance" which basically says that library components may access shared data that is hidden from the user if and only if it activly avoids possible data races.
简单说来就是:你瞒着用户使用共享内存是可以的(比如用COW实现string),但你必须负责处理可能的竞态条件。
而COW实现中避免竞态条件的关键在于:
1. 只对引用计数进行原子增减
2. 需要修改时,先分配和复制,后将引用计数-1(当引用计数为0时负责销毁)
先谈谈原子操作:
不同的体系结构一般会有不同的底层原语以支持原子操作。如Intel CPU本身就引入了#LOCK指令前缀,该前缀允许置于指定的操作(如算法指令、逻辑指令、bit指令、exchange指令等)之前使用,如lock inc会在执行inc指令时锁总线(锁定包含目标地址的一片内存区域,防止其他CPU在此期间的并发访问),从而序列化对同一地址的访问。
比起mutex之类的同步手段,原子操作自然要轻上不少,但比起普通的算术指令,依然算得上完全的重量级:
1. 系统通常会lock住比目标地址更大的一片区域,影响逻辑上不相关的地址访问。
2. lock指令具有”同步“语义,会阻止CPU本身的乱序执行优化。
Intel Developer's Manual vol 3的chapter 8 : Multiple-Processor Management中就有提到:
"Locked instructions can be used to synchronize data written by one processor and read by another processor."
也就是会等待之前发出的load和store指令的完成(由于CPU store buffer的存在,如果数据之前没有依赖,不需要等待load和store的结果)
3. 两个CPU对同一个地址进行原子操作,必然会导致cache-bounce。SMP系统中由于Cache一致性协议的存在,一个CPU对共享内存的修改必然会invalidate另一个CPU对该地址的cache,最终导致两个CPU对同一片内存不断”争夺“(cache不断被对方invalidate,需要重新从内存中读取),这是多线程编程中经典的False Sharing问题。
归根结底,COW为了保证”线程安全“而使用了原子操作,而原子操作本身的效率并不十分高。而且在多线程环境下,多个CPU对同一个地址的原子操作开销更大。COW中”共享“的实现,反而影响了多线程环境下string”拷贝“的性能,并不scale。
再谈谈操作顺序:
string A在线程1中访问,string B在线程2中访问,string A 和 string B 共享同一片内容(rc = 2)假设当线程1操作string A时线程2恰好也在操作string B,双方发现该string的内容是共享的,都遵守先分配复制,后减引用计数的执行序列。(最终会有一方发现rc=0,销毁原string内容)。
二、”失效“问题:草木皆兵!
std::string a = "some string";
std::string b = a;
assert(b.data() == a.data());// We assume std::string is COW-ed std::cout << b[0] << std::endl;
assert(b.data() != a.data()); // Oops!
1. offer set(n, c)2. make default iterator non-mutating
"The COW is dead, long live eager-copy"
总结:
参考资料:
http://www.cnblogs.com/promise6522/archive/2012/03/22/2412686.html