IT评测·应用市场-qidao123.com技术社区

标题: sync.Pool:提高Go语言程序性能的关键一步 [打印本页]

作者: 大连全瓷种植牙齿制作中心    时间: 2023-4-7 21:27
标题: sync.Pool:提高Go语言程序性能的关键一步
1. 简介

本文将介绍 Go 语言中的 sync.Pool并发原语,包括sync.Pool的基本使用方法、使用注意事项等的内容。能够更好得使用sync.Pool来减少对象的重复创建,最大限度实现对象的重复使用,减少程序GC的压力,以及提升程序的性能。
2. 问题引入

2.1 问题描述

这里我们实现一个简单的JSON序列化器,能够实现将一个map[string]int序列化为一个JSON字符串,实现如下:
  1. func IntToStringMap(m map[string]int) (string, error) {
  2.    // 定义一个bytes.Buffer,用于缓存数据
  3.    var buf bytes.Buffer
  4.    buf.Write([]byte("{"))
  5.    for k, v := range m {
  6.       buf.WriteString(fmt.Sprintf(`"%s":%d,`, k, v))
  7.    }
  8.    if len(m) > 0 {
  9.       buf.Truncate(buf.Len() - 1) // 去掉最后一个逗号
  10.    }
  11.    buf.Write([]byte("}"))
  12.    return buf.String(), nil
  13. }
复制代码
这里使用bytes.Buffer 来缓存数据,然后按照key:value的形式,将数据生成一个字符串,然后返回,实现是比较简单的。
每次调用IntToStringMap方法时,都会创建一个bytes.Buffer来缓存中间结果,而bytes.Buffer其实是可以被重用的,因为序列化规则和其并没有太大的关系,其只是作为一个缓存区来使用而已。
但是当前的实现为每次调用IntToStringMap时,都会创建一个bytes.Buffer,如果在一个应用中,请求并发量非常高时,频繁创建和销毁bytes.Buffer将会带来较大的性能开销,会导致对象的频繁分配和垃圾回收,增加了内存使用量和垃圾回收的压力。
那有什么方法能够让bytes.Buffer能够最大程度得被重复利用呢,避免重复的创建和回收呢?
2.2 解决方案

其实我们可以发现,为了让bytes.Buffer能够被重复利用,避免重复的创建和回收,我们此时只需要将bytes.Buffer缓存起来,在需要时,将其从缓存中取出;当用完后,便又将其放回到缓存池当中。这样子,便不需要每次调用IntToStringMap方法时,就创建一个bytes.Buffer。
这里我们可以自己实现一个缓存池,当需要对象时,可以从缓存池中获取,当不需要对象时,可以将对象放回缓存池中。IntToStringMap方法需要bytes.Buffer时,便从该缓存池中取,当用完后,便重新放回缓存池中,等待下一次的获取。下面是一个使用切片实现的一个bytes.Buffer缓存池。
  1. type BytesBufferPool struct {
  2.    mu   sync.Mutex
  3.    pool []*bytes.Buffer
  4. }
  5. func (p *BytesBufferPool) Get() *bytes.Buffer {
  6.    p.mu.Lock()
  7.    defer p.mu.Unlock()
  8.    n := len(p.pool)
  9.    if n == 0 {
  10.       // 当缓存池中没有对象时,创建一个bytes.Buffer
  11.       return &bytes.Buffer{}
  12.    }
  13.    // 有对象时,取出切片最后一个元素返回
  14.    v := p.pool[n-1]
  15.    p.pool[n-1] = nil
  16.    p.pool = p.pool[:n-1]
  17.    return v
  18. }
  19. func (p *BytesBufferPool) Put(buffer *bytes.Buffer) {
  20.    if buffer == nil {
  21.       return
  22.    }
  23.    // 将bytes.Buffer放入到切片当中
  24.    p.mu.Lock()
  25.    defer p.mu.Unlock()
  26.    obj.Reset()
  27.    p.pool = append(p.pool, buffer)
  28. }
复制代码
上面BytesBufferPool实现了一个bytes.Buffer的缓存池,其中Get方法用于从缓存池中取对象,如果没有对象,就创建一个新的对象返回;Put方法用于将对象重新放入BytesBufferPool当中,下面使用BytesBufferPool来优化IntToStringMap。
  1. // 首先定义一个BytesBufferPool
  2. var buffers BytesBufferPool
  3. func IntToStringMap(m map[string]int) (string, error) {
  4.    // bytes.Buffer不再自己创建,而是从BytesBufferPool中取出
  5.    buf := buffers.Get()
  6.    // 函数结束后,将bytes.Buffer重新放回缓存池当中
  7.    defer buffers.Put(buf)
  8.    buf.Write([]byte("{"))
  9.    for k, v := range m {
  10.       buf.WriteString(fmt.Sprintf(`"%s":%d,`, k, v))
  11.    }
  12.    if len(m) > 0 {
  13.       buf.Truncate(buf.Len() - 1) // 去掉最后一个逗号
  14.    }
  15.    buf.Write([]byte("}"))
  16.    return buf.String(), nil
  17. }
