5.4 缓存的性能和优化思路

前面我们实现的并发安全的进程内缓存性能如何呢?一般地,Go 语言中,对性能的判定通过标准库提供的基准测试来实现。

5.4.1 基准测试

因为需要同时测试 Set 和 Get,因此为我们前面实现的缓存增加一个导出的 Set 方法:

  1. func (t *TourCache) Set(key string, val interface{}) {
  2. if val == nil {
  3. return
  4. }
  5. t.mainCache.set(key, val)
  6. }

对于基准测试,我们在 BigCache 这个库的作者的基准测试基础上,增加 TourCache 的测试,并加上 SetGet 并行测试。可以通过如下命令获取我们的 fork 的测试代码,并执行测试:

  1. $ git clone https://github.com/polaris1119/bigcache-bench
  2. $ cd bigcache-bench
  3. $ go test -bench=. -benchmem -benchtime=4s ./... -timeout 30m

看看测试的结果。

  1. goos: linux
  2. goarch: amd64
  3. pkg: github.com/allegro/bigcache-bench
  4. BenchmarkMapSet-4 7800157 696 ns/op 231 B/op 3 allocs/op
  5. BenchmarkTourCacheSet-4 3901720 1324 ns/op 374 B/op 6 allocs/op
  6. BenchmarkConcurrentMapSet-4 2852230 1539 ns/op 312 B/op 8 allocs/op
  7. BenchmarkFreeCacheSet-4 5271206 1099 ns/op 331 B/op 2 allocs/op
  8. BenchmarkBigCacheSet-4 5646502 870 ns/op 303 B/op 2 allocs/op
  9. BenchmarkMapGet-4 11949051 443 ns/op 23 B/op 1 allocs/op
  10. BenchmarkTourCacheGet-4 8796339 519 ns/op 23 B/op 1 allocs/op
  11. BenchmarkConcurrentMapGet-4 8453431 589 ns/op 24 B/op 2 allocs/op
  12. BenchmarkFreeCacheGet-4 5766910 1020 ns/op 136 B/op 3 allocs/op
  13. BenchmarkBigCacheGet-4 6702626 724 ns/op 152 B/op 3 allocs/op
  14. BenchmarkTourCacheSetParallel-4 3135122 1368 ns/op 338 B/op 7 allocs/op
  15. BenchmarkBigCacheSetParallel-4 14178873 393 ns/op 323 B/op 3 allocs/op
  16. BenchmarkFreeCacheSetParallel-4 13892508 481 ns/op 337 B/op 3 allocs/op
  17. BenchmarkConcurrentMapSetParallel-4 12457863 439 ns/op 200 B/op 6 allocs/op
  18. BenchmarkTourCacheGetParallel-4 5936068 209 ns/op 24 B/op 2 allocs/op
  19. BenchmarkBigCacheGetParallel-4 6383874 240 ns/op 152 B/op 4 allocs/op
  20. BenchmarkFreeCacheGetParallel-4 4827747 282 ns/op 136 B/op 3 allocs/op
  21. BenchmarkConcurrentMapGetParallel-4 4005224 312 ns/op 24 B/op 2 allocs/op
  22. BenchmarkTourCacheSetGetParallel-4 1000000 1648 ns/op 403 B/op 9 allocs/op
  23. BenchmarkBigCacheSetGetParallel-4 2551568 512 ns/op 337 B/op 5 allocs/op
  24. BenchmarkFreeCacheSetGetParallel-4 2391876 539 ns/op 367 B/op 5 allocs/op
  25. BenchmarkConcurrentMapSetGetParallel-4 2123792 490 ns/op 224 B/op 8 allocs/op
  • 这个基准测试对比了 Map、ConcurrentMap(sync.Map)、BigCache、FreeCache 和 TourCache(LRU)。

