sync包

data race

需要注意代码中得data race行为,这时候需要锁来控制,同时产生了读写,通常我们可以使用go test -race来查询竞争行为。

detecting race conditions with go

sync.Pool

1. 概要

Go 并发相关库 sync 里面有一个有趣的 package Pool,sync.Pool 是个有趣的库,用很少的代码实现了很巧的功能。第一眼看到 Pool 这个名字,就让人想到池子,元素池化是常用的性能优化的手段(性能优化的几把斧头:并发,预处理,缓存)。比如,创建一个 100 个元素的池,然后就可以在池子里面直接获取到元素,免去了申请和初始化的流程,大大提高了性能。释放元素也是直接丢回池子而免去了真正释放元素带来的开销。

但是再仔细一看 sync.Pool 的实现,发现比我预期的还更有趣。sync.Pool 除了最常见的池化提升性能的思路,最重要的是减少 GC 。常用于一些对象实例创建昂贵的场景。注意,Pool 是 Goroutine 并发安全的。

2. 使用姿势

2.1 初始化 Pool 实例 New

第一个步骤就是创建一个 Pool 实例,关键一点是配置 New 方法,声明 Pool 元素创建的方法。

1
2
3
4
5
6
bufferpool := &sync.Pool {
New: func() interface {} {
println("Create new instance")
return struct{}{}
}
}

2.2 申请对象 Get

buffer := bufferPool.Get()

Get 方法会返回 Pool 已经存在的对象,如果没有,那么就走慢路径,也就是调用初始化的时候定义的 New 方法(也就是最开始定义的初始化行为)来初始化一个对象。

2.3 释放对象 Put

bufferPool.Put(buffer)

使用对象之后,调用 Put 方法声明把对象放回池子。注意了,这个调用之后仅仅是把这个对象放回池子,池子里面的对象啥时候真正释放外界是不清楚的,是不受外部控制的

你看,Pool 的用户使用界面就这三个接口,非常简单,而且是通用型的 Pool 池模式,针对所有的对象类型都可以用。

3. 思考

3.1 为什么用 Pool,而不是在运行的时候直接实例化对象呢?

本质原因:Go 的内存释放是由 runtime 来自动处理的,有 GC 过程。

举个栗子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package main

import (
"fmt"
"sync"
"sync/atomic"
)

// 用来统计实例真正创建的次数
var numCalcsCreated int32

// 创建实例的函数
func createBuffer() interface{} {
// 这里要注意下,非常重要的一点。这里必须使用原子加,不然有并发问题;
atomic.AddInt32(&numCalcsCreated, 1)
buffer := make([]byte, 1024)
return &buffer
}

func main() {
// 创建实例
bufferPool := &sync.Pool{
New: createBuffer,
}

// 多 goroutine 并发测试
numWorkers := 1024 * 1024
var wg sync.WaitGroup
wg.Add(numWorkers)

for i := 0; i < numWorkers; i++ {
go func() {
defer wg.Done()
// 申请一个 buffer 实例
buffer := bufferPool.Get()
_ = buffer.(*[]byte)
// 释放一个 buffer 实例
defer bufferPool.Put(buffer)
}()
}
wg.Wait()
fmt.Printf("%d buffer objects were created.\n", numCalcsCreated)
}

上面的例子可以直接复制运行起来看下,控制台输出:

1
2
3
4
➜  pool# go run test_pool.go        
3 buffer objects were created.
➜ pool# go run test_pool.go
4 buffer objects were created.

程序 go run 运行了两次,一次结果是 3 ,一次是 4 。这个是什么原因呢?

首先,这个是正常的情况,不知道你有没有注意到,创建 Pool 实例的时候,只要求填充了 New 函数,而根本没有声明或者限制这个 Pool 的大小。所以,记住一点,程序员作为使用方不能对 Pool 里面的元素个数做假定

再来,如果我不用 Pool 来申请实例,而是直接申请,也就是上面的代码只改一行:

将以下代码:

1
2
// 申请一个 buffer 实例
buffer := bufferPool.Get()

修改成:

1
2
// 申请一个 buffer 实例
buffer := createBuffer()

这个时候,我们再执行程序 go run test_pool.go,会发现什么?

