3

我与sync.Once的爱恨纠缠

 3 years ago
source link: https://blog.thinkeridea.com/202101/go/exsync/once.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.

官方描述 Once is an object that will perform exactly one action , 即 Once 是一个对象,它提供了保证某个动作只被执行一次功能,最典型的场景就是单例模式, Once 可用于任何符合 “exactly once” 语义的场景。

sync.Once 的用法

在多数情况下, sync.Once 被用于控制变量的初始化,这个变量的读写通常遵循单例模式,满足这三个条件:

  • 当且仅当第一次读某个变量时,进行初始化(写操作)
  • 变量被初始化过程中,所有读都被阻塞(读操作;当变量初始化完成后,读操作继续进行)
  • 变量仅初始化一次,初始化完成后驻留在内存里

在标准库中不乏有大量 sync.Once 的使用案例,在 strings 包中 replace.go 里实现字符串批量替换功能时,需要预编译生成替换规则,即采用不同的替换算法并创建相关算法实例,因 strings.Replacer 实现是线程安全且支持规则复用,在第一次解析替换规则并创建对应算法实例后,可以并发的进行字符串替换操作,避免多次解析替换规则浪费资源。

先看一下 strings.Replacer 的结构定义:

// source: strings/replace.go
type Replacer struct {
	once   sync.Once // guards buildOnce method
	r      replacer
	oldnew []string
}

这里定义了 once sync.Once 用来控制 r replacer 替换算法初始化,当我们使用 strings.NewReplacer 创建 strings.Replacer 时,这里采用惰性算法,并没有在这时进行 build 解析替换规则并创建对应算法实例,而是在执行替换时( Replacer.ReplaceReplacer.WriteString )进行的, r.once.Do(r.buildOnce) 使用 sync.OnceDo 方法保证只有在首次执行时才会执行 buildOnce 方法,而在 buildOnce 中调用 build 解析替换规则并创建对应算法实例,在 buildOnce 中进行赋值。

// source: strings/replace.go
func NewReplacer(oldnew ...string) *Replacer {
	if len(oldnew)%2 == 1 {
		panic("strings.NewReplacer: odd argument count")
	}
	return &Replacer{oldnew: append([]string(nil), oldnew...)}
}

func (r *Replacer) buildOnce() {
	r.r = r.build()
	r.oldnew = nil
}

func (b *Replacer) build() replacer {
    ....
}

func (r *Replacer) Replace(s string) string {
	r.once.Do(r.buildOnce)
	return r.r.Replace(s)
}

func (r *Replacer) WriteString(w io.Writer, s string) (n int, err error) {
	r.once.Do(r.buildOnce)
	return r.r.WriteString(w, s)
}

简单来说, once.Do 中的函数只会执行一次,并保证 once.Do 返回时,传入 Do 的函数已经执行完成。多个 goroutine 同时执行 once.Do 的时候,可以保证抢占到 once.Do 执行权的 goroutine 执行完 once.Do 后,其他 goroutine 才能得到返回。

once.Do 接收一个函数作为参数,该函数不接受任何参数,不返回任何参数。具体做什么由使用方决定,错误处理也由使用方控制,对函数初始化的结果也由使用方进行保存。

这给出了一种错误处理的例子 exec.closeOnceexec.closeOnce 保证了重复关闭文件,永远只执行一次,并且总是返回首次关闭产生的错误信息:

// source: os/exec/exec.go
type closeOnce struct {
	*os.File

	once sync.Once
	err  error
}

func (c *closeOnce) Close() error {
	c.once.Do(c.close)
	return c.err
}

func (c *closeOnce) close() {
	c.err = c.File.Close()
}

对 sync.Once 的爱与恨

Once 的实现非常的灵活、简洁、高效,排除注释部分 Once 仅用 17 行实现,且单次执行时间在 0.3ns 左右。这让我十分敬佩,对它可谓喜爱至极,但因为它的通用性,在使用 Once 时给我带来了一些小小的负担,这也成了我极少的使用它的原因。

Once 只保证调用安全性(即线程安全以及只执行一次动作函数),但是细心的朋友一定发现了我们往往需要配对定义 Once 和业务实例变量,极少使用的情况下(如上述两个例子)看起来并没有什么负担,但是如果我们项目中有大量实例进行管理时(一般是集中管理,便于解决依赖问题),这时就会变得有点丑陋。

一个实际的业务场景,我有一个 http 服务,它有数百个组件实例,我们创建了一个 APP 用来管理所有实例的初始化、依赖关系,从而保证各个组件依赖其接口,相互之间进行解耦,也使得每个组件的配置(初始化参数)、依赖易于管理,不过我们常常对单例实例在 http 服务启动时进行初始化,这样避免使用 Once ,且可以在 http 服务启动时暴露外部依赖问题(数据库、其它服务等)。