复制代码
到这里我们通过自己实现了一个缓存池,成功对InitToStringMap函数进行了优化,减少了bytes.Buffer对象频繁的创建和回收,在一定程度上提高了对象的频繁创建和回收。
但是,BytesBufferPool这个缓存池的实现,其实存在几点问题,其一,只能用于缓存bytes.Buffer对象;其二,不能根据系统的实际情况,动态调整对象池中缓存对象的数量。假如某段时间并发量较高,bytes.Buffer对象被大量创建,用完后,重新放回BytesBufferPool之后,将永远不会被回收,这有可能导致内存浪费,严重一点,也会导致内存泄漏。
既然自定义缓存池存在这些问题,那我们不禁要问,Go语言标准库中有没有提供了更方便的方式,来帮助我们缓存对象呢?
别说,还真有,Go标准库提供了sync.Pool,可以用来缓存那些需要频繁创建和销毁的对象,而且它支持缓存任何类型的对象,同时sync.Pool是可以根据系统的实际情况来调整缓存池中对象的数量,如果一个对象长时间未被使用,此时将会被回收掉。
相对于自己实现的缓冲池,sync.Pool的性能更高,充分利用多核cpu的能力,同时也能够根据系统当前使用对象的负载,来动态调整缓冲池中对象的数量,而且使用起来也比较简单,可以说是实现无状态对象缓存池的不二之选。
下面我们来看看sync.Pool的基本使用方式,然后将其应用到IntToStringMap方法的实现当中。
3. 基本使用

3.1 使用方式

3.1.1 sync.Pool的基本定义

sync.Pool的定义如下: 提供了Get,Put两个方法:
  1. type Pool struct {
  2.   noCopy noCopy
  3.   local     unsafe.Pointer // local fixed-size per-P pool, actual type is [P]poolLocal
  4.   localSize uintptr        // size of the local array
  5.   victim     unsafe.Pointer // local from previous cycle
  6.   victimSize uintptr        // size of victims array
  7.   New func() any
  8. }
  9. func (p *Pool) Put(x any) {}
  10. func (p *Pool) Get() any {}
复制代码
3.1.2 使用方式

当使用sync.Pool时,通常需要以下几个步骤:
下面是一个简单的代码的示例,展示了使用sync.Pool大概的代码结构:
  1. type struct data{
  2.     // 定义一些属性
  3. }
  4. //1. 创建一个data对象的缓存池
  5. var dataPool = sync.Pool{New: func() interface{} {
  6.    return &data{}
  7. }}
  8. func Operation_A(){
  9.     // 2. 需要用到data对象的地方,从缓存池中取出
  10.     d := dataPool.Get().(*data)
  11.     // 执行后续操作
  12.     // 3. 将对象重新放入缓存池中
  13.     dataPool.Put(d)
  14. }
复制代码
3.2 使用例子

下面我们使用sync.Pool来对IntToStringMap进行改造,实现对bytes.Buffer对象的重用,同时也能够自动根据系统当前的状况,自动调整缓冲池中对象的数量。
  1. // 1. 定义一个bytes.Buffer的对象缓冲池
  2. var buffers sync.Pool = sync.Pool{
  3.    New: func() interface{} {
  4.       return &bytes.Buffer{}
  5.    },
  6. }
  7. func IntToStringMap(m map[string]int) (string, error) {
  8.    // 2. 在需要的时候,从缓冲池中取出一个bytes.Buffer对象
  9.    buf := buffers.Get().(*bytes.Buffer)
  10.    buf.Reset()
  11.    // 3. 用完之后,将其重新放入缓冲池中
  12.    defer buffers.Put(buf)
  13.    buf.Write([]byte("{"))
  14.    for k, v := range m {
  15.       buf.WriteString(fmt.Sprintf(`"%s":%d,`, k, v))
  16.    }
  17.    if len(m) > 0 {
  18.       buf.Truncate(buf.Len() - 1) // 去掉最后一个逗号
  19.    }
  20.    buf.Write([]byte("}"))
  21.    return buf.String(), nil
  22. }
