写在前面

在高并发的情况下,如果每次请求都需要申请一块用于计算的内存,比如:

make([]int64, 0, len(ids))

将会是一件成本很高的事情。为了定位项目中的慢语句,我曾经采用“二分法”的方式打印慢日志,定位程序变慢的代码位置。它并不是每次都慢,而是每过几秒钟就突然变得极其慢,TPS能从2000降到200。引起这个问题就是类似于上面这条语句。

slice
make([]int64, 0)
appendsliceslice
a := make([]int64, 0)
fmt.Println(cap(a), len(a))

for i := 0; i < 3; i++ {
	a = append(a, 1)
	fmt.Println(cap(a), len(a))
}

0 0
1 1
2 2
4 3

每一次扩容空间,都是会重新申请一块区域,把就空间里面的元素复制进来,把新的追加进来。那旧空间里面的元素怎么办?等着垃圾回收呗。

slice
make([]int64, 0, len(ids))
slice

堆还是栈?

程序会从操作系统申请一块内存,而这块内存也会被分成堆和栈。栈可以简单得理解成一次函数调用内部申请到的内存,它们会随着函数的返回把内存还给系统。

func F() {
	temp := make([]int, 0, 20)
	...
}
temp
func F() []int{
	a := make([]int, 0, 20)
	return a
}

而上面这段代码,申请的代码一模一样,但是申请后作为返回值返回了,编译器会认为变量之后还会被使用,当函数返回之后并不会将其内存归还,那么它就会被申请到堆上面了。申请到堆上面的内存才会引起垃圾回收

那么考考大家,下面这三种情况怎么解释?

func F() {
	a := make([]int, 0, 20)
	b := make([]int, 0, 20000)

	l := 20
	c := make([]int, 0, l)
}
abab
ca

可以通过下面的命令查看变量申请的位置。详细内容可以参考我之前的文章《【译】优化Go的模式》

go build -gcflags='-m' . 2>&1

内存碎片化

c := make([]int, 0, l)

临时对象池

syncPool
func (p *Pool) Get() interface{}
Get

很快,我写出了第一版对象池优化方案:

var idsPool = sync.Pool{
	New: func() interface{} {
		ids := make([]int64, 0, 20000)
		return &ids
	},
}

func NewIds() []int64 {
	ids := idsPool.Get().(*[]int64)
	*ids = (*ids)[:0]
	idsPool.Put(ids)
	return *ids
}
sliceslice

紧接着参考了达达大神的代码sync_pool.go,又写了一版:

var DEFAULT_SYNC_POOL *SyncPool

func NewPool() *SyncPool {
	DEFAULT_SYNC_POOL = NewSyncPool(
		5,     
		30000, 
		2,     
	)
	return DEFAULT_SYNC_POOL
}

func Alloc(size int) []int64 {
	return DEFAULT_SYNC_POOL.Alloc(size)
}

func Free(mem []int64) {
	DEFAULT_SYNC_POOL.Free(mem)
}

// SyncPool is a sync.Pool base slab allocation memory pool
type SyncPool struct {
	classes     []sync.Pool
	classesSize []int
	minSize     int
	maxSize     int
}

func NewSyncPool(minSize, maxSize, factor int) *SyncPool {
	n := 0
	for chunkSize := minSize; chunkSize <= maxSize; chunkSize *= factor {
		n++
	}
	pool := &SyncPool{
		make([]sync.Pool, n),
		make([]int, n),
		minSize, maxSize,
	}
	n = 0
	for chunkSize := minSize; chunkSize <= maxSize; chunkSize *= factor {
		pool.classesSize[n] = chunkSize
		pool.classes[n].New = func(size int) func() interface{} {
			return func() interface{} {
				buf := make([]int64, size)
				return &buf
			}
		}(chunkSize)
		n++
	}
	return pool
}

func (pool *SyncPool) Alloc(size int) []int64 {
	if size <= pool.maxSize {
		for i := 0; i < len(pool.classesSize); i++ {
			if pool.classesSize[i] >= size {
				mem := pool.classes[i].Get().(*[]int64)
				// return (*mem)[:size]
				return (*mem)[:0]
			}
		}
	}
	return make([]int64, 0, size)
}

func (pool *SyncPool) Free(mem []int64) {
	if size := cap(mem); size <= pool.maxSize {
		for i := 0; i < len(pool.classesSize); i++ {
			if pool.classesSize[i] >= size {
				pool.classes[i].Put(&mem)
				return
			}
		}
	}
}

调用例子:

attrFilters := cache.Alloc(len(ids))
defer cache.Free(attrFilters)
Allocslice
DEFAULT_SYNC_POOL = NewSyncPool(
	5,     
	30000, 
	2,     
)
sliceFree

结论

为了优化接口,前前后后搞了一年。结果还是不错的,TPS提升了最少30%,TP99也降低很多。