从结果看,不管是否是并行测试,Get 方法的性能差距不大。然而,Set 方法的性能差距比较大。看看并行下的 Set 性能对比(具体数据,你的运行结果应该不一样):

  1. BenchmarkTourCacheSetParallel-4 3135122 1368 ns/op 338 B/op 7 allocs/op
  2. BenchmarkBigCacheSetParallel-4 14178873 393 ns/op 323 B/op 3 allocs/op
  3. BenchmarkFreeCacheSetParallel-4 13892508 481 ns/op 337 B/op 3 allocs/op
  4. BenchmarkConcurrentMapSetParallel-4 12457863 439 ns/op 200 B/op 6 allocs/op

TourCache 的性能只有 BigCache 和 FreeCache 的 1/4 左右。

Benchstat 工具简单介绍

有些读者可能要问,怎么认为这个基准测试是可靠的。比如再次运行,结果可能不一样。这里推荐一个 Go Team 的工具:benchstat。

执行如下命令安装 benchstat:

  1. go get -u golang.org/x/perf/cmd/benchstat

benchstat 这个工具计算并比较有关基准的统计信息。使用方式:

  1. benchstat [options] old.txt [new.txt] [more.txt ...]

针对上面 SetParallel 系列函数,我们运行 10 次基准测试,并将结果保存到 old.txt 文件中:

  1. go test -bench=SetParallel -benchmem -count=10 ./... | tee old.txt

然后使用 benchstat 工具对基准测试的结果进行统计。

  1. benchstat old.txt

结果如下:

  1. name time/op
  2. TourCacheSetGetParallel-4 1.52 µ s ± 3%
  3. BigCacheSetGetParallel-4 517ns ± 8%
  4. FreeCacheSetGetParallel-4 644ns ± 4%
  5. ConcurrentMapSetGetParallel-4 469ns ± 4%
  6. name alloc/op
  7. TourCacheSetGetParallel-4 367B ± 1%
  8. BigCacheSetGetParallel-4 336B ± 0%
  9. FreeCacheSetGetParallel-4 363B ± 1%
  10. ConcurrentMapSetGetParallel-4 224B ± 0%
  11. name allocs/op
  12. TourCacheSetGetParallel-4 9.00 ± 0%
  13. BigCacheSetGetParallel-4 5.00 ± 0%
  14. FreeCacheSetGetParallel-4 5.00 ± 0%
  15. ConcurrentMapSetGetParallel-4 8.00 ± 0%

我们关注最上面的一组。TourCache 的 time/op 在 1520ns 左右,上下浮动 3%;BigCache 的 time/op 在 517ns 左右,上下浮动 8%。总体上,TourCache 的性能是 BigCache 的 1/3 左右。

注意,ConcurrentMap 只是简单的存取,并没有实现 LRU。

如果在我们的库中将普通 map 换成 sync.Map 会怎么样?

sync.Map 类似于 Go 的 map[interface{}]interface{},但是可以安全地被多个 goroutine 并发使用,而无需额外的锁。大部分情况下,我们都应该只使用普通的 map,需要锁时加上锁。sync.Map 对以下两种场景进行了优化:

  • 当给定 key 的条目仅被写入一次却被读取多次时,例如在仅增长的高速缓存中;
  • 当多个 goroutine 读取,写入和覆盖不相交的键集合的条目时;

读者可以自行使用 sync.Map 替换 TourCache 中的 map 试试。

回过头我们看看基准测试的实现,主要关注 BenchmarkTourCacheSetParallel。

  1. func BenchmarkTourCacheSetParallel(b *testing.B) {
  2. cache := cache.NewTourCache(nil, lru.New(b.N*100, nil))
  3. rand.Seed(time.Now().Unix())
  4. b.RunParallel(func(pb *testing.PB) {
  5. id := rand.Intn(1000)
  6. counter := 0
  7. for pb.Next() {
  8. cache.Set(parallelKey(id, counter), value())
  9. counter = counter + 1
  10. }
  11. })
  12. }
  13. func key(i int) string {
  14. return fmt.Sprintf("key-%010d", i)
  15. }
  16. func value() []byte {
  17. return make([]byte, 100)
  18. }
  19. func parallelKey(threadID int, counter int) string {
  20. return fmt.Sprintf("key-%04d-%06d", threadID, counter)
  21. }

