3

sync.Pool:提高Go语言程序性能的关键一步 - 菜鸟额

 1 year ago
source link: https://www.cnblogs.com/chenjiazhan/p/17297430.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

2. 问题引入

2.1 问题描述

这里我们实现一个简单的JSON序列化器,能够实现将一个map[string]int序列化为一个JSON字符串,实现如下:

func IntToStringMap(m map[string]int) (string, error) {
   // 定义一个bytes.Buffer,用于缓存数据
   var buf bytes.Buffer
   buf.Write([]byte("{"))
   for k, v := range m {
      buf.WriteString(fmt.Sprintf(`"%s":%d,`, k, v))
   }
   if len(m) > 0 {
      buf.Truncate(buf.Len() - 1) // 去掉最后一个逗号
   }
   buf.Write([]byte("}"))
   return buf.String(), nil
}

这里使用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缓存池。

type BytesBufferPool struct {
   mu   sync.Mutex
   pool []*bytes.Buffer
}

func (p *BytesBufferPool) Get() *bytes.Buffer {
   p.mu.Lock()
   defer p.mu.Unlock()
   n := len(p.pool)
   if n == 0 {
      // 当缓存池中没有对象时,创建一个bytes.Buffer
      return &bytes.Buffer{}
   }
   // 有对象时,取出切片最后一个元素返回
   v := p.pool[n-1]
   p.pool[n-1] = nil
   p.pool = p.pool[:n-1]
   return v
}

func (p *BytesBufferPool) Put(buffer *bytes.Buffer) {
   if buffer == nil {
      return
   }
   // 将bytes.Buffer放入到切片当中
   p.mu.Lock()
   defer p.mu.Unlock()
   obj.Reset()
   p.pool = append(p.pool, buffer)
}

上面BytesBufferPool实现了一个bytes.Buffer的缓存池,其中Get方法用于从缓存池中取对象,如果没有对象,就创建一个新的对象返回;Put方法用于将对象重新放入BytesBufferPool当中,下面使用BytesBufferPool来优化IntToStringMap

// 首先定义一个BytesBufferPool
var buffers BytesBufferPool

func IntToStringMap(m map[string]int) (string, error) {
   // bytes.Buffer不再自己创建,而是从BytesBufferPool中取出
   buf := buffers.Get()
   // 函数结束后,将bytes.Buffer重新放回缓存池当中
   defer buffers.Put(buf)
   buf.Write([]byte("{"))
   for k, v := range m {
      buf.WriteString(fmt.Sprintf(`"%s":%d,`, k, v))
   }
   if len(m) > 0 {
      buf.Truncate(buf.Len() - 1) // 去掉最后一个逗号
   }
   buf.Write([]byte("}"))
   return buf.String(), nil
}

到这里我们通过自己实现了一个缓存池,成功对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两个方法:

type Pool struct {
  noCopy noCopy

  local     unsafe.Pointer // local fixed-size per-P pool, actual type is [P]poolLocal
  localSize uintptr        // size of the local array

  victim     unsafe.Pointer // local from previous cycle
  victimSize uintptr        // size of victims array

  New func() any
}
func (p *Pool) Put(x any) {}
func (p *Pool) Get() any {}
  • Get方法: 从sync.Pool中取出缓存对象
  • Put方法: 将缓存对象放入到sync.Pool当中
  • New函数: 在创建sync.Pool时,需要传入一个New函数,当Get方法获取不到对象时,此时将会调用New函数创建新的对象返回。

3.1.2 使用方式

当使用sync.Pool时,通常需要以下几个步骤:

  • 首先使用sync.Pool定义一个对象缓冲池
  • 在需要使用到对象时,从缓冲池中取出
  • 当使用完之后,重新将对象放回缓冲池中

下面是一个简单的代码的示例,展示了使用sync.Pool大概的代码结构:

type struct data{
    // 定义一些属性
}
//1. 创建一个data对象的缓存池
var dataPool = sync.Pool{New: func() interface{} {
   return &data{}
}}

func Operation_A(){
    // 2. 需要用到data对象的地方,从缓存池中取出
    d := dataPool.Get().(*data)
    // 执行后续操作
    // 3. 将对象重新放入缓存池中
    dataPool.Put(d)
}

