《用Go理解并发编程》2-1 Golang语言基础
本专栏关注并发编程。由于我们选择了Golang,有必要确保读者对于Golang的基本语法有充分了解,方能更加顺畅的参与之后的阅读并投身技术实践。
你可以通过互联网上丰富的基础资料进行学习,例如菜鸟教程( https://www.runoob.com/go/go-tutorial.html ) 等公开、免费的资料,从而了解Golang的基本语法和循环、条件等基本流程控制语句,我们将在这些基础之上,对这些知识点进行简要归纳并着重介绍接下来内容里与并发编程和并发编程优化高度相关的Golang语言特性。
除了基本语法,你还需要明白Golang的程序如何在电脑上编译运行——这样才能确保知识能够随时校验并实践。笔者不推荐“从0开始”的VScode书写代码+本地命令行方式运行你的代码,使用一个好的IDE可以事半功倍。目前笔者最推荐Goland这款IDE。学生注册JetBrains教育账户,可以获得1年免费Goland使用权。
本小节分为两部分:
- 基本语法知识
- 与后续并发编程关联紧密的知识
Golang基本语法知识
Hello world
任何语言的开始都需要Hello world。
编程案例
package main import "fmt" func main() { fmt.Println("Hello world!") }
案例输出
Hello world!
如上,则是基于Golang的Hello world,这个案例简要的说明了Golang程序的几大特点:
- 源文件需要声明package,引用其他文件通过指定package来实现,类似java
- 通过import来引用其他文件,或者是包,来实现函数与功能的扩展,类似python
- 程序一般需要有main函数,并指定包为main来决定程序入口,类似C语言
变量与表达式
编程案例
package main import "fmt" var ( sum int //通过在公共区域声明var,用于设置当前包下的全局变量 ) //Golang也支持常量,利用const关键字声明 func main() { //var z string //错误!此处变量若声明但未被使用,将会直接报错,程序无法编译通过 var a int //通过 var [name] [type] 方式声明变量 var b,c int //该方式可以一次性声明多个同类型变量 var d = 4 //可以在声明的同时免去声明类型,直接初始化值。 e := 5 //通过海象运算符 := ,可以直接声明并初始化变量。注意,此处初始化值是必须的。 _ = 233 //_ 是只写变量,用于占位,尤其用于函数返回多个值时舍弃其中若干值 a = 1 b = 2 c = a+b sum = a + b + c + d + e //Golang支持常见的运算,诸如加减乘除,与或位移。 //请注意,Golang不支持三元运算符:? fmt.Println( a, b, c, d, e, sum ) }
案例输出
1 2 3 4 5 15
该案例简要介绍了常见的Golang声明变量及初始化变量的方式。
条件与流程控制语句
编程案例
package main import "fmt" func main() { a,b,c,d:=2,10,250,666 for i:=0;i<a;i++{ //最易于理解的for循环 b=b*50 } for b>a{ //构造类似while-do的循环 if a=a+1;b>=c{ //if条件处支持多语句,用 ; 分割 fmt.Println(b) break //支持break continue流程控制,与C语言一致 } } for { //直接构造无限循环(必须要确保内部有语句执行,且最好有退出机制) if b>d{ goto OUT //支持goto机制,与C语言一样,通过设定标记,进行强制流程跳转到标记处 }else{ fmt.Println("爷挡了你一下!") } } OUT: //此处作为标记,可从其他地方goto到该标记 fmt.Println("谁能挡我?") }
案例输出
25000 谁能挡我?
如案例所展示,Golang的for与if均无需括号包裹,简明扼要。而且支持GOTO机制,虽有争议,但使流程控制更加直接。
Golang的for循环支持使用range关键字直接遍历切片(Slice,可以简单理解为动态数组)、Map、通道。虽然可能是Golang内最常用的循环方式,但由于涉及到深入特性,此处并未陈列。另外其他语言广泛支持的switch结构,Golang也是同样支持的,只是因为暂未找到很好的与案例结合的方式,故未予陈列。无论如何,尚未了解的读者不用担心,后文会单独在需要的时候进行标注。
函数
编程案例
package main import ( "fmt" ) //函数支持单、多形参和返回值,只要在同一个包内,相互识别无特殊顺序。 func UpFunc(num int) error{ //Upfunc被定义为接受一个参数,返回一个参数 fmt.Println("UpFunc:我压着main呢!你看,你肯定能找到我") func (str string){ //Golang支持匿名函数,匿名函数声明完之后将即刻被执行 fmt.Println(str) }("Unknown:我偷偷占个位置……") return nil //nil相当于其他语言中的null、空值,用于表示空对象 } func main() { fmt.Println("Main:虽然程序很流畅,但代码上我感觉我上下正在被挤压!我不能呼吸了!") err := UpFunc(6) //这是Golang最常见的错误捕获与处理方式 if err!=nil{ panic(err) } msg,_:=DownFunc("你想说啥",250) //看!此处通过运用 _ 实现了对不想要处理的err的屏蔽 fmt.Println(msg) fmt.Println("Main:我就像汉堡里的腌黄瓜一样可怜!") } func DownFunc(message string,num int)(string,error){ //DownFunc被定义为接受两个参数,应返回两个参数 //形参若未被使用,可以通过编译 OtherFunc() return "DownFunc:我顶着main呢!但不用我多说你也能找到我",nil } func OtherFunc(){ //defer修饰的函数调用或匿名函数将在函数内执行的最后执行 defer func (){ if r:=recover();r!=nil{ //recover机制:若执行recover()前有panic存在,将捕获到panic信息,并阻止panic进一步传递 //recover成功后,被捕获的panic将不会再起作用 fmt.Println("OtherFunc崩了,他临终前说:", r) } }() //此处如果函数内返回值(错误)不为空,则将触发panic中止函数执行 //若panic一直未得到处理,将一直到主函数,并触发程序终止 panic("是DownFunc把我放出来的!") }
案例输出
Main:虽然程序很流畅,但代码上我感觉我上下正在被挤压!我不能呼吸了! UpFunc:我压着main呢!你看,你肯定能找到我 Unknown:我偷偷占个位置…… OtherFunc崩了,他临终前说: 是DownFunc把我放出来的! DownFunc:我顶着main呢!但不用我多说你也能找到我 Main:我就像汉堡里的腌黄瓜一样可怜!
通过该案例,你应该能够体会到Golang在函数处理上与其他语言的异同和取舍,对一些应放宽的地方进行了优化,而对另一些应严格的地方则保持苛刻,具体表现如下:
- 函数定义后直接即声明,与文件内书写位置无关
- 函数支持多参数传入、多参数返回
- 函数支持优雅的退出前收尾工作,包括正常退出(return)和意外退出(panic)
- 函数对于错误的抛出地必须严格声明,精确到行,而目前并不支持try...catch
- 对于只接受函数部分返回值,必须显式指定抛弃的对象,用_实现
注意:Golang的函数不支持重载
结构体
编程案例
package main import "fmt" //属性或包内全局变量变量首字母小写在Golang的包内将作为私有属性/变量,无法被其他包引用 type meizi struct { mouse string } type Hanzi struct { meizi //通过这种方式直接进行继承meizi结构体的全部属性,包括函数 House bool } func main() { //var testWoman meizi //初始化结构体变量可以用上面的方式,也可以直接用:=运算符 testWoman := meizi{ mouse: "樱桃小嘴", } testMan := Hanzi{ meizi: testWoman, //若继承结构体内有自有属性,可以层层手写初始化,也可以直接传入一个已初始化该类结构体 House: false, //无论如何,在直接初始化赋值结构体的末尾有一个逗号 } fmt.Println(testWoman.mouse) testWoman.passWind() fmt.Println(testWoman.mouse) testWoman.Jiao() fmt.Println(testWoman.mouse) testMan.Jiao() testMan.IfHasHouse() } //下列展示将函数绑定到结构体的方式 //如果你了解面向对象编程,可以简单理解下面内容为构造对象的方法 //此函数内c表示该结构体,该函数内对结构体的引用为值引用 func (c meizi) passWind(){ c.mouse="大比卜" //因而修改结构体内值不会在该函数退出后保留修改 fmt.Println("噗……") } //而该函数内对结构体的引用为指针引用,参考C++语言,此时对c的操作,就是对原始结构体的操作 func (c *meizi) Jiao(){ c.mouse="张得老大了" //因而此处执行完毕后,Jiao函数退出后,该meizi的嘴将是“张的老大了” fmt.Println("妹子:啊~~") } //该函数内,this并没有特殊含义,作用同上面的c。可以看到,这里的this是值引用 func (this Hanzi) IfHasHouse(){ if this.House{ fmt.Println("这个男人有房子") } else{ fmt.Println("他暂时还没有房子") } } //对继承结构体内的函数定义同名函数以进行覆盖。定义后,对Hanzi的Jiao函数的调用将以下面为准 func (c Hanzi) Jiao(){ c.mouse="嘴张的老大了!" fmt.Println("汉子:啊!!!") }
案例输出
樱桃小嘴 噗…… 樱桃小嘴 妹子:啊~~ 张得老大了 汉子:啊!!! 他暂时还没有房子
Golang并发要素三剑客-简介
接下来我们简要介绍Golang基础语法中与后续并发编程紧密相关的内容。请读者体会go修饰机制、通道与select这三样内容在并发编程中可能的作用。
Go关键字
编程案例
package main import "fmt" func main(){ go func(){ fmt.Println("Hello world!") }() }
案例输出
(程序退出,且没有任何输出)
为什么这个“高级版Hello world”不会发生任何输出呢?
这是因为,通过go关键字,修饰的函数进入并发状态。根据投掷者模型思考,程序将把go修饰的内容当作球,当程序运行到go处时,将go所修饰的内容“丢出”,此时若人不关心运行结果,则人将认为“任务已完成”(例如人只关心把球丢出去而不关心球飞向何方,落在何地),故程序会直接退出。
即使在上一小节我们已经展示过Golang极简的并发语义抽象,想必在本节看完完整的例子,读者一样会被震撼到。用这个关键字作为这门编程语言的名称,可见这张“王炸”的威力!
当然,仅仅是go这个关键字,并不能完全体现Golang并发编程的威力,正如读者所见,加上go关键字之后的程序,没有按照人们传统的思路运行了,因为这些程序将由一些称之为协程的单元承载执行。很明显,这不能直接投入并发编程,还需要找到其他的配合者以及催化剂。
让我们暂时搁置“原材料”不足的问题,通过了解下面通道的特性,或许能给读者进一步补足“原材料”提供思路。
通道简介
通道(channel)是用来传递数据的一个数据结构,正如名称,通道这个数据结构支持双向存/取数据,但必须要指定从“哪个口”,也就是指定方向。在Golang中,用<-运算符实现。对于无缓冲通道,一边放,另外一边必须取,否则将阻塞;对于有缓冲通道,通道若内部为空,取数据将发生阻塞;若内部盛满,则放数据将发生阻塞,是不是很神奇?通过下面的案例,我们直观的了解一下通道这个结构的特性。
编程案例
package main import "fmt" func main() { c1 := make(chan int) //默认声明通道,不带缓冲区,通道放置内容必须及时得到取出(该通道内为int) testNum := 1 go func(channel chan int) { //无缓冲通道的读写操作必须在不同协程内,根据包工头模型,自己给自己发IM消息是无意义的 //若违反这个规定,编译虽将通过,但无法正常运行。程序将在遭遇首次违反这个规则的通道读写时报错 c1 <- testNum }(c1) testRes := <-c1 //可以正常从通道内掏出东西 fmt.Println("看看从通道掏出了什么宝贝?一看:",testRes) c2:=make(chan int,2) //通过第二个参数,可以指定通道缓冲区大小,如此处为2,通道内支持放入两个int //有缓冲通道,只要未满,无需其他协程处理,程序不会阻塞 c2 <- testNum+1 c2 <- testNum+1 testRes = <-c2 fmt.Println("看看从通道掏出了什么宝贝?一看:",testRes) testRes = <-c2 fmt.Println("看看从通道掏出了什么宝贝?一看:",testRes) testRes = <-c2 //将不再能取出东西,因为通道内东西被全部掏空,程序将尝试阻塞 fmt.Println("看看从通道掏出了什么宝贝?一看:",testRes) //经过运行时判定:程序阻塞后,并没有可能会有其他的协程或机制向该通道内丢入新东西 //因而认为程序发生死锁,程序终止(详见打印内报错)。 }
案例输出
看看从通道掏出了什么宝贝?一看: 1 看看从通道掏出了什么宝贝?一看: 2 看看从通道掏出了什么宝贝?一看: 2 fatal error: all goroutines are asleep - deadlock!
现在,拥有了通道这一强大的工具后,就好比包工头模型内工人们传递物资有了通道,交流信息有了手机,想必读者也能渐渐体会到通道这一数据结构将在并发过程中协程间通信将要起到的强力作用。好的,我们再参照投掷者模型和包工头模型,Golang上述语言特性可以进行如下的解释:
- 利用go关键字修饰,标记待投掷物品或待分配任务,这是一种任务分配机制
- 利用通道进行物资交换和信息交流,好比是IM软件、快递系统、物理世界的通道,这是一种任务协调机制
想象一下协调有序的工厂,有了任务分配,有了任务协调,还需要有什么呢?还有一项没有得到满足:
- 任务确认与等待机制
最后,在本节,我们介绍Golang的select语法,作为对并发过程内任务确认与等待机制的落实。
/*Golang的switch结构,是不是和其他语言很像*/ switch varTest { case val1: ... case val2: ... /* 你可以定义任意数量的 case */ default: /* 可选 */ ... } /*Golang的select结构,注意体会与switch的异同*/ select { case communication clause : statement(s); //do sth here case communication clause : statement(s); /* 你可以定义任意数量的 case,但必须涉及I/O操作(通信) */ default : /* 可选 */ statement(s); }
select 是 Golang 中的一个控制结构,如果你有过初步了解,乍一看上去十分像switch结构。但是在select内,每个 case 必须是一个通信操作,要么是发送要么是接收。select 随机执行一个可运行的 case。如果没有 case 可运行,它将阻塞,直到有 case 可运行。在Golang并发编程中,如果用到select,那么其一个默认的子句应该总是可运行的。当然,如果读者使用select的目的就是为了阻塞,那么另当别论。
自然是单纯文字叙述不够直观,我们简要来看一下基于select的编程案例。作为对并发过程内“任务确认与等待机制”的一项落实,读者可以着重观察并思考select对于整个程序并发的“收尾作用”。
Select机制
编程案例-龟兔赛跑
package main import ( "fmt" "time" ) func main() { RAS := make(chan int) //Rabbit arrival signal兔子抵达信号缩写,作者自己取的 GAS := make(chan int) //Glans arrival signal乌龟抵达信号缩写,作者自己取的 fmt.Println("裁判员:我需要确认检测设备是否顺畅") select { case <-RAS: fmt.Println("裁判员:【测试中】兔子发来消息,它已抵达终点") case <-GAS: fmt.Println("裁判员:【测试中】乌龟发来消息,它已抵达终点") default: //由于通道RAS和GAS均没有数据,select只有执行default方案 fmt.Println("裁判员:终点净空,监测设备正常,请求村长开赛!") } fmt.Println("羊村长:我宣布:龟兔赛跑大赛正式开始,冲冲冲!") fmt.Println("裁判员:兔子已经上路……") go RabbitGo(RAS) fmt.Println("裁判员:乌龟已经上路……") go GlansGo(GAS) select { //刚执行到此处时,RAS与GAS内均没有数据,由于没有default方案,select阻塞中 case <-RAS: fmt.Println("裁判员:兔子发来消息,它已抵达终点") case <-GAS: fmt.Println("裁判员:乌龟发来消息,它已抵达终点") } fmt.Println("比赛结束,小伙伴们议论纷纷,乌龟为什么这次输了。") select {} //没有任何方案,此处select将一直阻塞,下面的语句将永远不会执行 fmt.Println("乌龟:我对不起父老乡亲!") } func RabbitGo(RAS chan int) { time.Sleep(2 * time.Second) fmt.Println("兔子:我到了,我触发一下设备") RAS <- 1 //通过触发通信(往通道里塞内容),让RAS内有内容,触发select向下执行 } func GlansGo(GAS chan int) { time.Sleep(999 * time.Second) fmt.Println("乌龟:我到了,我触发一下设备") GAS <- 1 }
案例输出
裁判员:我需要确认检测设备是否顺畅 裁判员:终点净空,监测设备正常,请求村长开赛! 羊村长:我宣布:龟兔赛跑大赛正式开始,冲冲冲! 裁判员:兔子已经上路…… 裁判员:乌龟已经上路…… 兔子:我到了,我触发一下设备 裁判员:兔子发来消息,它已抵达终点 比赛结束,小伙伴们议论纷纷,乌龟为什么这次输了。 (程序未退出)
可以看出,select是一种特殊的基于监听I/O实现case选择执行的结构,不需要单独或额外的无限循环等结构,就可以优雅的实现对协程处理结果的把控。一般说来,将在如下环节使用到select:
- 当需要检查执行结果时,且如果未有结果,程序不阻塞(使用default)
- 当需要等待并检查执行结果时(只使用case,不使用default)
- 只需要等待结果,或者单纯的为了使程序进入阻塞状态(select区块内不添加任何东西)
掌握语言基础自然还不够,我们在本章的接下来,将继续关注在之后并发技术探讨过程中,可能用到的其他背景知识。