池化思想简介及其在开源中的应用

1,092 阅读6分钟

补充

---------------20230424补充--------------------- GIN

什么是池化

提前准备一些资源,在需要时可以重复使用这些预先准备的资源。

通俗的讲,池化技术就是:把一些资源预先分配好,组织到资源池中,之后的业务可以直接从资源池中获取,使用完后放回到资源池中

好处

  • 减少资源重复使用, 减少了资源分配和释放过程中的系统消耗。
  • 限制资源的使用量 。比如线程池中的Core和Max
  • 避免内存碎片化。 由于资源通常是集中分配的,避免了内存被碎片化占用

 

常见的池

协程池

image.png

连接池(Redis MySQL)

连接池包含数据库连接池、Redis连接池、Http连接池等

 

资源池(Locker WaitGroup ByteBuffer)

image.png

  image.png

image.png

使用&不使用 对比

bytesBuffer池

package benchmark_newStruct
import (
   "bytes"
   "io/ioutil"
   "sync"
   "testing"
)
var bytesBufferPool = sync.Pool{
   New: func() interface{} {
      return &bytes.Buffer{}
   },
}
var (
   fileName = "$(pwd)/benchmark/benchmark_pool/test.txt"
   data     = make([]byte, 10000) //字节长度
)
func newBytesBuffer() *bytes.Buffer {
   return new(bytes.Buffer)
}
func BenchmarkWriteFile(b *testing.B) {
   for n := 0; n < b.N; n++ {
      buf := newBytesBuffer()
      buf.Reset() // Reset 缓存区,不然会连接上次调用时保存在缓存区里的内容
      buf.Write(data)
      _ = ioutil.WriteFile(fileName, buf.Bytes(), 0644)
   }
}
func BenchmarkWriteFileWithPool(b *testing.B) {
   for n := 0; n < b.N; n++ {
      buf := bytesBufferPool.Get().(*bytes.Buffer) // 如果是第一个调用,则创建一个缓冲区
      buf.Reset() // Reset 缓存区,不然会连接上次调用时保存在缓存区里的内容
      buf.Write(data)
      _ = ioutil.WriteFile(fileName, buf.Bytes(), 0644)
      bytesBufferPool.Put(buf) // 将缓冲区放回 sync.Pool中
   }
}
  • 10^3字节

image.png

  • 10^4字节

image.png

  • 10^5字节

image.png

  • 10^7

image.png

可以看到,单次op的耗时降低从1/3 -> 1/10 , 单次op对内存消耗降低99%

 

Kafka

 

发送消息

我们先看一下客户端每条消息的元数据构成 ProducerRecode

image.png

其中灰色部分可能为空

 

发送消息的过程

  image.png

 

Kafka为了提高吞吐量,Producer采用批量发送数据的模式 ,其Producer 和Server的交互过程中使用了消息累加器和客户端内存池来提高吞吐量

 

Kafka参数

buffer.memory=         //内存池最大占用内存大小 默认32MB
batch.size=            //每个Batch的大小 默认16KB

 

消息累加器

Kafka生产端将消息聚合成一个Batch发送

 

常见的Batch聚合

image.png  

但是Kafka有一个Batch队列的概念,每个Batch都添加到一个Deque(双端队列)中

  image.png

新消息会添加到deque中的最后一个Batch中,当Batch达到配置的Batch.size或者linger.ms上限后,该Batch内的消息发送至Sender线程

 

 

同时,Deque在内存中常驻,因此当Batch中的消息被发送到Sender线程后,Batch不会直接被JVM回收,而是会等待一定时间(默认1分钟) 未被使用后放入内存池中

 

 

内存池设计

kafka客户端内存池不是JVM维护的,而是由Kafka手动维护的

 

  • 对象设计
public class BufferPool {
   //总共的内存大小
    private final long totalMemory;
//每一个producerbatch的默认大小
    private final int poolableSize;
//锁对象,用于保证多生产者获取内存的线程安全
    private final ReentrantLock lock;
//已经从jvm中申请的内存,但是目前没有使用
    private final Deque<ByteBuffer> free;
//等待申请的内存的生产者的condition队列
    private final Deque<Condition> waiters;
    //还剩余可用的内存空间大小,totalMemory = free.size() * poolableSize + nonPooledAvailableMemory
    private long nonPooledAvailableMemory;
//记录器
    private final Metrics metrics;
//kafka自带的时间类
    private final Time time;
//等待时间的类,和Metrics一起使用记录等待时长
    private final Sensor waitTime;
//是否关闭标志位
private boolean closed;
}