注意这里的 RunParallel。通过 RunParallel 方法以并行的方式执行给定的基准测试。RunParallel 会创建出多个 goroutine,并将 b.N 分配给这些 goroutine 执行,其中 goroutine 数量的默认值为 GOMAXPROCS。用户如果想要增加非 CPU 受限(non-CPU-bound)基准测试的并行性,那么可以在 RunParallel 之前调用 SetParallelism(如 SetParallelism(2),则 goroutine 数量为 2*GOMAXPROCS)。RunParallel 通常会与 -cpu 标志一同使用。

RunParallel 的参数是一个回调函数,它将在每个 goroutine 中执行,这个函数需要设置所有 goroutine 本地的状态,并迭代直到 pb.Next 返回 false 值为止。因为 StartTimerStopTimeResetTimer 这三个方法都带有全局作用,所以 回调函数不应该调用这些方法; 除此之外,回调函数也不应该调用 Run 方法。

5.4.2 高性能是如何做到的

从上面的基准测试可以看出,BigCache 和 FreeCache 的整体性能不错。

除此之外,还有另外一个库,它基于 BigCache,但做了一些改变,它就是 FastCache。有兴趣的读者可以自行了解。

本节我们来简单分析下它们是怎么做到高性能的。

5.4.2.1 加速并发访问

我们实现的 TourCache 性能不佳的主要原因在于使用了 sync.RWMutex 进行并发访问安全保护。对于非高并发场景下,锁争用少,性能好坏不明显。当在高并发场景下时,锁争用会很频繁,导致性能明显下降。

知道了性能不佳的原因,那怎么优化呢?为了并发安全,sync.RWMutex 似乎必不可少,那只能尽可能减少锁争用,让某些情况下无锁操作,或者让锁的时间尽可能短。

关于无锁操作,Go 源码提供了一些思路:

  • Go 的内存分配器,为每个工作线程绑定一个 cache,用于无锁 object 分配;当 cache 中没有可用内存时,才会加锁从 central 中获取;
  • Go 的调度器我们知道是 GMP 模型。对于要调度的任务,Go 除了有一个全局的任务队列,还为每一个 P 绑定了一个本地任务队列。这样,大部分时候,P 都可以无锁的获取本地任务队列中的任务来进行无锁操作;

以上两种做法可以抽象为将一堆数据块分为几组,组内的访问是无锁的,只有从全局数据块中为该组增加数据块时才需要锁。这样极大的降低了锁带来的开销。

BigCache 和 FreeCache 高性能的关键就是使用了类似的无锁技巧:将数据分片(分组),每个分片内需要锁。这跟上面的技巧是不一样的,但我认为思路是类似的。这样锁争用会得到较大的改善。

5.4.2.2 避免 GC

大部分时候,我们不需要刻意考虑该问题,因为现在的 Go GC 挺优秀的。然而有些场景,GC 可能会成为瓶颈,这时候需要刻意使用一些技巧来避免 GC 开销。

一般来说,常用的技巧有:

  • 对象池。重用对象,避免频繁分配内存;
  • 让内存分配在栈中,避免逃逸到堆;

对于 map 而言,GC 扫描时,是把 map 当做一个对象还是要扫描 map 里面一个个键值对?Go1.5 之后,如果 map 的键值都是基本类型,GC 不会扫描里面的键值对,这就避免了不必要的 GC 开销。这就是 BigCache 采用的优化方案。

而 FreeCache 采用的是另一种优化方案:减少指针数量。FreeCache 做到无论其中存储了多少记录,都只有 512 个指针。通过 key 的哈希值将数据集分割为 256 个段。每个段只有两个指针,一个是存储键和值的环形缓冲区,另一个是用于查找条目的索引切片。每个段都有自己的锁,因此它支持高并发访问。

5.4.3 小结

要做到高性能,必须减少锁导致的开销。本节提到的优化方案,在相应的库中是怎么实现的呢?下节我们学习下 BigCache 这个库是怎么做的。

本图书由 煎鱼©2020 版权所有,所有文章采用知识署名-非商业性使用-禁止演绎 4.0 国际进行许可。

5.4 缓存的性能和优化思路 - 图1