”在当前数字化时代,用户对在线服务的需求不断增加。为了满足实时性、稳定性的要求,构建高并发和高性能服务变得至关重要”
一、前言
在饮食行业的点餐APP中,顾客可以根据自己的需求选择附近的餐厅进入菜单进行点餐。从菜单的特性来看,其与访问量密切相关,即每个顾客在选择完成餐厅后,都会看到该餐厅对应的菜单数据,浏览人数越多,菜单的访问压力也就越大。而每个餐厅的菜单数据由特定于餐厅的差异数据和共性数据组成,在传统的菜单服务实现方法中,每当顾客选择餐厅进入菜单时,后端服务会根据菜单的共性数据来创建一个新对象。该方法的最大缺点就是:随着用户流量的增加,需要频繁地创建和销毁对象,导致后端服务在内存管理和性能方面效率低下。
本文将围绕此背景对对象池原理与对象池如何优化菜单业务进行详细介绍。
二、介绍
2.1 什么是对象池
即提前初始化一定数量实例的集合。当程序需要创建对象时从池子里获取,如果池子中没有符合条件的对象,则创建新的对象,使用完后需要进行回收,重新放入对象池,避免频繁创建对象造成内存开销,同时要建立清理机制,防止对象池无限增大。
2.2 实现方式
对象池的实现方式有很多种如:数组、链表等,但大多都千篇一律,本文将介绍golang的sync.pool底层原理来加深对对象池理解。
2.2.1 底层数据结构
数据结构源代码如下:
Go
type poolChain struct {
// head is the poolDequeue to push to. This is only accessed
// by the producer, so doesn't need to be synchronized.
head *poolChainElt
// tail is the poolDequeue to popTail from. This is accessed
// by consumers, so reads and writes must be atomic.
tail *poolChainElt
}
type poolChainElt struct {
poolDequeue
// next and prev link to the adjacent poolChainElts in this
// poolChain.
//
// next is written atomically by the producer and read
// atomically by the consumer. It only transitions from nil to
// non-nil.
//
// prev is written atomically by the consumer and read
// atomically by the producer. It only transitions from
// non-nil to nil.
next, prev *poolChainElt
}
type poolDequeue struct {
// headTail packs together a 32-bit head index and a 32-bit
// tail index. Both are indexes into vals modulo len(vals)-1.
//
// tail = index of oldest data in queue
// head = index of next slot to fill
//
// Slots in the range [tail, head) are owned by consumers.
// A consumer continues to own a slot outside this range until
// it nils the slot, at which point ownership passes to the
// producer.
//
// The head index is stored in the most-significant bits so
// that we can atomically add to it and the overflow is
// harmless.
headTail uint64
// vals is a ring buffer of interface{} values stored in this
// dequeue. The size of this must be a power of 2.
//
// vals[i].typ is nil if the slot is empty and non-nil
// otherwise. A slot is still in use until *both* the tail
// index has moved beyond it and typ has been set to nil. This
// is set to nil atomically by the consumer and read
// atomically by the producer.
vals []eface
}
sync.pool核心对象为poolChain,是一个链表 + ringBuffer(环形缓冲器) 结构 :
2.2.2 结构优点
1. ringBuffer的使用:
• 无锁即可保证数据正确性
环形缓冲区通常有一个读指针和一个写指针。读指针指向环形缓冲区中可读的数据,写指针指向环形缓冲区中可写的缓冲区。通过移动读指针和写指针就可以实现缓冲区的数据读取和写入。在通常情况下,环形缓冲区的读用户仅仅会影响读指针,而写用户仅仅会影响写指针
• 预先分配好内存,且分配的内存项可不断复用
• 连续内存结构,非常利于CPU Cache,访问速度快
cpu读取数据时会将数据从内存加载到自身三级缓存,Linux CPU Cache结构如下:
数据从内存中读取是以⼀小块⼀小块读取数据的,⽽不是按照单个元素来读取数据的,在 CPU Cache 中的,这样⼀小块⼀小块的数据,称为 Cache Line(缓存块)。所以连续内存数据会被cache line命中一起加载到CPU缓存中,减少了数据加载次数。
2. lock free的使用:
poolDequeue结构的head和tail是一个uint64的headTail变量。这是因为headTail 变量将 head和tail组在一起:其中高32位是head 变量,低32位是tail 变量。如下图所示:
在golang中poolDequeue可能会被多个P同时访问(GMP),在不引入 Mutex 锁的前提下,sync.Pool 利用了 atomic(原子锁)包中的 CAS 操作。两个 P 都可能会拿到对象,但在最终设置 headTail 的时候,只会有一个 P 调用 CAS 成功,另外一个 CAS 失败。
Go
atomic.CompareAndSwapUint64(&d.headTail, ptrs, ptrs2)
在更新 head 和 tail 的时候,也是通过原子变量 + 位运算进行操作的。例如,当实现 head++ 的时候,需要通过以下代码实现:
Go
const dequeueBits = 32atomic.AddUint64(&d.headTail, 1<<dequeueBits)
以上就是sync.pool底层对象池得基本原理,如想要更深入了解可自行研读golang sync.pool源码。
2.3 Benchmark性能测试演示
本文使用golang的sync.pool框架进行事例演示,完整代码如下:
Go
package tmp
import (
"sync"
"testing"
"time"
)
var pool = sync.Pool{
New: func() interface{} {
return new(YumcInfo)
},
}
// 模拟大对象
type NInfo struct {
name string
local string
content string
}
func (y *NInfo) init() {
y.name = ""
y.local = ""
y.content = ""
}
func newNObj() *NInfo {
//耗时模拟
time.Sleep(100 * time.Millisecond)
return new(NInfo)
}
// 不使用pool
func BenchmarkWrite(b *testing.B) {
var p *NInfo
for n := 0; n < b.N; n++ {
//验证耗时性能,这层循环需要注释
for j := 0; j < 20000; j++ {
p = newNObj()
p.name = "旅游景区"
p.local = "南京市区"
p.content = "夫子庙、紫荆山、钟山陵"
}
}
}
// 使用pool
func BenchmarkWriteWithPool(b *testing.B) {
for n := 0; n < b.N; n++ {
for j := 0; j < 20000; j++ {
p := pool.Get().(*NInfo) // 如果是第一个调用,则创建一个缓冲区
p.name = "旅游景区"
p.local = "南京市区"
p.content = "夫子庙、紫荆山、钟山陵"
// 放回 sync.Pool中,需要重置对象内容
p.init()
pool.Put(p)
}
}
}
2.3.1 内存性能提升
增加(for j := 0; j < 20000; j++)模拟频繁创建对象
1. 不使用对象池:
2. 使用对象池:
可以看出,内存消耗少了很多,因为复用了对象,几乎0开销【 480002 B/op => 0 B/op 】 |
2.3.2 延时性能提升
在创建对象中增加延时,模拟实际耗时对象创建
1. 不使用对象池:
2. 使用对象池:
可以看出,时间降低很多【 108570300 ns/op => 14.55 ns/op 】 |
2.4 应用场景
• 连接池:对于客户端连接创建和销毁是很耗费资源的,使用对象池可以避免频繁创建和销毁连接对象。
• 线程池:对于频繁创建和销毁线程的场景,使用线程池可以重复利用线程对象,提高性能和可伸缩性。
• 对象服用:对于需要频繁创建和销毁的对象,使用对象池可以避免频繁的创建和销毁开销,降低回收压力。
2.5 应用实践
本段将介绍对象池在菜单业务中实际应用,当用户进入菜单页面会全量加载菜单数据进行渲染(餐品会根据餐品售卖状况、门店配置等原因实时变化),业务特性使得菜单查询API要低延时、高性能。
因门店售卖菜单具有差异性,所以对菜单数据进行拆分,门店差异数据存放在redis,共性数据存放在服务本地共享,如下所示:
随着流量增长,发现菜单查询API延时与服务CPU使用率逐渐变高,服务监控发现GC过于频繁,采集dump生成火焰图如下:
注:火焰图就看顶层哪个函数占据宽度最大,只要有"平顶",就表示可能有性能问题
分析火焰图平顶发现调用栈顶部clone操作占用cpu频率高,查看代码发现在代码实现中因门店定制,会对原先的门店共性数据部分字段进行修改,因为共性数据是以全局变量形式在本地共享存储,为了避免门店修改相互影响数据准确性,代码对共性数据进行了clone处理,如下所示:
代码片段如下:
这种方式每次页面加载请求都会新开辟一块大内存存放共性数据,导致本地内存占用与GC开销严重从而影响服务性能。对此有两种优化方案:
1. 拆分门店共性数据结构,分离出变与不变字段
因共性数据结构比较复杂,拆分字段成本过大且未来数据格式变化不可预测,此方案暂未实施
2. 使用对象池
考量此方法快速且有效,具体实现流程如下:
三、结束语
在高性能服务开发中,GC对服务的CPU与内存影响尤为重要,目前GC优化的主要思路如下:
1. 减少内存分配次数
对象池、零拷贝
2. 减少对象的生命周期
尽早释放资源的原则,及时关闭文件、网络连接等资源。使用小对象、数组等简单数据结构(分配到栈),避免使用大对象和复杂数据结构,以减少内存占用
3. 优化GC参数
根据服务器性能调整GC触发规则
4. 编写友好代码,降低GC
(1)避免循环引用,减少对象的引用次数。
(2)避免大量的全局变量和常量,尽量使用局部变量和函数调用。
(3)使用指针等GC友好的数据结构,避免使用大量内存占用和频繁地复制操作。
(4)避免使用回调函数和反射等操作,这些操作可能会导致对象的生命周期变长,从而影响GC的性能。