标签 init 下的文章

Go 1.22引入的包级变量初始化次序问题

本文永久链接 – https://tonybai.com/2024/03/29/the-issue-in-pkg-level-var-init-order-in-go-1-22

细心的朋友可能已经注意到,从春节后,我的博客就“停更”了!实际上,这一情况部分是因为工作上的事务繁忙,另一部分则是因为我将工作之外的闲暇时间更多地投入到一本即将于今年中下旬出版的书的撰写了:在之前的积累基础上,我花了两个多月的时间完成了初稿。

当然,我也深切地怀念博客写作所带来的乐趣和与读者的互动。正巧,今天一位学员在《Go语言第一课》专栏留言给了我一个恢复下笔的机会。借此,我也准备恢复一下博客写作的节奏。

另外预告一下:我和我的技术团队合作翻译的一本Go语言入门书最早也将于2024年4月份上市,敬请期待


《Go语言第一课》专栏第8讲中,我曾系统讲解了Go包的初始化次序,以及Go包内包级变量、常量、init函数等的初始化次序。讲这些的初衷就是希望Go初学者能先了解一下Go程序的执行次序,这样在后续阅读和理解Go代码的时候,就好比拥有了“通往宝藏的地图”,可以直接沿着Go代码执行次序这张“地图”去阅读和理解Go代码,而不会在庞大的代码库中迷失了。

相对于早期的Go版本,Go包的初始化次序在Go 1.21版本开始会有所变化,这个可以看我的《Go 1.21中值得关注的几个变化》一文了解详情。

不过除了Go包的初始化次序得以明确之外,Go在1.22版本中的包级变量初始化次序也发生了一些“变化”,但Go 1.22的Release Notes压根没提到Go包内的变量初始化次序会有变化。究竟这些变化是有意为之,还是由于代码变更而引入的新问题呢?我们还得从近期《Go语言第一课》专栏的一位读者提出的问题讲起!

1. Go 1.22的输出结果与专栏文章中不同!

原专栏中的代码较多,为方便起见我又写了一段简化版的代码,可以等价地反映问题。下面的代码用于演示包级变量、常量和init函数的初始化次序:

// initorder.go
package main

import (
    "fmt"
)

var (
    v0 = constInitCheck()
    v1 = variableInit("v1")
    v2 = variableInit("v2")
)

const (
    c1 = "c1"
    c2 = "c2"
)

func constInitCheck() string {
    if c1 != "" {
        fmt.Println("main: const c1 has been initialized")
    }
    if c1 != "" {
        fmt.Println("main: const c2 has been initialized")
    }
    return ""
}

func variableInit(name string) string {
    fmt.Printf("main: var %s has been initialized\n", name)
    return name
}

func init() {
    fmt.Println("main: first init func invoked")
}

func init() {
    fmt.Println("main: second init func invoked")
}

func main() {
    // do nothing
}

使用Go 1.22版本之前的版本,比如Go 1.21版本,运行该程序的输出结果如下:

$go run initorder.go
main: const c1 has been initialized
main: const c2 has been initialized
main: var v1 has been initialized
main: var v2 has been initialized
main: first init func invoked
main: second init func invoked

这个输出结果也是专栏文章中的输出结果,即包级元素的初始化顺序是:常量 -> 变量 -> init函数。三个变量的初始化次序是v0 -> v1 -> v2。

但专栏的一位读者在使用最新Go 1.22版本运行上述程序后,却提出了如下问题:

总结一下这个问题的两个关键点如下:

  • Go 1.22版本运行上述程序的输出结果与文章中的结果不一致
  • 将const声明block搬移到var声明block的前面后,使用Go 1.22版本的输出结果与文章中的一致

我们先来复现一下问题。我使用Go 1.22.0运行上面的initorder.go,得到下面结果:

$go run main.go
main: var v1 has been initialized
main: var v2 has been initialized
main: const c1 has been initialized
main: const c2 has been initialized
main: first init func invoked
main: second init func invoked

该输出结果确如读者所说,与文中的输出顺序不一致了,变量的初始化次序变为了v1 -> v2 -> v0。这会让很多读者误以为包内元素的初始化次序变成了“变量 -> 常量 -> init函数”。是否真的如此了呢?我们下面来初步分析一下。

2. 原因初步分析

Go语言规范中对包内变量初始化次序的说明是这样的(截至2024.03):

Within a package, package-level variable initialization proceeds stepwise, with each step selecting the variable earliest in declaration order which has no dependencies on uninitialized variables. More precisely, a package-level variable is considered ready for initialization if it is not yet initialized and either has no initialization expression or its initialization expression has no dependencies on uninitialized variables. Initialization proceeds by repeatedly initializing the next package-level variable that is earliest in declaration order and ready for initialization, until there are no variables ready for initialization. Multiple variables on the left-hand side of a variable declaration initialized by single (multi-valued) expression on the right-hand side are initialized together: If any of the variables on the left-hand side is initialized, all those variables are initialized in the same step. For the purpose of package initialization, blank variables are treated like any other variables in declarations.

