在 echo 官网的手册上可以看到 echo 框架的路由性能主要依赖于 radix tree 和 sync.pool 对内存的复用。
Echo 的路由基于 radix tree ,它让路由的查询非常快。路由使用了 sync pool 来重复利用内存并且几乎达到了零内存占用。
对于高并发的应用来说,大量的 goroutines 的内存申请确实是个负担。想知道为什么需要用 sync.pool,了解代码中为什么使用了这个模块会提高性能,以及有哪些注意点,需要对 sync.pool 有一定的熟悉。先来看看它是如何实现的。
基于 Go 1.12 版本
主要结构
type Pool struct {
noCopy noCopy // noCopy 是一个空结构,用来防止 pool 在第一次使用后被复制
local unsafe.Pointer // per-P pool, 实际类型为 [P]poolLocal
localSize uintptr // local 的 size
// New 在 pool 中没有获取到,调用该方法生成一个变量
New func() interface{}
}
// 具体存储结构
type poolLocalInternal struct {
private interface{} // 只能由自己的 P 使用
shared []interface{} // 可以被任何的 P 使用
Mutex // 保护 shared 线程安全
}
type poolLocal struct {
poolLocalInternal
// 避免缓存 false sharing,使不同的线程操纵不同的缓存行,多核的情况下提升效率。
pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}
var (
allPoolsMu Mutex
allPools []*Pool // 池列表
)
关于缓存 false sharing 的文章可以参考我的译文
主体流程
看完整个结构后,我们先了解一下整个流程。
Put 方法
Put 方法的整个流程比较简单,主要是将用完的对象放回池中,看一下注释就可以理解。
func (p *Pool) Put(x interface{}) {
...
// 获取当前 P 的 pool
l := p.pin()
// 私有属性为空 放入
if l.private == nil {
l.private = x
x = nil
}
runtime_procUnpin()
// 私有属性放入失败 放入 shared 池
if x != nil {
l.Lock()
l.shared = append(l.shared, x)
l.Unlock()
}
...
}
Get 方法
我们找到对应的代码如下,
func (p *Pool) Get() interface{} {
...
// 获取当前 P 的 poolLocal
l := p.pin()
// 先从 private 读取
x := l.private
l.private = nil
runtime_procUnpin()
// private 没有
if x == nil {
l.Lock()
// 从当前 P 的 shared 末尾取一个
last := len(l.shared) - 1
if last >= 0 {
x = l.shared[last]
l.shared = l.shared[:last]
}
l.Unlock()
// 还没有取到 则去其他 P 的 shared 取
if x == nil {
x = p.getSlow()
}
}
...
// 最后还没取到 调用 NEW 方法生成一个
if x == nil && p.New != nil {
x = p.New()
}
return x
}
上面有一个 p.getSlow()
操作是说从其他的 P 中偷取一个,比较有意思,在 Go 的GMP模型中也存在这个偷的概念,基本和这个类似。我们来看看
func (p *Pool) getSlow() (x interface{}) {
...
// 尝试从其他 P 中窃取一个元素。
pid := runtime_procPin()
runtime_procUnpin()
for i := 0; i < int(size); i++ {
// 获取其他 P 的 poolLocal
l := indexLocal(local, (pid+i+1)%int(size))
l.Lock()
last := len(l.shared) - 1
if last >= 0 {
x = l.shared[last]
l.shared = l.shared[:last]
l.Unlock()
break
}
l.Unlock()
}
return x
}
存活周期以及内存回收
在倒入 pool 包时执行的 init 函数会向 GC 注册 poolCleanup
函数,也就是在 GC 之前会运行该函数。
func init() {
runtime_registerPoolCleanup(poolCleanup)
}
我们来看看 poolCleanup,该函数主要是将所有池的变量解除引用,为下一步的 GC 作准备。
func poolCleanup() {
// 在 GC 时会调用此函数。
// 它不能分配,也不应该调用任何运行时函数。
// 防御性地将所有东西归零,原因有两个:
// 1. 防止整个池的错误保留。
// 2. 如果GC发生时goroutine与Put / Get中的l.shared一起使用,它将保留整个Pool。因此下一周期内存消耗将增加一倍。
for i, p := range allPools {
// 将所有池对象接触引用 等待 GC 回收
allPools[i] = nil
for i := 0; i < int(p.localSize); i++ {
l := indexLocal(p.local, i)
l.private = nil
for j := range l.shared {
l.shared[j] = nil
}
l.shared = nil
}
p.local = nil
p.localSize = 0
}
allPools = []*Pool{}
}
整个流程图
echo 中的用途
在 echo 中主要用来存储 context,因为大量的 foroutines 不断申请 context 的内存,会给 GC 带来大的压力影响性能。所以 echo 采用 sync.pool 来优化。
// New creates an instance of Echo.
func New() (e *Echo) {
...
e.pool.New = func() interface{} {
return e.NewContext(nil, nil)
}
e.router = NewRouter(e)
return
}
// NewContext returns a Context instance.
func (e *Echo) NewContext(r *http.Request, w http.ResponseWriter) Context {
return &context{
request: r,
response: NewResponse(w, e),
store: make(Map),
echo: e,
pvalues: make([]string, *e.maxParam),
handler: NotFoundHandler,
}
}
// AcquireContext returns an empty `Context` instance from the pool.
// You must return the context by calling `ReleaseContext()`.
func (e *Echo) AcquireContext() Context {
return e.pool.Get().(Context)
}
// ReleaseContext returns the `Context` instance back to the pool.
// You must call it after `AcquireContext()`.
func (e *Echo) ReleaseContext(c Context) {
e.pool.Put(c)
}
看完定义,我们再看看,echo 里的使用。也就是说我们通过 pool 这种形式避免了在并发大的情况下,造成的内存申请,和 GC 的压力。
// http 请求处理方法
func (e *Echo) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// 从池里获取一个 context 对象
c := e.pool.Get().(*context)
// 重置对象
c.Reset(r, w)
...
// 用完后把 context
e.pool.Put(c)
}
基准测试
既然说 pool 的优势这么大,我们可以用基准测试来看一下 使用池和不实用池的区别。这里我们声明了一个非常简单的结构 S
package main
import (
"sync"
"testing"
)
type S struct {
num int
}
func BenchmarkWithPool(b *testing.B) {
var s *S
var pool = sync.Pool{
New: func() interface{} { return new(S) },
}
for i := 0; i < b.N; i++ {
for j := 0; j < 10000; j++ {
s = pool.Get().(*S)
s.num = 1
s.num++
pool.Put(s)
}
}
}
func BenchmarkWithNoPool(b *testing.B) {
var s *S
for i := 0; i < b.N; i++ {
for j := 0; j < 10000; j++ {
s = &S{num: 1}
s.num++
}
}
}
运行基准测试,
$ go test -bench=. -benchmem
goos: darwin
goarch: amd64
BenchmarkWithPool-4 10000 253269 ns/op 0 B/op 0 allocs/op
BenchmarkWithNoPool-4 10000 175742 ns/op 80000 B/op 10000 allocs/op
可以看到每次分配的内存 0 B vs 80000 B,每次内存分配次数 0 vs 10000。因为每次测试,我们执行了10000次迭代,所以看到没使用池的内存单次分配是 8B(即 结构 S 占的内存),单次分配次数为 1次。但是在每次执行的时间上使用池比不使用池是要多的,比较使用池涉及到池的维护,也算是正常的。这样看来,在高并发的场景下,context 的复用率非常高,所带来的 GC 压力也更小,所以效率当然就高了。