1
2
3
4
➜  pool go run test_pool_1.go
1048576 buffer objects were created.
➜ pool go run test_pool_1.go
1048576 buffer objects were created.

注意到,和之前有两个不同点

  1. 同样也是运行两次,两次结果相同。
  2. 对象创建的数量和并发 Worker 数量相同,数量等于 1048576 (这个就是 1024*1024);

原因很简单,因为每次都是直接调用 createBuffer 函数申请 buffer,有 1048576 个并发 Worker 调用,所以跑多少次结果都会是 1048576。

实际上还有一个不同点,就是程序跑的过程中,该进程分配消耗的内存很大。因为 Go 申请内存是程序员触发的,回收却是 Go 内部 runtime GC 回收器来执行的,这是一个异步的操作。这种业务不负责任的内存使用会对 GC 带来非常大的负担,进而影响整体程序的性能。

3.2 sync.Pool 是并发安全的吗?

sync.Pool 当然是并发安全的。官方文档里明确说了:

A Pool is safe for use by multiple goroutines simultaneously.

但是,为什么我这里会单独提出来呢?

因为 sync.Pool 只是本身的 Pool 数据结构是并发安全的,并不是说 Pool.New 函数一定是线程安全的。**Pool.New** 函数可能会被并发调用 ,如果 New 函数里面的实现是非并发安全的,那就会有问题。

细心的小伙伴会注意到我在上面的代码例子里,关于 createBuffer 函数的实现里,对于 numCalcsCreated 的计数加是用原子操作的:atomic.AddInt32(&numCalcsCreated, 1)

1
2
3
4
5
6
func createBuffer() interface{} {
// 这里要注意下,非常重要的一点。这里必须使用原子加,不然有并发问题;
atomic.AddInt32(&numCalcsCreated, 1)
buffer := make([]byte, 1024)
return &buffer
}

因为 numCalcsCreated 是个全局变量,Pool.New( 也就是 createBuffer ) 并发调用的时候,会导致 data race ,所以只有用原子操作才能保证数据的正确性。

小伙伴们可以尝试下,把 atomic.AddInt32(&numCalcsCreated, 1) 这样代码改成 numCalcsCreated++ ,然后用 go run -race test_pool.go 命令检查一下,肯定会报告告警的,类似如下:

1
2
3
4
5
6
7
8
9
10
11
WARNING: DATA RACE
Read at 0x000001287538 by goroutine 10:

Previous write at 0x000001287538 by goroutine 7:

==================
==================
WARNING: DATA RACE
Read at 0x000001287538 by goroutine 9:
main.createBuffer()

本质原因:Pool.New 函数可能会被并发调用。

3.3 为什么 sync.Pool 不适合用于像 socket 长连接或数据库连接池?

因为,我们不能对 sync.Pool 中保存的元素做任何假设,以下事情是都可以发生的:

  1. Pool 池里的元素随时可能释放掉,释放策略完全由 runtime 内部管理;
  2. Get 获取到的元素对象可能是刚创建的,也可能是之前创建好 cache 住的。使用者无法区分;
  3. Pool 池里面的元素个数你无法知道;

所以,只有的你的场景满足以上的假定,才能正确的使用 Pool 。sync.Pool 本质用途是增加临时对象的重用率,减少 GC 负担。划重点:临时对象。所以说,像 socket 这种带状态的,长期有效的资源是不适合 Pool 的。

4. 总结

  1. sync.Pool 本质用途是增加临时对象的重用率,减少 GC 负担;
  2. 不能对 Pool.Get 出来的对象做预判,有可能是新的(新分配的),有可能是旧的(之前人用过,然后 Put 进去的);
  3. 不能对 Pool 池里的元素个数做假定,你不能够;
  4. sync.Pool 本身的 Get, Put 调用是并发安全的,sync.New 指向的初始化函数会并发调用,里面安不安全只有自己知道;
  5. 当用完一个从 Pool 取出的实例时候,一定要记得调用 Put,否则 Pool 无法复用这个实例,通常这个用 defer 完成;

sync包
https://www.zengzx.xyz/2022/10/08/01.知识架构/01.Go/01.基础/11.sync/
作者
Eden
发布于
2022年10月8日
许可协议