Go语言圣经 - 第9章 基于共享变量的并发 - 9.7 示例:并发的非阻塞缓存

第9章 基于共享变量的并发

前一章我们使用了goroutine和channel这样直接而自然的方式来实现并发的方法,有时候它们会存在一些问题

本章我们会更详细的介绍并发机制,尤其在goroutine之间共享变量,我们还会介绍goroutine和操作系统线程之间一些技术差别

9.7 示例:并发的非阻塞缓存

本节我们会构建一个无阻塞的缓存,我们在编写并发程序经常会遇到需要构建一个缓存函数,但是现成的没有办法帮我们解决。也就是说,我们需要把某个函数的计算结果给存起来,使用这个值的时候直接使用就行了,而不是再重新计算一遍,现在我们要避免对整个缓存进行加锁,从而使得其它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更推崇并发通信)

你可能感兴趣的:(#,Go,缓存,golang,开发语言,并发,channel)