这个 http 服务需要很多辅助命令,每个命令负责极少的工作,如果我在命令启动时使用 APP 初始化所有组件,这造成了大量的资源浪费。我单独实现一个 Command 依赖管理组件,它大量使用 Once 保证各个组件只在第一次使用时进行初始化,这给我带来了一些困扰,我大量定义 Once 的实例,且它和具体的组件实例没有关联,我在使用时需要非常的小心。

使用过 go-extend/pool 中的 pool.BufferPool 的朋友如果留意其源码的话会发现其中定义了一些 sync.Once 的实例,这相对上诉场景却是相对少的,以下便是 pool.BufferPool 中的部分代码:

// source: https://github.com/thinkeridea/go-extend/blob/v1.1.2/pool/buffer.go

package pool

import (
	"bytes"
	"sync"
)

var (
	buff64   *sync.Pool
	buff128  *sync.Pool
	buff512  *sync.Pool
	buff1024 *sync.Pool
	buff2048 *sync.Pool
	buff4096 *sync.Pool
	buff8192 *sync.Pool

	buff64One   sync.Once
	buff128One  sync.Once
	buff512One  sync.Once
	buff1024One sync.Once
	buff2048One sync.Once
	buff4096One sync.Once
	buff8192One sync.Once
)

type pool sync.Pool

// BufferPool bytes.Buffer 的 sync.Pool 接口
// 可以直接 Get *bytes.Buffer 并 Reset Buffer
type BufferPool interface {

	// Get 从 Pool 中获取一个 *bytes.Buffer 实例, 该实例已经被 Reset
	Get() *bytes.Buffer
	// Put 把 *bytes.Buffer 放回 Pool 中
	Put(*bytes.Buffer)
}

func newBufferPool(size int) *sync.Pool {
	return &sync.Pool{
		New: func() interface{} {
			return bytes.NewBuffer(make([]byte, size))
		},
	}
}

// GetBuff64 获取一个初始容量为 64 的 *bytes.Buffer Pool
func GetBuff64() BufferPool {
	buff64One.Do(func() {
		buff64 = newBufferPool(64)
	})

	return (*pool)(buff64)
}

上诉代码中定义了 buff64Onebuff8192One 7个 Once 的实例,且对应的存在 buff64buff8192 的业务实例,我在 GetBuff64 中必须小心使用 Once 实例,避免错误使用导致对应的实例未被初始化,而且上诉的代码看起来还有一些丑陋。

探寻缓和与 sync.Once 的尴尬

鉴于我对 sync.Once 灵活、简洁、高效的喜爱,不能仅仅因为它的“吝啬”(极简的功能)便与之诀别,促使我开启了探寻缓和与 sync.Once 关系之路。

首先我想到的是对 sync.Once 的二次包装,使其可以保存一个数据,这样我就可以只定义 Once 的实例,由 Once 负责存储初始化的结果。 exsync.Once 这是我的第一个实验,它的实现非常简洁:

// source: https://github.com/thinkeridea/go-extend/blob/efa13c9456cb4ce97c16824de2996c84fa285fc3/exsync/once.go
type Once struct {
	once sync.Once
	v    interface{}
}

func (o *Once) Do(f func() interface{}) interface{} {
	o.once.Do(func() {
		o.v = f()
	})

	return o.v
}

它嵌套一个 sync.Once 实例,并覆盖其 Do 函数,使其接收一个 func() interface{} 函数,它要求初始化函数返回其结果,结果保存在 Once.v ,每次调用 Do 它便返回自己保存的结果,这使用起来就变得简单许多,改造之前 exec.closeOnce 例子:

type closeOnce struct {
	*os.File

	once exsync.Once
}

func (c *closeOnce) Close() error {
	return c.once.Do(c.close).(error)
}

func (c *closeOnce) close() interface{} {
	return c.File.Close()
}

这减少了一个业务层的数据定义,如果包含多个数据,可以使用自定义 struct 或者 []interface{} 进行数据保存, 一个简单打开文件的例子:

type openOnce struct {
	file exsync.Once
}

func (c *openOnce) Open(name string) (*os.File, error) {
	f := c.file.Do(func() interface{} {
		f, err := os.Open(name)
		return []interface{}{f, err}
	}).([]interface{})

	return f[0].(*os.File), f[1].(error)
}