粗略翻译后大致意思如下:

在包内,包级变量初始化逐步进行,每一步都会选择声明顺序中最早的且不依赖于未初始化变量的那个变量。更准确地说,如果包级变量尚未初始化并且没有初始化表达式或其初始化表达式不依赖于未初始化的变量,则认为该变量具备初始化条件。通过重复初始化声明顺序中最早且具备初始化条件的下一个包级变量来进行初始化,直到没有具备初始化条件的变量为止。由右侧单个(多值)表达式初始化的变量声明左侧的多个变量会一起初始化:如果左侧的任何变量被初始化,则所有这些变量都会被初始化在同一步骤中。出于包初始化的目的,空变量也被视为与声明中的任何其他变量一样。

按照Go语言规范的描述,我们来理论推导一下v0、v1和v2的初始化次序:

var (
    v0 = constInitCheck()
    v1 = variableInit("v1")
    v2 = variableInit("v2")
)
  • 第一轮:待初始化的包级变量集合{v0, v1, v2}。在这一轮,我们按声明顺序逐一看一下这三个变量。

v0未初始化,其声明语句的右侧有初始化表达式(initialization expression),且这个初始化表达式式(constInitCheck)不依赖未初始化的变量(仅仅依赖两个常量c1和c2),因此按照Spec描述,v0具备初始化条件,会先进行初始化,于是constInitCheck会被调用。

  • 第二轮:待初始化的包级变量集合{v1, v2}。

按声明顺序,先看v1。和v0一样,其声明语句的右侧有初始化表达式,且这个初始化表达式式(variableInit)不依赖未初始化的变量,因此按照Spec描述,v1具备初始化条件,会进行初始化,于是variableInit会被调用。

  • 第三轮:待初始化的包级变量集合{v2}。

这个没啥可推导的了,初始化v2就是了!

这样,包级变量的声明次序就应该是v0 -> v1 -> v2。这个理论推导结果显然与Go 1.22版之前的输出结果是一致的。但与Go 1.22版本的输出结果有悖。

那么Go 1.22版本为什么没有将v0作为第一个具备初始化条件的变量对其进行初始化呢?v0有初始化表达式constInitCheck,该函数没有依赖任何未初始化的包级变量,但该函数内部依赖了两个常量c1和c2:

func constInitCheck() string {
    if c1 != "" {
        fmt.Println("main: const c1 has been initialized")
    }
    if c1 != "" {
        fmt.Println("main: const c2 has been initialized")
    }
    return ""
}

我们大胆地猜测一下:Go 1.22版本将c1和c2当成了“未初始化的变量”了!还记得读者问题的第二个关键点吗:“将const声明block搬移到var声明block的前面后,使用Go 1.22版本的输出结果便与文章中的一致”。按照Go 1.22的逻辑,将常量声明放到前面后,按顺序常量先被初始化了。这样到v0时,v0具备初始化的条件就成立了,于是v0就可以先被初始化了。

3. “一波三折”的issue

为了证实上述推测,我在github.com/golang/go提了issue 66575,并对上述问题做了阐述,不过该issue被Go团队的年轻成员Sean Liao“闪电”关闭了。

好在几个小时后,Go大神Keith Randall看到了这个issue,并支持了我的猜测!他还闪电般地找出了导致Go 1.22版本出现此问题的commit,并给出了fix方案:cmd/compile: put constants before variables in initialization order。fix方案的思路就是将所有常量的初始化放到变量之前。

fix merge到主干后,Gobot自动关闭了该issue。

但严谨的Keith Randall随后reopen了该issue,并圈了Go语言之父的Robert Griesemer,希望后者确定一下是否需要更新一下Go spec。

目前该issue已经被加入Go 1.23 milestone,并会在Go 1.23 fix。


Gopher部落知识星球在2024年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。同时,我们也会加强代码质量和最佳实践的分享,包括如何编写简洁、可读、可测试的Go代码。此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

理解unsafe-assume-no-moving-gc包

本文永久链接 – https://tonybai.com/2023/04/16/understanding-unsafe-assume-no-moving-gc

1. 背景

在之前的《Go与神经网络:张量计算》一文中,不知道大家是否发现了,所有例子代码执行时,前面都加了一个环境变量ASSUME_NO_MOVING_GC_UNSAFE_RISK_IT_WITH,就像下面这样:

$ASSUME_NO_MOVING_GC_UNSAFE_RISK_IT_WITH=go1.20 go run tensor.go

这是怎么回事儿呢?如果不加上这个环境变量会发生什么呢?我们来试试:

// https://github.com/bigwhite/experiments/blob/master/go-and-nn/tensor-operations/tensor.go

$go run tensor.go
panic: Something in this program imports go4.org/unsafe/assume-no-moving-gc to declare that it assumes a non-moving garbage collector, but your version of go4.org/unsafe/assume-no-moving-gc hasn't been updated to assert that it's safe against the go1.20 runtime. If you want to risk it, run with environment variable ASSUME_NO_MOVING_GC_UNSAFE_RISK_IT_WITH=go1.20 set. Notably, if go1.20 adds a moving garbage collector, this program is unsafe to use.

goroutine 1 [running]:
go4.org/unsafe/assume-no-moving-gc.init.0()
    /Users/tonybai/Go/pkg/mod/go4.org/unsafe/assume-no-moving-gc@v0.0.0-20220617031537-928513b29760/untested.go:25 +0x1ba
exit status 2

我们看到,程序panic了!我们看到panic的错误信息提到了go4.org/unsafe/assume-no-moving-gc这个包,显然是这个包在“作祟”,那么assume-no-moving-gc这个包究竟是做什么的呢?究竟有何功用?为何gorgonia.org/tensor会依赖这个包?这超出了《Go与神经网络:张量计算》那篇文章的范畴,所以我并未提及。在这篇文章中,我就和大家一起来理解一下unsafe-assume-no-moving-gc这个包。

2. unsafe-assume-no-moving-gc究竟是什么包?

unsafe-assume-no-moving-gc这个包的canonical import path是go4.org/unsafe/assume-no-moving-gc,显然它是go4.org这个组织开源的包。我们看看go4.org的主页(如下图):

这个站点主页非常“简陋”,最大的价值在于解释了go4的来历:gopher的谐音。go4.org开源了一些Go包,这个在其官方github站点可以看到:

项目不多,Star数也不多,但随便翻看一个项目的contributor,我们能看到前Googler、前Go核心团队成员、net/http包的设计者Brad Fitzpatrick(bradfitz)以及Go runtime的核心贡献者Josh Bleecher Snyder(josharian)。现在这两人似乎都在初创公司tailscale任职,做基于wireguard协议的远程安全控制平台(简单理解就是VPN平台)。tailscale汇集了一撮Go语言的原核心开发,go4.org就是他们开源的一些misc go包。而unsafe-assume-no-moving-gc这个包就是其中之一。

那么这个包究竟是做什么的呢?我们接着往下看。

3. unsafe-assume-no-moving-gc的工作原理

unsafe-assume-no-moving-gc是一个非常简单的包:

$tree unsafe-assume-no-moving-gc -F
unsafe-assume-no-moving-gc
├── LICENSE
├── README.md
├── assume-no-moving-gc.go
├── assume-no-moving-gc_test.go
├── go.mod
└── untested.go

0 directories, 6 files

除了test源文件外,它的源文件只有两个assume-no-moving-gc.go和untested.go。打开这两个源文件,你会发现这个包甚至都没有提供任何API。那这个包究竟是做什么用的呢?下面是这个包的README:

大致的理解就是如果你的代码中使用了Go中的unsafe tip,那么你的程序可以正常工作的前提是Go运行时垃圾回收器不是一个带迁移机制的回收器(collector)

所谓带迁移机制的collector,即在GC回收时可能将某些heap object挪到其他内存地址上。你的程序如果导入unsafe-assume-no-moving-gc这个包,就可以在Go GC支持迁移机制时以“程序启动崩溃”的行为提醒你。

我们来看一个例子:

// main.go
package main

import (
    "fmt"

    _ "go4.org/unsafe/assume-no-moving-gc"
)

func main() {
    fmt.Println("unsafe-assume-no-moving-gc demo")
}

go mod tidy后,使用Go 1.20版本运行该源文件:

$go mod tidy
go: finding module for package go4.org/unsafe/assume-no-moving-gc
go: downloading go4.org/unsafe/assume-no-moving-gc v0.0.0-20230221090011-e4bae7ad2296
go: downloading go4.org v0.0.0-20230225012048-214862532bf5

$go run main.go
unsafe-assume-no-moving-gc demo

由于目前最新Go 1.20.x版本的GC并非带迁移机制的GC,因此使用Go 1.20跑上面程序不会导致panic。

我们将unsafe-assume-no-moving-gc包回退到以前的版本,比如:v0.0.0-20230221090011-e4bae7ad2296,然后再run一遍main.go:

$go get go4.org/unsafe/assume-no-moving-gc@v0.0.0-20201222180813-1025295fd063
go: downgraded go4.org/unsafe/assume-no-moving-gc v0.0.0-20230221090011-e4bae7ad2296 => v0.0.0-20201222180813-1025295fd063

