场景
某些函数调用频繁,但其计算却非常耗时,为了避免每次调用时都重新计算一遍,我们需要保存函数的计算结果,这样在对函数进行调用的时候,只需要计算一次,之后的调用可以直接从缓存中返回计算结果。
使用下面的httpGetBody()
作为我们需要缓存的函数样例。
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) // ReadAll会返回两个结果,一个[]byte数组和一个错误
}
设计
缓存的设计要求是并发安全,并且要尽量高效。
memo 1
版本1
// Func 是待缓存的函数(即key)
type Func func(key string) (interface{}, error)
// Result 作为缓存结果(即value)
type result struct {
value interface{}
err error
}
// 缓存通过调用 f 函数得到的结果
type Memo struct {
f Func
cache map[string]result
}
func NewMemo(f Func) *Memo {
memo := &Memo{f, make(map[string]result)}
return memo
}
// Get方法,线程不安全
func (memo *Memo) Get(url string) (interface{}, error) {
res, ok := memo.cache[url]
if !ok { // 如果缓存中不存在,通过调用memo中的f函数计算出结果,并把结果缓存起来
res.value, res.err = memo.f(url)
memo.cache[url] = res
}
return res.value, res.err
}
Memo实例会记录需要缓存的函数f(类型为Func),以及缓存内容(里面是一个string到result映射的map)。
这是一个最简单的实现,由于没有加锁,是线程不安全的。我们先对其进行简单的测试。
var urls = []string {
"https://www.nowcoder.com/",
"https://www.nowcoder.com/contestRoom",
"https://www.nowcoder.com/interview/ai/index",
"https://www.nowcoder.com/courses",
"https://www.nowcoder.com/recommend",
"https://www.nowcoder.com/courses", // 重复的url,测试缓存效果
"https://www.nowcoder.com/contestRoom", // 重复的url,测试缓存效果
}
// 单个goroutine,顺序调用
func TestMemoSingle(t *testing.T) {
m := NewMemo(httpGetBody)
totalTime := time.Now()
for _, url := range urls {
start := time.Now()
value, err := m.Get(url)
if err != nil {
log.Println(err)
}
fmt.Printf("%s, %s, %d bytes\n", url, time.Since(start), len(value.([]byte)))
}
fmt.Printf("total time used: %s\n", time.Since(totalTime))
}
// 并发调用
// 使用 sync.WaitGroup 来等待所有的请求都完成再返回
func TestMemoConcurrency(t *testing.T) {
m := NewMemo(httpGetBody)
var group sync.WaitGroup
totalTime := time.Now()
for _, url := range urls {
group.Add(1)
go func(url string) {
start := time.Now()
value, err := m.Get(url)
if err != nil {
log.Println(err)
}
fmt.Printf("%s, %s, %d bytes\n", url, time.Since(start), len(value.([]byte)))
group.Done() // equals ==> group.Add(-1)
}(url)
}
group.Wait()
fmt.Printf("total time used: %s\n", time.Since(totalTime))
}
首先测试单个goroutine顺序执行的情况,测试结果如下:
$ go test -v -run=TestMemoSingle
=== RUN TestMemoSingle
https://www.nowcoder.com/, 289.8287ms, 95378 bytes
https://www.nowcoder.com/contestRoom, 178.8973ms, 71541 bytes
https://www.nowcoder.com/interview/ai/index, 68.9602ms, 21320 bytes
https://www.nowcoder.com/courses, 148.9146ms, 64304 bytes
https://www.nowcoder.com/recommend, 121.932ms, 90666 bytes
https://www.nowcoder.com/courses, 0s, 64304 bytes // 可以看到,本次调用直接从缓存中获取结果,耗时为0
https://www.nowcoder.com/contestRoom, 0s, 71541 bytes // 同上
total time used: 809.5305ms
--- PASS: TestMemoSingle (0.81s)
PASS
ok _/D_/workspace/GoRepo/gopl/ch9/memo1 1.546s
可以清楚的看到,当访问之前已经被访问过的 url 时,可以立刻从缓存中返回结果。我们再来试试看并发访问的情况。
$ go test -v -run=TestMemoConcurrency
=== RUN TestMemoConcurrency
https://www.nowcoder.com/interview/ai/index, 252.8542ms, 21320 bytes
https://www.nowcoder.com/, 253.8524ms, 95378 bytes
https://www.nowcoder.com/recommend, 279.8401ms, 90666 bytes
https://www.nowcoder.com/courses, 280.8377ms, 64304 bytes
https://www.nowcoder.com/courses, 318.8194ms, 64304 bytes
https://www.nowcoder.com/contestRoom, 359.7913ms, 71541 bytes
https://www.nowcoder.com/contestRoom, 404.7649ms, 71541 bytes
total time used: 404.7649ms
--- PASS: TestMemoConcurrency (0.40s)
PASS
ok _/D_/workspace/GoRepo/gopl/ch9/memo1 3.034s
并发访问时(请多测试几次),可以看到,总的用时比单个gouroutine顺序访问时少了差不多一半。但访问相同 url 时似乎没有达到缓存的效果。原因很简单嘛,我们在实现Get()
方法时,没有加锁限制,因此多个goroutine可能同时访问memo实例,也就是出现了数据竞争。
在 Go 中,我们可以利用-race
标签,它能帮助我们识别自己写的代码中是否出现了数据竞争。比如:
$ go test -v -race -run=TestMemoConcurrency
=== RUN TestMemoConcurrency
...
==================
WARNING: DATA RACE
Write at 0x00c000078cc0 by goroutine 10: // 在 goroutine 10 中写入
runtime.mapassign_faststr()
D:/soft/Go/src/runtime/map_faststr.go:202 +0x0
_/D_/workspace/GoRepo/gopl/ch9/memo1.(*Memo).Get()
D:/workspace/GoRepo/gopl/ch9/memo1/memo.go:40 +0x1d5
_/D_/workspace/GoRepo/gopl/ch9/memo1.TestMemoConcurrency.func1()
D:/workspace/GoRepo/gopl/ch9/memo1/memo_test.go:46 +0x96
Previous write at 0x00c000078cc0 by goroutine 7: // 在 goroutine 7 中也出现写入
runtime.mapassign_faststr()
D:/soft/Go/src/runtime/map_faststr.go:202 +0x0
_/D_/workspace/GoRepo/gopl/ch9/memo1.(*Memo).Get()
D:/workspace/GoRepo/gopl/ch9/memo1/memo.go:40 +0x1d5
_/D_/workspace/GoRepo/gopl/ch9/memo1.TestMemoConcurrency.func1()
D:/workspace/GoRepo/gopl/ch9/memo1/memo_test.go:46 +0x96
...
FAIL
exit status 1
FAIL _/D_/workspace/GoRepo/gopl/ch9/memo1 0.699s
可以看到,memo.go 的第40行(对应memo.cache[url] = res
)出现了2次,说明有两个goroutine在没有同步干预的情况下更新了cache map。这表明Get不是并发安全的,存在数据竞争。
OK,那我们就设法对Get()
方法进行加锁(mutex),最粗暴的方式莫过于如下:
// Get is concurrency-safe.
func (memo *Memo) Get(key string) (value interface{}, err 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
}
这样做当然实现了所谓的“并发安全”,但是也失去了“并发性”,每次对f的调用期间都会持有锁,Get将本来可以并行运行的I/O操作串行化了。显然,这不是我们所希望的。