这看起来使初始化的代码变得复杂了一些,对多返回值的问题暂时没有更好的实现,我会在后续逐渐考虑这类问题的处理方式,单个值时它使我得到一些惊喜和便捷。即使这样我随后发现它相对 sync.Once 的性能大幅度下降,达到10倍之多,起初我认为是 interface 的带来的,我立刻实现了一个 exsync.OncePointer 以期许它可以在性能上给我一个惊喜:

// source: https://github.com/thinkeridea/go-extend/blob/efa13c9456cb4ce97c16824de2996c84fa285fc3/exsync/once.go
type OncePointer struct {
	once sync.Once
	v    unsafe.Pointer
}

func (o *OncePointer) Do(f func() unsafe.Pointer) unsafe.Pointer {
	o.once.Do(func() {
		o.v = f()
	})

	return o.v
}

使用 unsafe.Pointer 存储实例,让其在编译时确定类型,来提升其性能,使用示例如下:

type closeOnce struct {
	*os.File

	once exsync.OncePointer
}

func (c *closeOnce) Close() error {
	return *(*error)(c.once.Do(c.close))
}

func (c *closeOnce) close() unsafe.Pointer {
	err := c.File.Close()
	return unsafe.Pointer(&err)
}

尴尬的是这并没有使其性能有极大提升,仅仅只是稍微提升一些,难道我要和 sync.Once 就此诀别,还是凑合过……

转机的到来

我本已放弃优化,即使其性能极大下降,但是它仍然可以在 3ns 内完成任务,这并不会形成瓶颈。但多少内心还是有些不甘,仅仅只是包装使其保存一个值不应该导致性能下降如此严重,究竟是什么导致其性能如此严重下降的,仔细做了分析发现由于 sync.Once 非常的高效,且代码简洁,我嵌套包装使其多了一层调用,且可能导致其无法内联,这对一些性能不高的组件影响极小,但是像 sync.Once 这样高效任何小小的损耗表现都十分明显。

我直接拷贝 sync.Once 中的代码到 exsync.Onceexsync.OncePointer 实现中,这让我得到与 sync.Once 接近的性能, exsync.OncePointer 的实现甚至总是好于 sync.Once

以下是性能测试的结果,其代码位于 exsync/benchmark/once_test.go :

goos: darwin
goarch: amd64
pkg: github.com/thinkeridea/go-extend/exsync/benchmark
BenchmarkSyncOnce-8      	1000000000	         0.391 ns/op	       0 B/op	       0 allocs/op
BenchmarkOnce-8          	1000000000	         0.407 ns/op	       0 B/op	       0 allocs/op
BenchmarkOncePointer-8   	1000000000	         0.389 ns/op	       0 B/op	       0 allocs/op
PASS
ok  	github.com/thinkeridea/go-extend/exsync/benchmark	1.438s

得到这个结果后我毫不犹豫、马不停蹄的改变了 pool.BufferPool 中的代码,这使 pool.BufferPool 变得简洁许多:

package pool

import (
	"bytes"
	"sync"
	"unsafe"

	"github.com/thinkeridea/go-extend/exsync"
)

var (
	buff64   exsync.OncePointer
	buff128  exsync.OncePointer
	buff512  exsync.OncePointer
	buff1024 exsync.OncePointer
	buff2048 exsync.OncePointer
	buff4096 exsync.OncePointer
	buff8192 exsync.OncePointer
)

type bufferPool struct {
	sync.Pool
}

// BufferPool bytes.Buffer 的 sync.Pool 接口
// 可以直接 Get *bytes.Buffer 并 Reset Buffer
type BufferPool interface {

	// Get 从 Pool 中获取一个 *bytes.Buffer 实例, 该实例已经被 Reset
	Get() *bytes.Buffer
	// Put 把 *bytes.Buffer 放回 Pool 中
	Put(*bytes.Buffer)
}

func newBufferPool(size int) unsafe.Pointer {
	return unsafe.Pointer(&bufferPool{
		Pool: sync.Pool{
			New: func() interface{} {
				return bytes.NewBuffer(make([]byte, size))
			},
		},
	})
}

// GetBuff64 获取一个初始容量为 64 的 *bytes.Buffer Pool
func GetBuff64() BufferPool {
	return (*bufferPool)(buff64.Do(func() unsafe.Pointer {
		return newBufferPool(64)
	}))
}

总结

如此对 sync.Once 进行二次封装,使其通用性有所下降,并一定是一个好的方案,我乐于公开它,因为它在大多数时刻可以减少使用者的负担,使得代码变的简练。

后续的思考:

Once
Do

解决以上这些问题,可以使 sync.Once 应用在更多的场景中,但势必导致其性能有所下降,这需要一些实验和折中处理。

谢谢你请我吃糖果

VzmYj2R.jpg!mobile支付宝

uaMJf2b.jpg!mobile微信


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK