借一次线上BUG学习Golang内存模型
线上代码BUG回顾:
主要是 config 配置文件(公共配置版,很多包都会去Init一下),利用全局变量进行替换加载,但是没加锁进行同步控制导致 config 里面对应的值读取有一定概率为 空值;
直接说下代码表现形式吧,直接上demo代码不废话:
下面这段demo代码是一个config的基础配置组件包,每个项目可以自己定义一个项目独有的config,但是绝大部分都会在内嵌该基础配置包;
var c *Config
type Config struct {
// 一大堆属性嵌套配置
// ......
}
// Get 获取配置 外部通过调用该方法直接获取全局变量c
func Get() *Config {
return c
}
// Init 初始化
func (config *Config) Init(opts ...Option) error {
// 开始各种给 config 赋值
// 然后最终给了一个全局变量c
c = config
return nil
}
外部程序加载调用上面的包代码:
type Config struct {
ginConfig.Config // 直接嵌套上面的结构体Config
}
// 调用方式如下:
config = &Config{FlagDisable: true}
if err := config.Init(); err != nil { // 这里调用的就是上面的 Init 初始化 函数
return err
}
以上就是部分代码初始化和调用过程。其实有经验的 gopher 一眼可以看出,一个全局变量在进行Init() 函数初始化时(注意它这里并木有走go自带的默认加载 init() 方式,它是自定义的大写Init函数手动调用加载)对其进行赋值写操作,它这里并没有任何类似任何锁的操作。
如果整个程序你的所有服务都是显示的都只在 main 函数中加载那么一次,通过显示传递到要使用config 的函数中就可能不会发生读写竞争的情况。但是我司一些基础项目结构包封装并没有这样搞,而是隐式操作,直接在想加载初始化配置文件的任何地方进行类似 config.Init() 操作。
比如一个业务数据 Dao 层的初始化需要获取对应该业务独有的 config 包,你只有去调一下它的 Init() 函数,不然势必会报 空指针 panic,结果就是如上面所说的那样,这个 Init() 函数中也加载了一次 config 的基础配置组件包。类似这种无感知的封装操作,多处都在写入 config 操作同一个全局变量,又不加锁,情况可想而知!
它就是可能会读出空值(至于为什么,请往下看)。直接导致本次操作接口无效报错。
这种错误原因如下:
- 对 golang 本身的内存模型不够熟知
- 对 golang 项目组织参数传递,包组件的应用配置设计的不合理
正确建议:
- 消除全局变量,显示传入任何需要用到的config的地方,让调用者明确清楚需要的数据源。
- 实在有全局变量,如果程序中修改数据时有其他goroutine同时读取,那么必须将读取串行化,为了串行化访问,请使用channel或其他同步原语,例如sync和sync/atomic来保护数据。
- 对Go语言内存模型加深学习;正文开始如下:
内存模式
基本概念:
The Go memory model specifies the conditions under which reads of a variable in one goroutine can be guaranteed to observe values produced by writes to the same variable in a different goroutine.
Go 内存模型指定了在何种条件下可以保证在一个 goroutine 中能正确读取到由其他 goroutine 进行写操作某相同变量所产生的值。
核心思想把控: Happens Before:先行发生;
To specify the requirements of reads and writes, we define happens before, a partial order on the execution of memory operations in a Go program. If event *e1* happens before event e2, then we say that *e2* happens after e1. Also, if *e1* does not happen before *e2* and does not happen after e2, then we say that *e1* and *e2* happen concurrently.
这里的这段概念你去看原文后发现说了个啥,比较晦涩,这端话之前原文还提到 :
Within a single goroutine, reads and writes must behave as if they executed in the order specified by the program. That is, compilers and processors may reorder the reads and writes executed within a single goroutine only when the reordering does not change the behavior within that goroutine as defined by the language specification. Because of this reordering, the execution order observed by one goroutine may differ from the order perceived by another.
在一个 goroutine 中读写的行为势必是按照指定好的顺序去执行, 而编译器和处理器会让你指定的这种顺序进行重排 reorder;
For example, if one goroutine executes
a = 1; b = 2;
, another might observe the updated value ofb
before the updated value ofa
.比如在一个g中写入了
a = 1; b = 2;
,但在另外一个g中读取时可能看到b
在a
之前更新,即结果可能是 a = 0; b =2; 而不是你想的 要么都没读取到即都为0,要么都读取完a=1;b=2的情况。看看几个demo其实就很好理解了:
package main import "time" var a, b int func f() { a = 1 b = 2 } func g() { time.Sleep(1 * time.Microsecond) print(b) print(a) } func main() { go f() g() }
输出结果可能如下:
- 21
- 20
- 01
- 00
To specify the requirements of reads and writes, we define happens before, a partial order on the execution of memory operations in a Go program. If event *e1* happens before event e2, then we say that *e2* happens after e1. Also, if *e1* does not happen before *e2* and does not happen after e2, then we say that *e1* and *e2* happen concurrently.
再来回顾下happens-before:
如果满足下面条件,对变量v的读操作r可以侦测到对变量v的写操作w:
- r does not happen before w.
- There is no other write w to v that happens after w but before r.
为了保证对变量v的读操作r可以侦测到某个对v的写操作w,必须确保w是r可以侦测到的唯一的写操作。
所有结合文章开头说的BUG现象,代码中的全局变量 c, 要想再config.Get()读取时不为空值(即为侦测到某个对c的写操作w),那必须确保对 c 的写操作是对于读取该变量时的唯一写操作。
运用工具函数包等:
sync包中比如 锁操作、once、atomic包等,包括利用 channel,来通过通讯来共享变量,而不是共享变量来进行通讯。
想深入了解可以看下面 参考文章,尤其是官方原版 ,建议读5遍!
参考
- The Go Memory Model(英文原版,反复读五遍)
- 中文翻译版
- 民间解说版本