《并发哲学:从编程入道到开悟升天》3.2 复习基础,同时再加一点
3.2 复习基础,同时再加一点
从上一小节走来,你应该明白Golang类C的阅读风格,简明的格式控制,面向过程的编写特色让该语言可以迅速由计算机及其相关科班人员上手。同时针对多核处理器进行优化、面向云原生、并发封装/支持优异、内存安全让Golang在当下后台开发领域独占鳌头。编译型特点也使其开发出来的程序在封装和部署上具有得天独厚的优势。即使在错误处理方面被诟病过于严格不够友好,软件包管理的方面Golang也有很长一段路需要走,但这丝毫不影响Golang成为编程人员入门云原生/高并发编程的首选语言。正如上一小节介绍,在云原生、devops工具链,以Golang为开发语言的事实标准不胜枚举,Golang的重要性不言而喻。
本小节,我们假设你已经通过菜鸟教程( https://www.runoob.com/go/go-tutorial.html ) 等公开资料了解了Golang的基本语法和循环、条件等基本流程控制语句,我们将在这些基础之上,对这些知识点进行简要归纳并着重介绍接下来内容里与并发编程和并发编程优化高度相关的Golang语言特性。
请注意,我们强烈推荐你应该先掌握Golang的基本语法,再向下阅读,至少也应该先了解一下。如果你已经对自己掌握Golang基本语法的水平十分有信心,你可以跳过下面的复习甚至是后半段的“再加一点”,但我们尤其建议不跳过本节之后的内容。
复习Golang的基本语法
我们先来复习下Golang的基本语法
编程案例1--Hello world
package main import "fmt" func main() { fmt.Println("Hello world!") }
编程案例1输出
Hello world!
如上,则是基于Golang的Hello world,这个案例简要的说明了Golang程序的几大特点:
- 源文件需要声明package,引用其他文件通过指定package来实现,类似java
- 通过import来引用其他文件,或者是包,来实现函数与功能的扩展,类似python
- 程序一般需要有main函数,并指定包为main来决定程序入口,类似C语言
编程案例2--变量与表达式
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 ) }
编程案例2输出
1 2 3 4 5 15
该案例简要介绍了常见的Golang声明变量及初始化变量的方式。
编程案例3--条件与流程控制
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{ //构造类似do...while的循环 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: fmt.Println("谁能挡我?") }
编程案例3输出
25000 谁能挡我?
如案例所展示,golang的for与if均无需括号包裹,简明扼要。而且支持GOTO机制,虽有争议,但使流程控制更加直接。
golang的for循环还支持直接遍历切片(Slice,可以简单理解为动态数组)、Map、管道,并提供高级语法支持强化编程效率。虽然可能是golang内最常用的循环方式,但由于涉及到深入特性,此处并未陈列。另外其他语言广泛支持的switch结构,golang也是同样支持的,只是因为暂未找到很好的与案例结合的方式,故未予陈列。无论如何,尚未了解的读者不用担心,后文会单独在需要的时候进行标注。
编程案例4--函数
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把我放出来的!") }
编程案例4输出
Main:虽然程序很流畅,但代码上我感觉我上下正在被挤压!我不能呼吸了! UpFunc:我压着main呢!你看,你肯定能找到我 Unknown:我偷偷占个位置…… OtherFunc崩了,他临终前说: 是DownFunc把我放出来的! DownFunc:我顶着main呢!但不用我多说你也能找到我 Main:我就像汉堡里的腌黄瓜一样可怜!
通过该案例,你应该能够体会到Golang在函数处理上与其他语言的异同和取舍,对一些应放宽的地方进行了优化,而对另一些应严格的地方则保持苛刻,具体表现如下:
- 函数定义后直接即声明,与文件内书写位置无关
- 函数支持多参数传入、多参数返回
- 函数支持优雅的退出前收尾工作,包括正常退出(return)和意外退出(panic)
- 函数对于错误的抛出地必须严格声明,精确到行,而目前并不支持try...catch
- 对于只接受函数部分返回值,必须显式指定抛弃的对象,用_实现
注意:Golang的函数不支持重载
编程案例5--结构体
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("汉子:啊!!!") }
编程案例5输出
樱桃小嘴 噗…… 樱桃小嘴 妹子:啊~~ 张得老大了 汉子:啊!!! 他暂时还没有房子
确保上述内容可以准确理解并自如运用,作为Golang语言的入门,已经足够。如果你是Golang新手,那么不用担心,在本章今后的学习过程中,会一直尝试在合适的时间点,对需要补充说明的Golang语言知识点进行着重巩固。
了解和掌握了基本语法,我们可以在上述基础之上,展开我们对Golang并发支持相关特性的探索……
了解Golang关于并发的特性语法与结构(再加一点)
若只是会用Golang而拒绝了解并发加成特性,Golang的效用将至少降低一半。
如上一节介绍,Golang用一种极简的方式,将代码片段标记为需要由另一种调度单元——协程,执行的内容。而这种标记,则是Go所有运用并发能力编程的基础。来看下面的例子:
编程案例6--Go关键字修饰下函数由协程执行
package main import "fmt" func main(){ go func(){ fmt.Println("Hello world!") }() }
编程案例6输出
(程序退出,且没有任何输出)
为什么这个“高级版Hello world”不会发生任何输出呢?
这是因为,通过go关键字,修饰的函数进入并发状态,被调度到另一个协程(也可成为运行时线程,或goroutine)进行执行。根据投掷者模型,程序将把go修饰的内容当作球,当程序运行到go处时,将go所修饰的内容丢出,此时若人不关心运行结果,则人将认为“任务已完成”(例如人只关心把球丢出去而不关心球飞向何方,落在何地),故程序会直接退出。
即使在上一小节我们已经简要介绍过Golang极简的并发语义抽象,我想在本节看完完整的例子,你一样会被震撼到。用这个关键字作为这门编程语言的名称,可见这张“王炸”的威力!
当然,仅仅是go这个关键字,并不能完全体现golang并发编程的威力,正如你所见,加上go关键字之后的程序,没有按照人们传统的思路运行了,因为这些程序将由一些称之为协程的单元承载执行。很明显,这不能直接投入并发编程,还需要找到其他的配合者以及催化剂。
让我们暂时搁置“原材料”不足的问题吧。通过了解下面管道的特性,或许能给你进一步补足“原材料”提供思路。
通道/管道(channel)是用来传递数据的一个数据结构,正如名称,管道这个数据结构支持双向存/取数据,但必须要指定从“哪个口”,也就是指定方向。在golang中,用<-运算符实现。对于无缓冲管道,一边放,另外一边必须取,否则将阻塞;对于有缓冲管道,管道若内部为空,取数据将发生阻塞;若内部盛满,则放数据将发生阻塞,是不是很神奇?通过案例7,我们直观的了解一下管道这个结构的特性。
编程案例7--管道基本特性
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) //经过运行时判定:程序阻塞后,并没有可能会有其他的协程或机制向该管道内丢入新东西 //因而认为程序发生死锁,程序终止(详见打印内报错)。 }
编程案例7输出
看看从管道掏出了什么宝贝?一看: 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对于整个程序并发的“收尾作用”。
编程案例8--利用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 }
编程案例7输出
裁判员:我需要确认检测设备是否顺畅 裁判员:终点净空,监测设备正常,请求村长开赛! 羊村长:我宣布:龟兔赛跑大赛正式开始,冲冲冲! 裁判员:兔子已经上路…… 裁判员:乌龟已经上路…… 兔子:我到了,我触发一下设备 裁判员:兔子发来消息,它已抵达终点 比赛结束,小伙伴们议论纷纷,乌龟为什么这次输了。 (程序未退出)
可以看出,select是一种特殊的基于监听I/O实现case选择执行的结构,不需要单独或额外的无限循环等结构,就可以优雅的实现对协程处理结果的把控。一般说来,将在如下环节使用到select:
- 当需要检查执行结果时,且如果未有结果,程序不阻塞(使用default)
- 当需要等待并检查执行结果时(只使用case,不使用default)
- 只需要等待结果,或者单纯的为了使程序进入阻塞状态(select区块内不添加任何东西)
到现在,你已经了解并掌握了Golang的基本语法,并在我们“再加一点”的指导下,了解了Golang为支持并发要素而特别优化的go关键字并发语法、chan通信方法和select阻塞方法。你需要明白,本节的知识即使完全掌握,也不能说明你对Golang的理解达到精深的地步————毕竟本书并不是为了指导你如何学习Golang。但是,这些内容对于你顺利理解并运用接下来的知识,已经完全足够。
准备好了,让我们接着出发吧。