3.2 使用例子

下面我们使用sync.Pool来对IntToStringMap进行改造,实现对bytes.Buffer对象的重用,同时也能够自动根据系统当前的状况,自动调整缓冲池中对象的数量。

// 1. 定义一个bytes.Buffer的对象缓冲池
var buffers sync.Pool = sync.Pool{
   New: func() interface{} {
      return &bytes.Buffer{}
   },
}
func IntToStringMap(m map[string]int) (string, error) {
   // 2. 在需要的时候,从缓冲池中取出一个bytes.Buffer对象
   buf := buffers.Get().(*bytes.Buffer)
   buf.Reset()
   // 3. 用完之后,将其重新放入缓冲池中
   defer buffers.Put(buf)
   buf.Write([]byte("{"))
   for k, v := range m {
      buf.WriteString(fmt.Sprintf(`"%s":%d,`, k, v))
   }
   if len(m) > 0 {
      buf.Truncate(buf.Len() - 1) // 去掉最后一个逗号
   }
   buf.Write([]byte("}"))
   return buf.String(), nil
}

上面我们使用sync.Pool实现了一个bytes.Buffer的缓冲池,在 IntToStringMap 函数中,我们从 buffers 中获取一个 bytes.Buffer 对象,并在函数结束时将其放回池中,避免了频繁创建和销毁 bytes.Buffer 对象的开销。

同时,由于sync.PoolIntToStringMap调用不频繁的情况下,能够自动回收sync.Pool中的bytes.Buffer对象,无需用户操心,也能减小内存的压力。而且其底层实现也有考虑到多核cpu并发执行,每一个processor都会有其对应的本地缓存,在一定程度也减少了多线程加锁的开销。

从上面可以看出,sync.Pool使用起来非常简单,但是其还是存在一些注意事项,如果使用不当的话,还是有可能会导致内存泄漏等问题的,下面就来介绍sync.Pool使用时的注意事项。

4.使用注意事项

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

如果不注意放入sync.Pool缓冲池中对象的大小,可能出现sync.Pool中只存在几个对象,却占据了大量的内存,导致内存泄漏。

这里对于有固定大小的对象,并不需要太过注意放入sync.Pool中对象的大小,这种场景出现内存泄漏的可能性小之又小。但是,如果放入sync.Pool中的对象存在自动扩容的机制,如果不注意放入sync.Pool中对象的大小,此时将很有可能导致内存泄漏。下面来看一个例子:

func Sprintf(format string, a ...any) string {
   p := newPrinter()
   p.doPrintf(format, a)
   s := string(p.buf)
   p.free()
   return s
}

Sprintf方法根据传入的format和对应的参数,完成组装,返回对应的字符串结果。按照普通的思路,此时只需要申请一个byte数组,然后根据一定规则,将format参数的内容放入byte数组中,最终将byte数组转换为字符串返回即可。

按照上面这个思路我们发现,其实每次使用到的byte数组是可复用的,并不需要重复构建。

实际上Sprintf方法的实现也是如此,byte数组其实并非每次创建一个新的,而是会对其进行复用。其实现了一个pp结构体,format参数按照一定规则组装成字符串的职责,交付给pp结构体,同时byte数组作为pp结构体的成员变量。

然后将pp的实例放入sync.Pool当中,实现pp重复使用目的,从而简介避免了重复创建byte数组导致频繁的GC,同时也提升了性能。下面是newPrinter方法的逻辑,获取pp结构体,都是从sync.Pool中获取:

var ppFree = sync.Pool{
   New: func() any { return new(pp) },
}

// newPrinter allocates a new pp struct or grabs a cached one.
func newPrinter() *pp {
    // 从ppFree中获取pp
   p := ppFree.Get().(*pp)
   // 执行一些初始化逻辑
   p.panicking = false
   p.erroring = false
   p.wrapErrs = false
   p.fmt.init(&p.buf)
   return p
}

下面回到上面的byte数组,此时其作为pp结构体的一个成员变量,用于字符串格式化的中间结果,定义如下:

