前因

之所以突然去了解这块内容,是因为是之前做了关于gateway的一些笔记,想趁着笔记还未落灰,把它里头的代码抠出来看看,加深下了解,毕竟貌似有用的到的地方。

看到Gateway中有介绍说使用写时复制(COW)技术,可以减少锁的操作。这项技术于我并不陌生,在写linux驱动的时候,研究(貌似说的有点过,学习)内核时就经常听到这个词汇,毕竟不管是文件系统,还是进程操作都会看见它的身影。

Copy-On-Write

引用维基百科的定义:

1
2
3
写入时复制(英语:Copy-on-write,简称COW)是一种计算机程序设计领域的优化策略。

其核心思想是,如果有多个调用者(callers)同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的(transparently)。此作法主要的优点是如果调用者没有修改该资源,就不会有副本(private copy)被创建,因此多个调用者只是读取操作时可以共享同一份资源。

也就是说,在一份共享资源,被多个调用者共同消费时,若出现修改资源的操作,我们并不直接对资源进行修改,而是对将资源修改操作划分为三个步骤:

  • 第一:先将资源进行复制,复制出一个新的资源备份;
  • 第二:往这个资源备份里面添加新的数据;
  • 第三:将原先资源地址指向资源备份的地址。

这样的好处是,我们在读数据时不需要加锁,因为资源是不变的,相对于其他不需要修改资源的调用者来说。这类似与一种读写分离的操作,将写和读两种操作作用于两个不同的地址上,互不干扰。

Copy-On-Write实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package main

import (
"fmt"
"strconv"
)

var testMap map[int]*string

func copyBak() map[int]*string {
vals := make(map[int]*string)

for key, value := range testMap {
vals[key] = value
}

return vals
}

func main() {

testMap = make(map[int]*string)
str := "123"
testMap[1] = &str
testMap[2] = &str
testMap[3] = &str
testMap[4] = &str

go func() {
for {
fmt.Println(*testMap[3])
}
}()

go func() {
for i := 0; i < 100000000; i++ {
// 写数据是需要加锁的,并发写的情况下,可能会出现copy多份数据的情况
// 但此处只有一个协程做此操作
// 1、复制出新的Map
newVals := copyBak()
// 2、修改已有的元素,或添加新元素
str = "12343264364634" + strconv.Itoa(i)
newVals[3] = &str
// 3、将原有的Map地址指向新的Map
testMap = newVals
}
}()

select {}
}

修改时锁住的仅仅是资源的一个备份,所以并不会影响到原有资源的正常访问,原有资源依然可以正常访问。

我代码里头的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
type UpdatetaskInfoMap struct {
TaskCreateTime int64 `json:"task_Create_time,omitempty"`
DeviceIdMap map[string]struct{} `json:"device_id_map"`
}

// 如果是结构体,json marshal更为方便进行复制操作
func (ms *UpdatetaskInfoMap) clone() *UpdatetaskInfoMap {
taskInfos := &UpdatetaskInfoMap{}
bytes, _ := json.Marshal(ms)
json.Unmarshal(bytes, taskInfos)
return taskInfos
}

func (ms *UpdatetaskInfoMap) copyDeviceIDArr(exclude string) map[string]struct{} {
values := make(map[string]struct{})
for key, value := range ms.DeviceIdMap {
if key != exclude {
values[key] = value
}
}
return values
}

// 添加
func (ms *UpdatetaskInfoMap) AddDeviceId(deviceIdArr []string) {

deviceIDMap := ms.copyDeviceIDArr("")

for _, deviceId := range deviceIdArr {
deviceIDMap[deviceId] = struct{}{}
}

ms.DeviceIdMap = deviceIDMap

return
}

// 删除操作
func (ms *UpdatetaskInfoMap) RemoveDeviceId(deviceId string) error {

if _, ok := ms.DeviceIdMap[deviceId]; ok {
return g.ErrTaskNotFound
}

deviceIDMap := ms.copyDeviceIDArr(deviceId)
ms.DeviceIdMap = deviceIDMap

return nil
}

Copy-On-Write应用场景以及读写锁

Copy-On-Write并发容器用于读多写少的并发场景。比如白名单,黑名单,商品类目的访问、更新场景以及一些数据的配置项(Gateway将此作为更新配置项),假如我们有一个搜索网站,用户在这个网站的搜索框中,输入关键字搜索内容,但是某些关键字不允许被搜索。这些不能被搜索的关键字会被放在一个黑名单当中,黑名单每天晚上更新一次。当用户搜索时,会检查当前关键字在不在黑名单当中,如果在,则提示不能搜索。

读多写少一般还会被介绍使用读写锁,RWMutex,那和COW之前有什么不一样呢。我们顺道来看一下。

这里直接上一个Golang中读写锁的阻塞状态:

  1. 读锁不能阻塞读锁
  2. 读锁需要阻塞写锁,直到所有读锁都释放
  3. 写锁需要阻塞读锁,直到所有写锁都释放
  4. 写锁需要阻塞写锁

可以看到的是,读锁虽然不会阻塞读锁,但是会将写锁给阻塞了,这并不符合我们在读数据时不加锁的想法。

我们都知道Map并发读写不安全,但Go语言开发小组很长时间都没去修复这个问题,为什么呢,这是因为Go开发组之前在很多Go其他的组件中使用了Map,若给其加锁,则会导致很多其他组件速度下降,所以开发组权衡后不考虑修改Map,而是后期推出了sync.Map来弥补这个遗憾。

所以,加锁操作带来的是一些多余的瞬间延时,在不需要的时候尽量不需要加锁。减少了资源的分配。

Copy-On-Write缺点

引用:JAVA中的COPYONWRITE容器

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,就是为了避免使用锁,所以现在在还未发生改变的情况下,还是默认认为其是原子操作。