原文链接: https://blog.thinkeridea.com/202101/go/exsync/once.html

官方描述 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 应用在更多的场景中,但势必导致其性能有所下降,这需要一些实验和折中处理。

转载:

本文作者: 戚银(thinkeridea

本文链接: https://blog.thinkeridea.com/202101/go/exsync/once.html

版权声明: 本博客所有文章除特别声明外,均采用 CC BY 4.0 CN协议 许可协议。转载请注明出处!

【Go】我与sync.Once的爱恨纠缠的更多相关文章

  1. PHP中var_dump、&&和GLOBALS的爱恨纠缠

    var_dump函数:用来打印显示一个变量的内容与结构: &&:定义一个可变变量.php中,在定义变量时,需要在前面加上一个“&”符号,当加上两个“&&”符号时 ...

  2. Menu与ActionBar的爱恨情仇

    最近在开发一款音乐播放器,在开发过程中遇到了一点小麻烦,通过android API搞清楚了Menu与ActionBar的爱恨情仇,写了个小Demo祭奠一下那些年我们陷进去的坑,有不对的地方请大神们批评 ...

  3. web移动端fixed布局和input等表单的爱恨情仇 - 终极BUG,完美解决

    [问题]移动端开发,ios下当fixed属性和输入框input(这里不限于input,只要可以调用移动端输入法的都包括,如:textarea.HTML5中contenteditable等),同时存在的 ...

  4. CSS中a标签样式的“爱恨”原则

    CSS为一些特殊效果准备了特定的工具,我们称之为“伪类”.其中有几项是我们经常用到的,下面我们就详细介绍一下经常用于定义链接样式的四个伪类,它们分别是: 1 :link 2 :visited 3 :h ...

  5. 注解:大话AOP与Android的爱恨情仇

    转载:大话AOP与Android的爱恨情仇 1. AOP与OOP的区别 平时我接触多的就是OOP(Object Oriented Programming面向对象).AOP(Aspect Oriente ...

  6. 除了love和hate,还能怎么表达那些年的“爱恨情仇”?

    实用英语 帮你全面提高英语水平 关注 童鞋们每次刷美剧的时候,相信都会被CP感满满的男女主角虐得体无完肤吧. 可是,一到我们自己表达爱意或者恨意的时候,却苦于词穷,只会用love, like, hat ...

  7. 对json的爱恨情仇

    本文回想了对json的爱恨情仇. C++有风险,使用需慎重. 本文相关代码在:http://download.csdn.net/detail/baihacker/7862785 当中的測试数据不在里面 ...

  8. String、StringBuilder、StringBuffer的爱恨情仇

    第三阶段 JAVA常见对象的学习 StringBuffer和StringBuilder类 (一) StringBuffer类的概述 (1) 基本概述 下文以StringBuffer为例 前面我们用字符 ...

  9. [转帖]探秘华为(一):华为和H3C(华三)的爱恨情仇史!

    探秘华为(一):华为和H3C(华三)的爱恨情仇史! https://baijiahao.baidu.com/s?id=1620703498823290828&wfr=spider&fo ...

随机推荐

  1. 使用KVM的API编写一个简易的AArch64虚拟机

    参考资料: Linux虚拟化KVM-Qemu分析(一) Linux虚拟化KVM-Qemu分析(二)之ARMv8虚拟化 Linux虚拟化KVM-Qemu分析(三)之KVM源码(1) Linux虚拟化KV ...

  2. 安装rose遇到的问题

    将rose文件夹里面的rose.rar直接解压到rose安装目录里面的common文件夹中 安装教程与问题解决连接https://blog.csdn.net/Farewell_w/article/de ...

  3. 第十九章、Model/View开发:QTableView的功能及属性

    老猿Python博文目录 专栏:使用PyQt开发图形界面Python应用 老猿Python博客地址 一.概述 在Designer的部件栏Item Views中提供了PyQt和Qt已经实现好的table ...

  4. 第13.3节 图形界面开发tkinter

    一. 引言 老猿最开始是准备就tkinter单独开一个章节,但学了一段时间tkinter,最后放弃了,前一阵子还准备干脆不介绍相关的内容.主要原因有三个,一是tkinter没有界面设计的工具,所有界面 ...

  5. PyQt学习随笔:ListView控件的视图和数据模型分离案例

    Qt 中view类控件的目的是实现数据和模型分离,控件展示数据,数据保存在数据存储中,数据存储中的数据改变了,则控件中展示的数据跟随改变.当设计时只指定了一个控件和一个数据存储关联时,这种分离虽然也能 ...

  6. Libp2p 简介

    这是一个翻译的系列文章,原文参考:Introduction :: libp2p Documentation 欢迎来阅读libp2p相关文档,不论你是刚开始学习如何用libp2p来搭建P2P系统, 还是 ...

  7. qtp学习入门

    qtp的学习,初始入门是简单的,推荐田艳琴的<QTP从实践到精通>这边书,看过后,一周就可以入门,并能够自行编写脚本,但是想要进入更深一层,则需要更广阔的知识!这条路任重道远,你我共勉

  8. 【APIO2019】桥梁(询问分块)

    Description 给定一张 \(n\) 个点,\(m\) 条边的无向图,边 \(i\) 的权值为 \(d_i\).现有 \(q\) 次操作,第 \(j\) 个操作有两种模式: \(1\ b_j\ ...

  9. JAVA死锁的检测流程

    步骤一. 查询检测的进程 1.首先查看系统资源占用信息,TOP看一下 发现正在运行的JAVA项目CPU占用率很高,百分之360左右了,那么问题一定出在这个程序中 2 .也可以通过名称查询进程pid 步 ...

  10. js实现弹幕

    弹幕是一个很常见的功能,下面是本人封装的一个小小的实现方案,存在不足之处可以提出来或自由改进. 直接上代码:复制可运行 <!DOCTYPE html> <html> <h ...