复制代码
上面我们使用sync.Pool实现了一个bytes.Buffer的缓冲池,在 IntToStringMap 函数中,我们从 buffers 中获取一个 bytes.Buffer 对象,并在函数结束时将其放回池中,避免了频繁创建和销毁 bytes.Buffer 对象的开销。
同时,由于sync.Pool在IntToStringMap调用不频繁的情况下,能够自动回收sync.Pool中的bytes.Buffer对象,无需用户操心,也能减小内存的压力。而且其底层实现也有考虑到多核cpu并发执行,每一个processor都会有其对应的本地缓存,在一定程度也减少了多线程加锁的开销。
从上面可以看出,sync.Pool使用起来非常简单,但是其还是存在一些注意事项,如果使用不当的话,还是有可能会导致内存泄漏等问题的,下面就来介绍sync.Pool使用时的注意事项。
4.使用注意事项

4.1 需要注意放入对象的大小

如果不注意放入sync.Pool缓冲池中对象的大小,可能出现sync.Pool中只存在几个对象,却占据了大量的内存,导致内存泄漏。
这里对于有固定大小的对象,并不需要太过注意放入sync.Pool中对象的大小,这种场景出现内存泄漏的可能性小之又小。但是,如果放入sync.Pool中的对象存在自动扩容的机制,如果不注意放入sync.Pool中对象的大小,此时将很有可能导致内存泄漏。下面来看一个例子:
  1. func Sprintf(format string, a ...any) string {
  2.    p := newPrinter()
  3.    p.doPrintf(format, a)
  4.    s := string(p.buf)
  5.    p.free()
  6.    return s
  7. }
复制代码
Sprintf方法根据传入的format和对应的参数,完成组装,返回对应的字符串结果。按照普通的思路,此时只需要申请一个byte数组,然后根据一定规则,将format和参数的内容放入byte数组中,最终将byte数组转换为字符串返回即可。
按照上面这个思路我们发现,其实每次使用到的byte数组是可复用的,并不需要重复构建。
实际上Sprintf方法的实现也是如此,byte数组其实并非每次创建一个新的,而是会对其进行复用。其实现了一个pp结构体,format和参数按照一定规则组装成字符串的职责,交付给pp结构体,同时byte数组作为pp结构体的成员变量。
然后将pp的实例放入sync.Pool当中,实现pp重复使用目的,从而简介避免了重复创建byte数组导致频繁的GC,同时也提升了性能。下面是newPrinter方法的逻辑,获取pp结构体,都是从sync.Pool中获取:
  1. var ppFree = sync.Pool{
  2.    New: func() any { return new(pp) },
  3. }
  4. // newPrinter allocates a new pp struct or grabs a cached one.
  5. func newPrinter() *pp {
  6.     // 从ppFree中获取pp
  7.    p := ppFree.Get().(*pp)
  8.    // 执行一些初始化逻辑
  9.    p.panicking = false
  10.    p.erroring = false
  11.    p.wrapErrs = false
  12.    p.fmt.init(&p.buf)
  13.    return p
  14. }
复制代码
下面回到上面的byte数组,此时其作为pp结构体的一个成员变量,用于字符串格式化的中间结果,定义如下:
  1. // Use simple []byte instead of bytes.Buffer to avoid large dependency.
  2. type buffer []byte
  3. type pp struct {
  4.    buf buffer
  5.    // 省略掉其他不相关的字段
  6. }
复制代码
这里看起来似乎没啥问题,但是其实是有可能存在内存浪费甚至内存泄漏的问题。假如此时存在一个非常长的字符串需要格式化,此时调用Sprintf来实现格式化,此时pp结构体中的buffer也同样需要不断扩容,直到能够存储整个字符串的长度为止,此时pp结构体中的buffer将会占据比较大的内存。
当Sprintf方法完成之后,重新将pp结构体放入sync.Pool当中,此时pp结构体中的buffer占据的内存将不会被释放。
但是,如果下次调用Sprintf方法来格式化的字符串,长度并没有那么长,但是此时从sync.Pool中取出的pp结构体中的byte数组长度却是上次扩容之后的byte数组,此时将会导致内存浪费,严重点甚至可能导致内存泄漏。
因此,因为pp对象中buffer字段占据的内存是会自动扩容的,对象的大小是不固定的,因此将pp对象重新放入sync.Pool中时,需要注意放入对象的大小,如果太大,可能会导致内存泄漏或者内存浪费的情况,此时可以直接抛弃,不重新放入sync.Pool当中。事实上,pp结构体重新放入sync.Pool也是基于该逻辑,其会先判断pp结构体中buffer字段占据的内存大小,如果太大,此时将不会重新放入sync.Pool当中,而是直接丢弃,具体如下:
[code]func (p *pp) free() {   // 如果byte数组的大小超过一定限度,此时将会直接返回   if cap(p.buf) > 64




欢迎光临 IT评测·应用市场-qidao123.com技术社区 (https://dis.qidao123.com/) Powered by Discuz! X3.4