copy-on-write技术
前因
之所以突然去了解这块内容,是因为是之前做了关于gateway的一些笔记,想趁着笔记还未落灰,把它里头的代码抠出来看看,加深下了解,毕竟貌似有用的到的地方。
看到Gateway中有介绍说使用写时复制(COW)技术,可以减少锁的操作。这项技术于我并不陌生,在写linux驱动的时候,研究
(貌似说的有点过,学习)内核时就经常听到这个词汇,毕竟不管是文件系统,还是进程操作都会看见它的身影。
Copy-On-Write
引用维基百科的定义:
1 | 写入时复制(英语:Copy-on-write,简称COW)是一种计算机程序设计领域的优化策略。 |
也就是说,在一份共享资源,被多个调用者共同消费时,若出现修改资源的操作,我们并不直接对资源进行修改,而是对将资源修改操作划分为三个步骤:
- 第一:先将资源进行复制,复制出一个新的资源备份;
- 第二:往这个资源备份里面添加新的数据;
- 第三:将原先资源地址指向资源备份的地址。
这样的好处是,我们在读数据时不需要加锁,因为资源是不变的,相对于其他不需要修改资源的调用者来说。这类似与一种读写分离的操作,将写和读两种操作作用于两个不同的地址上,互不干扰。
Copy-On-Write实现
1 | package main |
修改时锁住的仅仅是资源的一个备份,所以并不会影响到原有资源的正常访问,原有资源依然可以正常访问。
我代码里头的例子。
1 | type UpdatetaskInfoMap struct { |
Copy-On-Write应用场景以及读写锁
Copy-On-Write并发容器用于读多写少的并发场景。比如白名单,黑名单,商品类目的访问、更新场景以及一些数据的配置项(Gateway将此作为更新配置项),假如我们有一个搜索网站,用户在这个网站的搜索框中,输入关键字搜索内容,但是某些关键字不允许被搜索。这些不能被搜索的关键字会被放在一个黑名单当中,黑名单每天晚上更新一次。当用户搜索时,会检查当前关键字在不在黑名单当中,如果在,则提示不能搜索。
读多写少一般还会被介绍使用读写锁,RWMutex
,那和COW
之前有什么不一样呢。我们顺道来看一下。
这里直接上一个Golang
中读写锁的阻塞状态:
- 读锁不能阻塞读锁
- 读锁需要阻塞写锁,直到所有读锁都释放
- 写锁需要阻塞读锁,直到所有写锁都释放
- 写锁需要阻塞写锁
可以看到的是,读锁虽然不会阻塞读锁,但是会将写锁给阻塞了,这并不符合我们在读数据时不加锁的想法。
我们都知道Map并发读写不安全,但Go语言开发小组很长时间都没去修复这个问题,为什么呢,这是因为Go
开发组之前在很多Go其他的组件中使用了Map,若给其加锁,则会导致很多其他组件速度下降,所以开发组权衡后不考虑修改Map,而是后期推出了sync.Map
来弥补这个遗憾。
所以,加锁操作带来的是一些多余的瞬间延时,在不需要的时候尽量不需要加锁。减少了资源的分配。
Copy-On-Write缺点
Copy-On-Write容器有很多优点,但是同时也存在两个问题,即内存占用问题和数据一致性问题。所以在开发的时候需要注意一下。
内存占用问题。因为Copy-On-Write的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制容器里的引用,只是在写的时候会创建新对象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存)。如果这些对象占用的内存比较大,比如说200M左右,那么再写入100M数据进去,内存就会占用300M,那么这个时候很有可能造成频繁的Yong GC和Full GC。之前我们系统中使用了一个服务由于每晚使用Copy-On-Write机制更新大对象,造成了每晚15秒的Full GC,应用响应时间也随之变长。
针对内存占用问题,可以通过压缩容器中的元素的方法来减少大对象的内存消耗,比如,如果元素全是10进制的数字,可以考虑把它压缩成36进制或64进制。
* 注意:若是包含有IO读取操作,则也会造成操作上的IO双倍消费。
数据一致性问题。Copy-On-Write容器只能保证数据的最终一致性
,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用Copy-On-Write容器。这里也就是说会出现一些脏数据,可能凑巧使用的原有资源数据,
Copy-On-Write Linux下的一些应用
linux中fork()就有COW的应用,父子进程间数据共享,而当子进程并不使用父进程数据的话,数据不需要进行进行复制,这时候使用写时复制就会很划算,只有当子进程需要使用到父进程数据时,系统触发异常,进入中断,复制使用的那份数据上来。
文件系统中的文件修改也类似,我们所做的修改并不直接作用于源文件,例如,要修改文件A的内容时,先把A读出来,写到临时文件B里面去。然后在B中做修改,我们不保存时,是不会做写回文件操作的。常见的,我们使用vim打开一个文件,做一些修改,但并不保存,突然做关机,会发现源文件并不会变动,但vim会提示我们是否recover文件,做这个操作的依据便是,其有一份备份数据可以参照。
这里讲解的更为详细,若需要详细了解,可以参考这篇博客,COW奶牛!Copy On Write机制了解一下
2020/08/26 Go中指针赋值是否是原子的?
群里今天有人讨论,指针赋值是否是原子的,如果不是原子的,同时读写,就会出现data race
,发生panic。而且因为使用go race去检测,是会报出data race
警告的。
这让我又想起了bilibili
微服务框架上的一个issue,是B站大佬毛剑关于这个问题和网友的讨论。
golang中多goroutine操作map时直接赋值不用加锁?。
data race,只是警告,不代表就真正会panic。因为编译器还是识别不出真正意图的,另外你发的链接和我这里也完全不一样,他是有一个人写,其他人在读,这块data race检查,如果不出意外,应该是根据function的栈地址不同,根据变量的读写来判断警告的,不是100%精准。
那么问题来了,我的map始终只有读。
我的写操作,始终只是对变量map的指针赋值,
a 如果一开始指向1,我修改成3,这个操作是原子的,即使是脏读,也不会出现影响,因为我对1,也只有读操作,那么赋值的是谁呢?是临时对象3,因为只有一个人在写他,是变量的拥有者。最终是把 a 从1 改成 3,而这部原子,这个技术大量使用在很多很多地方。那么唯一有风险的地方是什么呢,是memory barrier。就是如果存在CPU L1/2 之类的cache,在多核下变量不更新(参考nginx的time更新),可以使用内敛汇编指示该处需要:::memory,需要屏障,另外过期内存。
然后问题又来了,golang 的memory model 不存在这个问题。
另外建议看看Java的ConcencyHashMap的实现,核心也是COW,就是先复制副本再替换。话说内核的fork也是COW 实现的,另外nginx的cycle 对象也是同样的远离,即变量(8字节 64bit)操作是原子的。代码中如果是直接操作 r.Clients就有问题了,我看了weedfs的代码,他就犯错了,再另外,Java的Hashmap为什么他们都犯错了,都是用的同一个对象(那怕只有一个人写),也是不行的。
你会发现ConcencyHashMap 为什么需要volatile来做这个事情,这就是memory barrier的故事,C代码需要依赖内联汇编,让其他的pthread可见更新asm volatile ("" ::: “memory”),我之前担心golang会有类似问题,后来看了内存模型,确认不会出现。
最后的结论是,当前是原子操作,但Go规范中,没有明确指出,所以,此处是一个不确定态,如果改变,所有这样的操作都是有风险的。
另外seaweedfs已经接受了这个PR,加了锁,即使是一个goroutine 在写,其他 goroutine 在读,如果不是原子的,同样有风险。
这里有相关的讨论:谈谈go语言编程的并发安全。
综合来看,虽然它是一个不确定的状态,但是使用COW,就是为了避免使用锁,所以现在在还未发生改变的情况下,还是默认认为其是原子操作。
本文标题:copy-on-write技术
文章作者:小师
发布时间:2019-09-03
最后更新:2022-05-04
原始链接:chunlife.top/2019/09/03/copy-on-write技术/
版权声明:本站所有文章均采用知识共享署名4.0国际许可协议进行许可