现代CPU一般具有三级缓存,目的是追求性价比。CPU在存取内存的时候存在缓存命中的问题,因此一般内存占用少的程序有更高的缓存命中率,一般性能会更好,但是内存占用的大小究竟对程序性能有多大影响呢?今天我们用WEB框架中常见数据结构的设计来分析一下性能。
Web框架中间件设计
在Web框架中常见的路由数据结构都是数,每个叶子节点代表一条路由,节点中有所有中间件按顺序串联起来的handlers,一般是函数的切片数组。就拿Golang世界中目前用的最多的Gin框架来说,他支持路由分组,可以对分组应用中间件,最后每个路由节点上生成的执行链,就是把父级分组中所有的中间件和自己的handler函数,包装成[]HandlerFunc
切片。
函数其实是一个指针类型,占用8个字节,如果有大量的分组,每个分组有大量的中间件函数,路由也超级多的情况下,一个WEB应用程序光是这个节点的执行链都会占用大量的内存。在访问的过程中势必造成大量的CPU缓存不命中的情况。
如果我们把所有的分组中间件函数放在统一的全局切片中,每个路由不用单独构建自己的执行链切片,只记录在全局切片中的起始位置,以后通过索引的方式访问中间件函数,能节省大量的内存。
下面时两种数据结构算法的公共共享代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
package cpu
import (
"runtime"
)
var GoroutinesNum = 200000 // Goroutines并发数
var NodeCount = 100000 // 路由节点个数
var OneNodeHandlerLen = 2000 // 每个节点中间件个数
func init() {
runtime.GOMAXPROCS(2)
}
type MidHandler func()
func CusHandlers() {
//var idx = 1
//for idx < 1 {
// idx++
//}
//return 1
}
type TreeNodeMini struct {
routerIdx int16
matchStart uint16
HdsStart uint16
HdsLen uint16
}
type TreeNodeCommon struct {
routerIdx int16
matchStart uint16
Hds []MidHandler
}
|
A:类似Gin的数据结构
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
|
package funcs
import (
"math/rand"
"testing"
"time"
"yufa/test/cpu"
)
var myTree []cpu.TreeNodeCommon
func init() {
myTree = make([]cpu.TreeNodeCommon, cpu.NodeCount)
for i := 0; i < cpu.NodeCount; i++ {
myTree[i].Hds = make([]cpu.MidHandler, cpu.OneNodeHandlerLen)
for j := 0; j < cpu.OneNodeHandlerLen; j++ {
myTree[i].Hds[j] = cpu.CusHandlers
}
}
}
func BenchmarkFuncSlice(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
b.StartTimer()
rand.Seed(time.Now().UnixNano())
//i := -1
// 并发测试模式
b.SetParallelism(cpu.GoroutinesNum)
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
//i++
//execNodeCommon(myTree[i%cpu.NodeCount])
execNodeCommon(myTree[rand.Intn(cpu.NodeCount)])
}
})
}
func execNodeCommon(common cpu.TreeNodeCommon) {
for i := 0; i < len(common.Hds); i++ {
common.Hds[i]()
}
}
|
B:叶子节点共享分组中间件的数据结构
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
|
package index
import (
"math/rand"
"testing"
"time"
"yufa/test/cpu"
)
var myTree []cpu.TreeNodeMini
var handlerSlice []cpu.MidHandler
func init() {
myTree = make([]cpu.TreeNodeMini, cpu.NodeCount)
handlerSlice = make([]cpu.MidHandler, cpu.OneNodeHandlerLen)
for i := 0; i < cpu.OneNodeHandlerLen; i++ {
handlerSlice[i] = cpu.CusHandlers
}
for j := 0; j < cpu.NodeCount; j++ {
myTree[j].HdsStart = 0
myTree[j].HdsLen = uint16(cpu.OneNodeHandlerLen)
}
}
func BenchmarkIndexSlice(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
b.StartTimer()
rand.Seed(time.Now().UnixNano())
//i := -1
// 并发测试模式
b.SetParallelism(cpu.GoroutinesNum)
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
//i++
//execNodeMini(myTree[i%cpu.NodeCount])
execNodeMini(myTree[rand.Intn(cpu.NodeCount)])
}
})
}
func execNodeMini(mini cpu.TreeNodeMini) {
for mini.HdsLen > 0 {
handlerSlice[mini.HdsStart]()
mini.HdsLen--
mini.HdsStart++
}
}
|
性能测试
看看A的性能测试结果:
1
2
3
4
5
6
7
8
|
yufa\test\cpu\funcs> go test -bench=$ -benchmem -benchtime=10s
goos: windows
goarch: amd64
pkg: yufa/test/cpu/funcs
cpu: Intel(R) Core(TM) i7-10700 CPU @ 2.90GHz
BenchmarkFuncSlice-2 8081774 1472 ns/op 1 B/op 0 allocs/op
PASS
ok yufa/test/cpu/funcs 28.299s
|
再看看B的性能测试结果:
1
2
3
4
5
6
7
8
|
yufa\test\cpu\index> go test -bench=$ -benchmem -benchtime=10s
goos: windows
goarch: amd64
pkg: yufa/test/cpu/index
cpu: Intel(R) Core(TM) i7-10700 CPU @ 2.90GHz
BenchmarkIndexSlice-2 8510996 1403 ns/op 1 B/op 0 allocs/op
PASS
ok yufa/test/cpu/index 20.322s
|
总结
你会发现AB两种设计方法,除了A的内存占用比B多1.6GB以外,无论CPU占用情况还是最后的基准测试性能,几乎都是一样的。而多出来的1.6G刚好是每个路由节点都有自己的独立的执行链切片,每个函数变量占用8字节,最后算下来刚好是1.6G左右的空间。
1
2
3
4
5
|
var NodeCount = 100000 // 路由节点个数
var OneNodeHandlerLen = 2000 // 每个节点中间件个数
// 这种数据结构的内存占用多了下面 1.6G 的内存空间。
100000*2000*8 = 1600000000 = 1.6G
|
如果将路由节点个数和每个节点的中间件函数个数改小,比如:
1
2
3
|
var GoroutinesNum = 200000 // 测试时候的并发数
var NodeCount = 100 // 路由节点个数
var OneNodeHandlerLen = 20 // 每个节点中间件个数
|
测试之后你更会发现,两种情况执行性能几乎无差别。
好吧,经过测试我发现我以前完全想错了,本以为内存占用大的情况下,CPU三级缓存不命中的时候会带来可观的换页开销,但至少目前的测试狠狠的被打脸了,CPU访问内存的性能是惊人的高,CPU三级缓存换页的开销和业务逻辑的运算比起来根本不算啥。
内存当然需要节省,当运行大量程序的时候,内存占用过多可能导致程序直接崩溃。但是刻意节省少量内存不会给你的程序带来太大的性能改观,反而可能因为引入复杂的数据结构,造成代码逻辑复杂,而且中间换算的计算量增大反而消耗掉一部分性能。
(完)