// Use simple []byte instead of bytes.Buffer to avoid large dependency.
type buffer []byte

type pp struct {
   buf buffer
   // 省略掉其他不相关的字段
}

这里看起来似乎没啥问题,但是其实是有可能存在内存浪费甚至内存泄漏的问题。假如此时存在一个非常长的字符串需要格式化,此时调用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当中,而是直接丢弃,具体如下:

func (p *pp) free() {
   // 如果byte数组的大小超过一定限度,此时将会直接返回
   if cap(p.buf) > 64<<10 {
      return
   }

   p.buf = p.buf[:0]
   p.arg = nil
   p.value = reflect.Value{}
   p.wrappedErr = nil
   
   // 否则,则重新放回sync.Pool当中
   ppFree.Put(p)
}

基于以上总结,如果sync.Pool中存储的对象占据的内存大小是不固定的话,此时需要注意放入对象的大小,防止内存泄漏或者内存浪费。

4.2 不要往sync.Pool中放入数据库连接/TCP连接

TCP连接和数据库连接等资源的获取和释放通常需要遵循一定的规范,比如需要在连接完成后显式地关闭连接等,这些规范是基于网络协议、数据库协议等规范而制定的,如果这些规范没有被正确遵守,就可能导致连接泄漏、连接池资源耗尽等问题。

当使用 sync.Pool 存储连接对象时,如果这些连接对象并没有显式的关闭,那么它们就会在内存中一直存在,直到进程结束。如果连接对象数量过多,那么这些未关闭的连接对象就会占用过多的内存资源,导致内存泄漏等问题。

举个例子,假设有一个对象Conn表示数据库连接,它的Close方法用于关闭连接。如果将Conn对象放入sync.Pool中,并在从池中取出并使用后没有手动调用Close方法归还对象,那么这些连接就会一直保持打开状态,直到程序退出或达到连接数限制等情况。这可能会导致资源耗尽或其他一些问题。

以下是一个简单的示例代码,使用 sync.Pool 存储TCP连接对象,演示了连接对象泄漏的情况:

import (
   "fmt"
   "net"
   "sync"
   "time"
)

var pool = &sync.Pool{
   New: func() interface{} {
      conn, err := net.Dial("tcp", "localhost:8000")
      if err != nil {
         panic(err)
      }
      return conn
   },
}

func main() {

   // 模拟使用连接
   for i := 0; i < 100; i++ {
      conn := pool.Get().(net.Conn)
      time.Sleep(100 * time.Millisecond)
      fmt.Fprintf(conn, "GET / HTTP/1.0\r\n\r\n")
      // 不关闭连接
      // 不在使用连接时,释放连接对象到池中即可
      pool.Put(conn)
   }

}

在上面的代码中,我们使用 net.Dial 创建了一个 TCP 连接,并将其存储到 sync.Pool 中。在模拟使用连接时,我们从池中获取连接对象,向服务器发送一个简单的 HTTP 请求,然后将连接对象释放到池中。但是,我们没有显式地关闭连接对象。如果连接对象的数量很大,那么这些未关闭的连接对象就会占用大量的内存资源,导致内存泄漏等问题。

因此,对于数据库连接或者TCP连接这种资源的释放需要遵循一定的规范,此时不应该使用sync.Pool来复用,可以自己实现数据库连接池等方式来实现连接的复用。

本文介绍了 Go 语言中的 sync.Pool原语,它是实现对象重复利用,降低程序GC频次,提高程序性能的一个非常好的工具。

我们首先通过一个简单的JSON序列化器的实现,引入了需要对象重复使用的场景,进而自己实现了一个缓冲池,由该缓冲池存在的问题,进而引出sync.Pool。接着,我们介绍了sync.Pool的基本使用以及将其应用到JSON序列化器的实现当中。

在接下来,介绍了sync.Pool常见的注意事项,如需要注意放入sync.Pool对象的大小,对其进行了分析,从而讲述了sync.Pool可能存在的一些注意事项,帮忙大家更好得对其进行使用。

基于以上内容,本文完成了对 sync.Pool的介绍,希望能够帮助大家更好地理解和使用Go语言中的sync.Pool


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK