标签 RobPike 下的文章

理解Golang语句中的求值顺序

Golang在变量声明、初始化以及赋值语句上照比C语言有了许多改进:

a) 支持在同一行声明多个变量

var a, b, c int

b) 支持在同一行初始化多个变量(不同类型也可以)

var a, b, c = 5, "hello", 3.45
a, b, c := 5, "hello", 3.45 (short variable declaration)

c) 支持在同一行对多个变量进行赋值(在声明后且不同类型也可以)

a, b, c = 5, "hello", 3.45

这种语法糖我们是笑纳的,毕竟人生苦短,少写一行是一行啊^_^。

但这种语法糖却给我们带来了一些令人困惑的问题!比如下面这个就是Rob Pike在一个talk中slide(Go Course Day2)中的一个问题:

n0, n1 = n0 + n1, n0

or:

n0, n1 = op(n0,n1), n0

n0, n1的值在上述语句执行完毕后到底为多少呢?

显然这个问题涉及到Go语言的语句求值顺序(evaluation order)。求值序在任何一门编程语言中都是比较难缠的,很多情形下,语言规范给出的答案都是“undefined(未定义)” or "not specified" or “依赖实现”,尤其是对于哪些模棱两可的写法,就如Rob Pike给出的那个问题。

我们要想搞清楚Go中的求值顺序,我们需要求助于Go language specification,Go spec与Go发行版一起发布,你可以启动一个godoc web server(godoc -http=:6060,然后访问localhost:6060/ref/spec)查看go language specification。Go language specification专门有一个小节/ref/spec#Order_of_evaluation对求值顺序做了说明。

在Go specs中,有这样三点陈述:

1、变量声明(variable declaration)中的初始化表达式(initialization expressions)的求值顺序(evaluation order)由初始化依赖(initialization dependencies)决定;但对于初始化表达式内部的操作数的求值需要按照2中的顺序:从左到右;
2、在非变量初始化语句中,对表达式、赋值语句或返回语句中的操作数进行求值时,操作数中包含的函数(function)调用、方法(method)调用和通信操作(主要针对channel)将按语法从左到右的顺序求值。
3、赋值语句求值分为两个阶段,第一阶段是等号左边的index expressions、pointer indirections和等号右边的表达式中的操作数的求值顺序按照2中从左到右的顺序;第二阶段按从左到右的顺序对变量赋值。

下面我们就分别理解一下这三点。

一、变量声明中初始化表达式的求值顺序

带初始化表达式的变量声明的形式如下:

var a, b, c = expr1, expr2, expr3 //包级别或函数/方法内部

or 

a, b, c := expr1, expr2, expr3 //仅函数/方法内部

根据lang specs说明,求值顺序是由初始化依赖(initialization dependencies)规则决定的。那初始化依赖规则是什么呢?在Golang specs中也有专门章节说明:ref/spec#Package_initialization。

初始化依赖规则总结一下,大致有如下几条:

1、包中,包级别变量的初始化顺序按照声明先后的顺序,但如果某个变量(比如a)的初始化表达式中依赖其他变量(比如b),那么变量a的初始化顺序在变量b后面。
2、对于未初始化的,且不含有对应初始化表达式或其初始化表达式不依赖任何未初始化变量的变量,我们称之为"ready for initialization"变量。初始化就是按照声明顺序重复执行对下一个变量的初始化过程,直到没有"ready for initialization"变量为止。
3、如果初始化过程完毕后依然有变量处于未初始化状态,那程序有语法错误。
4、多个处于不同文件中的变量的声明顺序依赖编译器处理文件的顺序,先处理的文件中的变量的声明顺序先于后处理的文件中的所有变量。
5、依赖分析以包为单位执行,只有位于同一个包中的被依赖的变量、函数、方法才会被考虑。

规则是抽象难懂的,例子更直观易理解,我们看一个golang spec中的例子,并使用上述规则进行分析。实验环境:go 1.5, amd64,Darwin Kernel Version 13.1.0

//golang-statements-evaluating-order/example1.go
package main

import "fmt"

var (
    a = c + b
    b = f()
    c = f()
    d = 3
)

func f() int {
    d++
    return d
}

func main() {
    fmt.Println(a, b, c, d)
}

我们来分析一下程序执行后的a, b, c, d四个变量的结果值,不过不同的初始化顺序会导致结果值不同,因此分析四个变量的初始化顺序是至关重要的。

变量a, b, c, d的初始化过程如下:

1、根据规则,初始化按照变量声明先后顺序进行,因此先来分析变量a,a初始化表达式依赖b 和c;因此变量a的初始化次序排在b、c的后面;
2、按照a的初始化右值表达式,c、b在右值表达式中的出现顺序是c先于b;
3、c是否是一个ready for initialization变量呢?我们看到c依赖f这个函数,而f这个函数则依赖变量d的初始化,因此d排在c之前;
4、我们来看变量d,"d = 3",d未初始化且不含有初始化表达式,因此d是一个ready for initialization变量,我们可以从d开始初始化了。至此四个变量的初始化顺序排定 d -> c -> b -> a;(这块儿与spec中分析有差异,但从运行结果来看,应该是这个顺序;关于这个spec的issue参见#12369)
5、d初始化为3,此时已初始化变量集合[d=3];
6、接着初始化c:c = f(),因此c = 4(此时d=4),此时已初始化变量集合[c=4,d=4];
7、接下来轮到b:b = f(),因此b = 5 (此时d = 5),此时已初始化变量集合[b=5,c=4,d=5];
8、最后初始化a: a = c + b,在已初始化变量集合中我们可以找到b和c,因此a= 9,这样四个变量到此均已初始化;
9、经过分析:程序执行的结果应该是9,5,4,5。

我们来执行一下这个程序,验证一下我们的分析结果是否正确:

$go run example1.go
9 5 4 5

我们再来看一个例子,也是golang specs中的例子,我们稍作改造,并把它设定为example2:

//golang-statements-evaluating-order/example2.go
package main

import "fmt"

var a, b, c = f() + v(), g(), sqr(u()) + v()

func f() int {
    fmt.Println("calling f")
    return c
}

func g() int {
    fmt.Println("calling g")
    return a
}

func sqr(x int) int {
    fmt.Println("calling sqr")
    return x * x
}

func v() int {
    fmt.Println("calling v")
    return 1
}

func u() int {
    fmt.Println("calling u")
    return 2
}

func main() {
    fmt.Println(a, b, c)
}

同样根据变量初始化依赖规则对这个例子进行分析:

1、按照变量声明顺序,先初始化a:a= f() + v(),f()依赖变量c;v不依赖任何变量,因此变量c的初始化顺序应该在a变量前:c -> a。
2、分析c:c = sqr(u()) + v();u、sqr、v三个函数不依赖任何变量,因此c处于ready for initialization,于是对c进行初始化,函数执行顺序(从左到右)为:u() -> sqr() -> v(); 此时已初始化变量集合:[c = 5];
3、回到a:a = f() + v(),c初始化后,a也处理ready for initialization,于是对a初始化,函数执行顺序为:f() -> v(),此时已初始化变量集合:[c=5, a= 6];
4、按照变量声明次序,接下来轮到变量b:b= g(),而g()依赖a,a已经初始化完毕了,因此b也是ready for initialization,于是对b初始化,函数执行次序为:g(),至此已初始化变量集合:[c=5, a=6, b=6]。
5、经过分析:程序执行的结果应该是6,6,5。

我们来执行一下这个程序,验证一下我们的分析结果是否正确:

$go run example2.go
calling u
calling sqr
calling v
calling f
calling v
calling g
6 6 5

二、非变量初始化语句中的求值顺序

前面提到过:在非变量初始化语句中,对表达式、赋值语句或返回语句中的操作数进行求值时,操作数中包含的函数(function)调用、方法(method)调用和通信操作(主要针对channel)将按语法从左到右的顺序求值

我们同样来看一个例子:example3.go

//golang-statements-evaluating-order/example3.go
package main

import "fmt"

func f() int {
    fmt.Println("calling f")
    return 1
}

func g(a, b, c int) int {
    fmt.Println("calling g")
    return 2
}

func h() int {
    fmt.Println("calling h")
    return 3
}

func i() int {
    fmt.Println("calling i")
    return 1
}

func j() int {
    fmt.Println("calling j")
    return 1
}

func k() bool {
    fmt.Println("calling k")
    return true
}

func main() {
    var y = []int{11, 12, 13}
    var x = []int{21, 22, 23}

    var c chan int = make(chan int)
    go func() {
        c <- 1
    }()

    y[f()], _ = g(h(), i()+x[j()], <-c), k()

    fmt.Println(y)
}

y[f()], _ = g(h(), i()+x[j()], <-c), k() 这行语句是赋值语句,但赋值语句的操作数中包含函数调用、channel操作,按照规则,这些函数调用、channel操作按从左到右顺序估值。

1、按照从左到右顺序,第一个是y[f()]中的f();
2、接下来是g(),g()的参数列表依然是一个赋值操作,因此其涉及到的函数调用顺序为h(), i(),j(),<-c,因此实际上的顺序为h() –> i()–> j() –> c操作 -> g();
3、最后是k(),因此完整的调用顺序是:f()->
h() –> i()–> j() –> c操作 -> g() –> k()。

实际运行情况如下:

$go run example3.go
calling f
calling h
calling i
calling j
calling g
calling k
[11 2 13]

三、赋值语句的求值顺序

我们再回到前面Rob Pike那个问题:

n0, n1 = n0 + n1, n0

or:

n0, n1 = op(n0, n1), n0

这是一个赋值语句,根据规则3,我们对等号两端的表达式的操作数采用从左到右的求值顺序。

我们假定初值:
n0, n1 = 1, 2

1、第一阶段:等号两端表达式求值,上述问题中,只有右端有n0+n1和n0两个表达式,但表达式的操作数(n0,n1)都是初始化过后的了,因此直接将值带入,得到求值结果。求值后,语句可以看成:n0, n1 = 3, 1;
2、第二阶段:赋值。n0 =3, n1 = 1

//golang-statements-evaluating-order/example4.go
package main

import "fmt"

func example1() {
    n0, n1 := 1, 2
    n0, n1 = n0+n1, n0
    fmt.Println(n0, n1)
}

func op(a, b int) int {
    return a + b
}

func example2() {
    n0, n1 := 1, 2
    n0, n1 = op(n0, n1), n0
    fmt.Println(n0, n1)
}

func main() {
    example1()
    example2()
}

$go run example4.go
3 1
3 1

四、小结

虽说理解了规则,但实际工作中我们还是尽量不要写出像:"var a, b, c = f() + v(), g(), sqr(u()) + v()"这样复杂、难以让人理解的语句。必要的话,拆分成多行就好了,还可以增加些代码量(如果你的公司是以代码量为评价绩效指标之一的),得饶人处且饶人啊,烧脑的语句还是尽量避免为好。

以上实验代码在这里可以下载到。

五、参考资料

The Go Programming Language Specification (Version of August 5, 2015) 

也谈并发与并行

在一般人的眼中,“并行”就是并行,即你干你的,我干我的,两个“并行”的执行过程可能是两条毫无瓜葛的平行线,也可能是有交叉,但瞬即分开的两条线。不 过在程序员的世界里,有关“并行”的概念却有两个单词:Concurrency和Parallelism,对应的比较主流的中文翻译为并发 (Concurrency)和并行(Parallelism)。

之前一直使用C、Python进行Coding,对Concrrency和Parallelism的异同并不十分关心,也未求甚解。但switch to golang后,尤其是学习2012年Rob Pike的一个talk slide:“Concurrency is not Parallelism(译作:并发不是并行)"后,感觉之前对于“并行”的理解还未到火候。

golang的Author们对文档还是非常看重的。按照目前golang的age来说,其文档的充分性相对于其他语言已经是相对较好的了。golang 的 author们还时不时放出一些blog、talk和slide,以帮助大家编写出more idiomatic的golang程序。Rob Pike的“并发不是并行”就是golang官方站点上的一个talk slide(中文版在这里 )。

Rob Pike是Golang大神,这里先列出他在talk中对于并发与并行的学术阐释和理解:

【Concurrency并发】
Programming as the composition of independently executing processes. (Processes in the general sense, not Linux processes. Famously hard to define.)
将相互独立的执行过程综合到一起的编程技术。(这里是指通常意义上的执行过程,而不是Linux进程。很难定义。)

Concurrency is about dealing with lots of things at once.
并发是指同时处理很多事情。

Concurrency is about structure.
并发关乎结构。

Concurrency provides a way to structure a solution to solve a problem that may (but not necessarily) be parallelizable.
并发提供了一种方式让我们能够设计一种方案将问题(非必须的)并行的解决。

Concurrency is a way to structure a program by breaking it into pieces that can be executed independently.
并发是一种将一个程序分解成小片段独立执行的程序设计方法。

【Parallelism并行】
Programming as the simultaneous execution of (possibly related) computations.
同时执行(通常是相关的)计算任务的编程技术。

Parallelism is about doing lots of things at once.
并行是指同时能完成很多事情。

Parallelism is about execution.
并行关乎执行。

【小结】
They are Not the same, but related.
它们不相同,但相关。

怎么样?看上上面的论述是不是一头雾水啊。Rob Pike也觉得这些概念以及描述过于抽象,于是给了一个具体的“地鼠推车运书”的例子,不过当你看完这个例子后,可能会变得更加糊涂,至少我有这种感觉-地鼠凌乱综合症^_^。这是因为这个例子隐含的结合了Go语言goroutine调度的三个概念:P(虚拟processor上下文)、M(内核线程)和G(Goroutine对象)。如果仅仅从理解并行和并发的差异来说,我们可以抛开go语言,用生活中的例子感觉更适合些。

下面我们就来一个例子来说说明一下并发与并行,从一个程序的设计演进角度来阐述。

问题:说的是一个Gopher早起后的生活,Gopher早起后,有三个任务(或者称为三件事情)要完成:洗漱、早餐、着装。我们来设计一个程序,帮助Gopher高效正确的完成这三件事。

如果你是程序员,要完成这个场景,你可能会这么设计你的程序:

program1:

最简单的思路:这个gopher一件一件事情去完成:

main:
    call 洗漱
    call 早餐
    call 着装

这里我们把Gopher看做是一颗cpu,它按程序逻辑,顺序执行洗漱、早餐和着装三件事。即如下图那样:

现在我们玩个克隆游戏,我们clone出一个与这个Gopher一模一样的Gopher,且两个gopher之间存在着某种超宇宙联系,一个Gopher行为的结果都能反应到另外一个gopher上。我们让这两个Gopher一起来做这三件事情,看看是否能够提速。

遗憾的是,两个Gopher都要从洗漱做起。一个Gopher占用了卫生间开始洗漱,另外一个Gopher只能等着,而没法去做早餐或是着装。当那个 Gopher完成洗漱,后面的这个Gopher由于超联系也同步完成了洗漱,进入下一个环节:早餐。过程还是一样的,只能一个Gopher在餐厅准备早 餐。也就是说这两个Gopher没有一起做事,而是一个做,一个赋闲。因此我们看到两个Gopher并没有加快事情完成的步伐,从过程上来看,即便有更多 的Gopher,也依旧无法提速。我们需要对程序做些改造。

注:首尾相连的红线的总长度 = 完成时间。

program2:

main:
    pthread_create(洗漱)
    pthread_create(早餐)
    pthread_create(着装)

    waitAll

Gopher来执行一遍新程序。由于建立了三个逻辑执行体,因此Gopher在三个执行体间切换,从Gopher的角度去看,Gopher的执行路径如下图:


Program2-1

Gopher不再像上面Program1那样顺序执行了,而是在三个活动间切换,但总时长依旧没有下降。

为了验证该程序在多Gopher下是否有效率提升,我们再玩一次克隆游戏,这次clone出另外两个Gopher,三个Gopher一起来执行该程序,一个可能的执行路径见下图:


Program2-2

每个Gopher绑定一个逻辑执行体,整体完成的总时长下降为原来的三分之一。这次三个Gopher都没有赋闲,真正做到你干你的,我干我的,一起做。

program3:

虽然在program2中,多个Gopher一起工作提升了效率,但那是极限么,还能提高么?我们试想一下三个活动:洗漱、早餐和着装的难易不同,耗时不 同。一个可能的结果是Gopher1完成了洗漱,但Gopher2才准备了一半早餐,Gopher3刚选完上衣。这时Gopher1便开始空闲,无法帮助 Gopher2和Gopher3继续提高效率。我们再试试重新组合一下要完成的任务,让每个Gopher都能执行不同的活动环节。

main:
        c chan job
        for i = 0; i < 3; i++  {
            go gopherworker(c)
        }

        for j := range jobs {
            c <- j
        }
        … …

gopherworker(c chan job):
      for {
         select {
         case <-c:
         … …
      }

以下是一个可能的执行路径图:

到了这里,不知道你是否通过上面程序演进的过程悟道些什么,例子里我通篇没有提到并发或并行。

但从例子可以看出,并发和并行是两个阶段的事情。并发在程序的设计和实现阶段,并行在程序的执行阶段。

在Program1之前,我们只有问题,并无方案。

Program1方案让我们可以解决问题,但从Program1的执行结果来看,Program1并不能并行执行。原因是在设计和实现阶段程序就是按照顺序思路进行的,这就好比底子没打好,在平房的地基上永远不能盖50层的大楼。

Program2-1方案的执行结果与Program1相同,但Program2在设计和实现阶段采用的理念却与Program1完全不同,如果说 Program1打的是平房的地基,那么Program2打的就是大厦的地基,虽然Program2-1上依旧盖的是平房(单Gopher执行)。但 Program2-2显然就是在这样的地基上盖的摩天大楼了(多Gopher执行)。Program2的结构使得Program2在多Gopher下提升 了效率,实现了运行时并行。

Program3更进一步,在设计和实现阶段就本着充分高效的利用多个Gopher的理念,并最终实现了执行阶段的并行。

因此我们在编程语言层面更多谈并发,Golang对外宣传时永远用的是支持并发,而不是支持并行。设计实现阶段好比打地基,不同水准的地基决定了你在这个地基上面是只能盖平房,还是盖高层,还是能盖摩天大楼。

我们再回过头来重温Rob Pike大神关于两者的阐述:“并发关乎结构,并行关乎执行”,是不是感觉意味深长啊,大神就是大神,一句话就能抓住本质。

go 1.5之前默认情况下,Go程序都是不能并行的,因为Go将GOMAXPROCS默认设置为1,这样你仅仅能利用一个内核线程。Go 1.5及以后GOMAXPROCS被默认设置为所运行机器的CPU核数,如果你的机器是多核的,你的Go程序就有可能在运行期是并行的,前提是你在设计程 序时就充分运用了并发的设计理念,否则就会像Program1那样,即便有1w颗CPU,你也只能利用上一颗。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系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