Go实现线程安全的缓存

场景

某些函数调用频繁,但其计算却非常耗时,为了避免每次调用时都重新计算一遍,我们需要保存函数的计算结果,这样在对函数进行调用的时候,只需要计算一次,之后的调用可以直接从缓存中返回计算结果。

使用下面的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操作串行化了。显然,这不是我们所希望的。

猜你喜欢

转载自www.cnblogs.com/kkbill/p/12673911.html