image.png

nonPooledAvailableMemory指的就是非缓冲池的可用内存大小。
非缓冲池分配内存,其实就是调用ByteBuffer.allocat分配真实的JVM内存

 

消息发送流程

引入内存池之后 消息发送的大概流程 image.png

流程图

image.png  

总结

  • Kafka以Batch为最小单位发送消息
  • Batch实际是一段定长的byte数组,对该数组的所有操作仅支持追加且每次追加都会加锁
  • Kafka通过内存池的方式对每个Batch进行内存管理,避免频繁进行内存的申请、Batch对象的创建和销毁

思考

Q1: 我们看到,在kafka当中,对于内存申请 无处不在公平,包括deque也一样,公平的双向队列 先进先出,而没有引入优先队列去实现类似权重的功能 Why?
A1: 我理解对于消息发送这个场景,每一条消息都是公平的,不存在哪条消息更高优,如果需要引入权重的概念,那可以做back up ,去单独搭建高优消息的Kafka集群

Broker

image.png 在Kafka的架构中,会有很多客户端向Broker端发送请求,Kafka 的 Broker 端有个 SocketServer 组件,用来和客户端建立连接,然后通过Acceptor线程来进行请求的分发,由于Acceptor不涉及具体的逻辑处理,非常得轻量级,因此有很高的吞吐量。

接着Acceptor 线程采用轮询的方式将入站请求公平地发到所有网络线程中

网络线程池

网络线程池默认大小是 3个,表示每台 Broker 启动时会创建 3 个网络线程,专门处理客户端发送的请求,可以通过Broker 端参数 num.network.threads来进行修改。

image.png

当网络线程拿到请求后,会将请求放入到一个共享请求队列中。

IO线程池

同时,Broker 端还有个 IO 线程池,负责从该队列中取出请求,执行真正的处理。如果是 Produce 生产请求,则将消息写入到底层的磁盘日志中;如果是 Fetch 请求,则从磁盘或页缓存中读取消息。

IO 线程池处中的线程是执行请求逻辑的线程,默认是8,表示每台 Broker 启动后自动创建 8 个 IO 线程处理请求,可以通过Broker 端参数 num.io.threads调整

GIN

gin的Context在主引擎中是以sync.Pool的方式完成初始化的,也就是说一开始不会有任何Context存在,直到第一个请求过来时才会初始化一个Context,后续的请求有可能会复用之前的Context,这就解释了为什么会有一个 reset() 方法。

func New() *Engine {
	engine := &Engine{
	...
	}
	//初始化生成一个Context的方法
	engine.pool.New = func() any {
		return engine.allocateContext(engine.maxParams)
	}
	return engine
}


//生成一个gin的Context
func (engine *Engine) allocateContext(maxParams uint16) *Context {
	v := make(Params, 0, maxParams) //声明一个Params的切片,并且直接定义好容量
	skippedNodes := make([]skippedNode, 0, engine.maxSections) 
	//返回一个gin的Context,注意此时的Context还不完整,不能直接使用。
	return &Context{engine: engine, params: &v, skippedNodes: &skippedNodes}
}


//真正获取Context,并且完成初始化的地方。
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	//从池子里拿到一个Context,此时没人能够保证这个Context是新的还是别人用过的。
	c := engine.pool.Get().(*Context) 
	//将两个最核心的部分塞进Context。
	c.writermem.reset(w)
	c.Request = req
	//重置下Context其他属性,无论他是不是二手的,这里都可以保证它是新的。
	c.reset() 
	//开始处理HTTP请求。
	engine.handleHTTPRequest(c)
	//处理完成,将当前用过的Context再放回池子里。
	engine.pool.Put(c)
}

总结

  • 池化思想是服务端性能优化的一个方法,通常能给我们带来较高的性能提升,但是同时为了维护这个池子会带来额外的消耗,通常来说这个消耗都是可以被接受的