前一章我们使用了goroutine和channel这样直接而自然的方式来实现并发的方法,有时候它们会存在一些问题
本章我们会更详细的介绍并发机制,尤其在goroutine之间共享变量,我们还会介绍goroutine和操作系统线程之间一些技术差别
本节我们会构建一个无阻塞的缓存,我们在编写并发程序经常会遇到需要构建一个缓存函数,但是现成的没有办法帮我们解决。也就是说,我们需要把某个函数的计算结果给存起来,使用这个值的时候直接使用就行了,而不是再重新计算一遍,现在我们要避免对整个缓存进行加锁,从而使得其它gorotine为了使用它都去争一个锁
我们来看看下面的程序
func httpGetBody(url string) (interface{},error) {
resp,err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return ioutil.ReadAll(resp.Body)
}
我们想要获得一个访问链接的body,最后的返回调用了ioutil.ReadAll ,这样就能满足httpGetbody的返回值类型了,我们返回的类型
现在我们来涉及一个cache
type Memo struct {
f Func
cache map[string]result
}
type Func func(key string) (interface{},error)
type result struct {
value interface{}
err error
}
func New(f Func) *Memo {
return &Memo{f:f, cache: make(map[string]result)}
}
func (memo Memo) Get(key string) (interface{},error) {
res,ok := memo.cache[key]
if !ok {
res.value,res.err = memo.f(key)
memo.cache[key]= res
}
return res.value,res.err
}
Memo实例会记录需要缓存的函数f,以及缓存内容,每个result都是函数返回的值对儿——一个值和一个错误值,我们再来看一个Memo的变形
m := memo.New(httpGetBody)
for url := range incomingURLs() {
start := time.Now()
value,err := m.Get(url)
if err != nil {
log.Print(rr)
}
fmt.Printf("%s,%s,%d bytes\n",
url,time.Sicne(start),len(value,([]byte)))
}
对于每一个流入URL每一个元素我们都会调用Get,并打印调用延时及其返回的数据的大小的log
我们可以使用测试包来系统的鉴定缓存的效果,从下面的测试输出,我们可以看到URL流包含了一些重复的情况我们第一次对每一个URL的(*Memo).Get的调用会花几百毫秒,但是以后只需要1毫秒就可以返回完整的数据了
$ go test -v gopl.io/ch9/memo1
=== RUN Test
https://golang.org, 175.026418ms, 7537 bytes
https://godoc.org, 172.686825ms, 6878 bytes
https://play.golang.org, 115.762377ms, 5767 bytes
http://gopl.io, 749.887242ms, 2856 bytes
https://golang.org, 721ns, 7537 bytes
https://godoc.org, 152ns, 6878 bytes
https://play.golang.org, 205ns, 5767 bytes
http://gopl.io, 326ns, 2856 bytes
--- PASS: Test (1.21s)
PASS
ok gopl.io/ch9/memo1 1.257s
这个测试是顺序的去做所有的调用的
经过前面的学习,我们知道,这种独立的http请求可以很好的做并发,现在我们便来改下,使用sync.WaitGroup来等待所有的请求都完成之后再返回
m := memo.New(httpGetBody)
var n sync.WaitGroup
for url := range imcomingURLs() {
n.Add(1)
go func(url string) {
start := time.Now()
value,err := m.Get(url)
if err != nil {
log.Print(err)
}
fmt.Printf("%s,%s,%d bytes\n",
url, time.Since(start),len(value.([]byte)))
n.Done()
}(url)
}
n.Wait()
现在测试这个程序,它会跑的更快,但是它并不会每次都能正确运行(偶发bug),有一些意料之外的cache miss,或者命中了缓存但却返回了错误的值,或者甚至会直接崩溃。所以我们用 -race这个flag来运行程序,竞争检擦器会帮我们打印报告
$ go test -run=TestConcurrent -race -v gopl.io/ch9/memo1
=== RUN TestConcurrent
...
WARNING: DATA RACE
Write by goroutine 36:
runtime.mapassign1()
~/go/src/runtime/hashmap.go:411 +0x0
gopl.io/ch9/memo1.(*Memo).Get()
~/gobook2/src/gopl.io/ch9/memo1/memo.go:32 +0x205
...
Previous write by goroutine 35:
runtime.mapassign1()
~/go/src/runtime/hashmap.go:411 +0x0
gopl.io/ch9/memo1.(*Memo).Get()
~/gobook2/src/gopl.io/ch9/memo1/memo.go:32 +0x205
...
Found 1 data race(s)
FAIL gopl.io/ch9/memo1 2.393s
memo.go第32行出现了两次,说明有两个goroutine在没有同步干预的情况下更新了cache map,这表明Get不是并发安全的,存在数据竞争
func (memo Memo) Get(key string) (interface{},error) {
res,ok := memo.cache[key]
if !ok {
res.value,res.err = memo.f(key)
memo.cache[key]= res
}
return res.value,res.err
}
最简单的使cache并发安全的方式是使用基于监控的同步,给memo加上Mutex,在Get的一开始获取互斥锁,在return的时候释放,就可以让cache的操作发生再临界区内了
type Memo struct {
f Func
mu sync.Mutex
cache map[string]result
}
func (memo Memo) Get(key string) (interface{},error) {
memo.mu.Lock()
res,ok := memo.cache[key]
if !ok {
res.value,res.err = memo.f(key)
memo.cache[key]= res
}
memo.mu.Unlock()
return res.value,res.err
}
现在测试依然并发进行,但是竞争检查器“沉默”了,但是加锁操作使得Get本来可以并行运行的I/O操作串行化了,这不是我们想要的目的
下面的Get实现,调用Get的goroutine会两次获取锁:查找阶段获取一次,如果查找没有返回任何内容,那么进入更新阶段会再次获取,在这两次获取锁的中间阶段,其它goroutine可以随意使用cache
func (memo Memo) Get(key string) (interface{},error) {
memo.mu.Lock()
res,ok := memo.cache[key]
memo.mu.Unlock()
if !ok {
res.value,res.err = memo.f(key)
memo.mu.Lock()
memo.cache[key] = res
memo.mu.Unlock()
}
return res.value,res.err
}
上述修改会再次提升代码的性能,但有些URL被获取了两次,这种情况会有两个以上的goroutine同一时刻调用Get来请求同样的URL,多个goroutine一起查询cache,发现没有值,然后一起调用f。在得到结果后,也都会去更新map,其中一个结果会覆盖掉另一个结果
上面其实是有重复的工作的,理想情况下当然是避免种工作,它们被称为duplicate suppression,下面版本的的Memo每一个map元素都是指向一个条目的指针,每一个条目包含对函数f调用结果的内容缓存,与之前不同的是这次entry还包含了一个ready的channel,在条目的结果被设置后,这个channel就会被关闭,以向其它goroutine广播,去读取该条目内的结果是安全的了
type entry struct {
res result
ready chan struct{}
}
func New(f Func) *Memo {
return &Memo{f:f,cache:make(map[string]*entry)}
}
type Memo struct {
f Func
mu sync.Mutex
cache map[string]*entry
}
func (memo *Memo) Get(key string) (value interface{},err error) {
memo.mu.Lock()
e := memo.cache[key]
if e == nil {
e = &entry{ready:make(chan struct{})}
memo.cache[key] = e
memo.mu.Unlock()
e.res.value,e.res.err = memo.f(key)
close(e.ready)
}else {
memo.mu.Unlock()
<- e.ready
}
return e.res.value,e.res.err
}
现在go函数包含如下步骤:
获取互斥锁来保护共享变量 cache map;查询map中是否存在指定条目,若否,分配空间插入新条目,释放互斥锁,若存在但没有写入完成,goroutine必须等到ready之后才能读到条目结果,如果想知道是否ready的话,可以直接从ready channel中读取,由于这个读操作是在channel 关闭之前一直是阻塞的
在上述否的情况下,需要向map中插入一个没有准备好的条目,当前正在调用的goroutine就需要负责调用慢函数、更新条目以及向其它所有goroutine广播条目已经ready可读的消息了
条目中的r.res.value和e.res.err变量是在多个goroutine之间共享的,创建条目的goroutine同时也会设置条目的值,其他goroutine在收到“ready”的广播消息之后会立刻去读取条目的值。尽管会被多个goroutine同时访问,但是并不需要互斥锁。ready channel的关闭一定会发生在其它goroutine接收到广播事件之前,因此第一个goroutine对这些变量的写操作是一定发生在这些读操作之前的。不会发生数据竞争
这样并发、不重复、无阻塞的cache就完成了
上面这样Memo的实现使用了一个互斥量来保护多个goroutine调用get时的共享map变量,不妨把这种设计和前面提到的把map变量限制在一个单独的moniter goroutine的方案做一些对比,后者在调用get时需要发消息
Func 、result和entry的声明和之前保持一致
type Func func(key string) (interface{}, error)
type result struct {
value interface{}
err error
}
type entry struct {
res result
ready chan struct{}
}
然而Memo类型中包含了一个叫requests的channel,Get的调用方用这个channel 来和monitor channel来通信。requests channel中的元素类型是request,Get调用方会把这个结构中的两组key都填充好,实际上用这两个变量对函数进行缓存的。另一个叫response的channel会被拿来发送响应结果,这个channel只会传回一个单独的值
type request struct {
key string
response chan<- result
}
type Memo struct {
requests chan request
}
func New(f Func) *Memo {
memo := &Memo{requests: make(chan request)}
go memo.server(f)
return memo
}
func (memo *Memo) Get(key string) (interface{}, error) {
response := make(chan result)
memo.requests <- request{key, response}
res := <-response
return res.value, res.err
}
func (memo *Memo) Close() { close(memo.requests) }
上面的Get方法,会创建一个response channel,把它放进request结构中,然后发送给monitor goroutine,然后又去马上接受它
cache变量被限制在了monitor,(*Memo).server中,下面会看到。monitor会在循环中一直读取请求,直到request channel 被Close方法关闭,每一个请求都会查询cache,如果没有找到条目的话,那么就会创建或者插入一个新条目
func (memo *Memo) server(f Func) {
cache := make(map[string]*entry)
for req := range memo.requests {
e := cache[req.key]
if e == nil {
e = &entry{ready: make(chan struct{})}
cache[req.key] = e
go e.call(f, req.key)
}
go e.deliver(req.response)
}
}
func (e *entry)deliver(response chan <- result) {
<- e.ready
response <- e.res
}
和基于互斥量的版本类似,第一个对于某个key的请求需要去调用f并且传入key,并将结果存在条目里,并关闭ready channel来广播条目的ready信息。是(*entry).call来完成上述工作
对于同一个key的请求会等待结果变为ready,并将结果从response发送给客户端的goroutine。上述工作是用使用(*entry).deliver来完成。对call 的deliver方法的调用必须让它在自己的goroutine中进行以确保monitor goroutines不会因此被阻塞住而没法处理新的请求
这个例子说明无论是用上锁还是通信来建立并发程序是可行的
上面两种方法都可以,但读者可以根据具体情况再选择(go更推崇并发通信)