$go run main.go
panic: Something in this program imports go4.org/unsafe/assume-no-moving-gc to declare that it assumes a non-moving garbage collector, but your version of go4.org/unsafe/assume-no-moving-gc hasn't been updated to assert that it's safe against the go1.20 runtime. If you want to risk it, run with environment variable ASSUME_NO_MOVING_GC_UNSAFE_RISK_IT_WITH=go1.20 set. Notably, if go1.20 adds a moving garbage collector, this program is unsafe to use.

goroutine 1 [running]:
go4.org/unsafe/assume-no-moving-gc.init.0()
    /Users/tonybai/Go/pkg/mod/go4.org/unsafe/assume-no-moving-gc@v0.0.0-20201222180813-1025295fd063/untested.go:24 +0x1ba
exit status 2

从输出的panic error信息中,我们看到go4.org/unsafe/assume-no-moving-gc尚未被升级到可以信任go 1.20版本的版本,因此以Go 1.20运行该程序可能有风险。如果你能确认不会存在问题,可以用ASSUME_NO_MOVING_GC_UNSAFE_RISK_IT_WITH=go1.20这个环境变量来避免panic,比如下面这个输出:

$ASSUME_NO_MOVING_GC_UNSAFE_RISK_IT_WITH=go1.20 go run main.go
unsafe-assume-no-moving-gc demo

那么unsafe-assume-no-moving-gc包是怎么做到上述“检测”的呢?其诀窍就在untested.go这个源文件中。我们下载go4.org/unsafe/assume-no-moving-gc源码,并将其“回退”到1025295fd063这个commit时刻:

$git checkout 1025295fd063
Note: checking out '1025295fd063'.

... ...

HEAD is now at 1025295 flesh out package doc

查看untested.go:

// Copyright 2020 Brad Fitzpatrick. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// +build go1.18

package assume_no_moving_gc

import (
    "os"
    "runtime"
    "strings"
)

func init() {
    dots := strings.SplitN(runtime.Version(), ".", 3)
    v := runtime.Version()
    if len(dots) >= 2 {
        v = dots[0] + "." + dots[1]
    }
    if os.Getenv(env) == v {
        return
    }
    panic("Something in this program imports go4.org/unsafe/assume-no-moving-gc to declare that it assumes a non-moving garbage collector, but your version of go4.org/unsafe/assume-no-moving-gc hasn't been updated to assert that it's safe against the " + v + " runtime. If you want to risk it, run with environment variable " + env + "=" + v + " set. Notably, if " + v + " adds a moving garbage collector, this program is unsafe to use.")
}

这个文件有两个特点:

  • 使用了build constraint:// +build go1.18,这意味着在你使用Go 1.18及更高版本时,该源文件才会参与编译。
  • 包含了init函数,你的代码在导入assume_no_moving_gc包时,该init函数会执行,产生“副作用”。

注:关于build constraint的用法,参见go help buildconstraint。

这样,我们使用go 1.20版本运行上面main.go时,由于go 1.20版本大于go 1.18版本,untested.go将被编译且其中的init函数将被执行,如果env这个常量(“ASSUME_NO_MOVING_GC_UNSAFE_RISK_IT_WITH”)所对应的环境变量没有设置,那么init函数将走到panic,从而导致程序退出并输出panic信息。

现在我们将assume_no_moving_gc包的版本切换回最新版本,最新版本的untested.go中的build constraint如下:

  //go:build go1.21
  // +build go1.21

这意味着你使用Go 1.21或以上版本时,untested.go文件才会被编译,如果我们使用go 1.20版本运行main.go,我们便不会“触发”untested.go中init函数的副作用,于是main.go得以正常运行。

注:截至go 1.20版本,Go GC依然不会挪动heap object。

在理解unsafe-assume-no-moving-gc包之前,我就该包的功用“咨询”了ChatGPT,ChatGPT的回答如下:

可以看出,ChatGPT基本上是一本正经地“胡说八道”。

4. 小结

unsafe-assume-no-moving-gc只针对GC对heap object的迁移,而不会保证栈地址的迁移,我们知道,Go中栈地址是会变的,因为goroutine的初始栈才2KB,一旦超出这个范围,Go runtime就会对栈进行扩展,即分配一个更大的地址范围作为goroutine的栈,然后将原栈上的变量迁移到新栈中,这样原先栈上变量的地址就都会发生变化。

不过,如果你的Go源码中采用了unsafe tips,依赖了heap object的地址,那么这里建议你导入unsafe-assume-no-moving-gc包。但要注意,随着go最新版本的发布,你要及时更新依赖的unsafe-assume-no-moving-gc的版本。否则当用户使用最新版本go时,依赖你的包的程序就会以panic来提醒。


“Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2023年,Gopher部落将进一步聚焦于如何编写雅、地道、可读、可测试的Go代码,关注代码质量并深入理解Go核心技术,并继续加强与星友的互动。欢迎大家加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats