-
Notifications
You must be signed in to change notification settings - Fork 1
浅谈Go语言
导语
Go语言(也称为Golang)是google在2009年推出的一种编译型编程语言。相对于大多数语言,golang具有编写并发或网络交互简单、丰富的数据类型、编译快等特点,比较适合于高性能、高并发场景。本文主要基于笔者的亲身实践和总结,介绍golang的一些特性,重点介绍并发的实现和使用,希望能引发读者一些启发或兴趣。
Go语言(也称为Golang)是google在2009年推出的一种编译型编程语言。相对于大多数语言,golang具有编写并发或网络交互简单、丰富的数据类型、编译快等特点,比较适合于高性能、高并发场景。2015年度的TIOBE指数计算机语言份额排名中,golang排在60+名,19年7月已上升到16名。
笔者曾于15年上半年在2个实际项目中引入golang 1.3,代码量大概在3000行左右,最大的体会是并发编程入门容易。本文将以1.3来介绍,不过golang团队更新太快,19年最新版本已到了1.12,不过核心设计思想与1.3并无大差异。
众所周知,曾经在很长一段时间里,google保持着一个传统,允许员工拥有20%自由时间来开发实验性项目,可惜现在该制度已经废弃。golang正是由一个强大团队利用这20%时间开发的。这里有必要介绍一下该团队的核心成员,个个来头不小,都是计算机领域大神级人物。最大牌的当属B和C语言设计者、Unix和Plan 9创始人、1983年图灵奖获得者Ken Thompson,其以70+岁高龄,不知道在golang实际开发中撰了多少代码......另外,这份名单中还包括了Unix核心成员Rob Pike、java HotSpot虚拟机和js v8引擎的开发者Robert Griesemer、Memcached作者Brad Fitzpatrick,等等。
OK,进入正题,笔者结合个人整理和思考,依次介绍golang几个值得一说的特性和精髓,如果读者能从中受到某些启发,就足够了。
通过实践,笔者认为golang在并发编程方面比绝大多数语言要简洁不少,这一点是其最大亮点之一,也是其在未来进入高并发高性能场景的重要筹码。
不同于传统的多进程或多线程,golang的并发执行单元是一种称为goroutine的协程。协程这个概念已被引入到不少语言中,比如golang、python、lua等。协程经常被理解为轻量级线程,一个线程可以包含多个协程,共享堆不共享栈。协程间一般由应用程序显式实现调度,上下文切换无需下到内核层,高效不少。协程间一般不做同步通讯,而golang中实现协程间通讯有两种:1)共享内存型,即使用全局变量+mutex锁来实现数据共享;2)消息传递型,即使用一种独有的channel机制进行异步通讯。
由于在共享数据场景中会用到锁,再加上GC,其并发性能有时不如异步复用IO模型,因此相对于大多数语言来说,golang的并发编程简单比并发性能更具卖点。
下面是一段用golang写的并发程序:
package main
import {
"fmt"
"runtime"
"time"
}
var MULTICORE int
func main() {
MULTICORE = runtime.NumCPU() //计算出本地的cpu核总数
//指定MULTICORE个核来运行
//这里没有设置cpu亲和性,所以各个线程会在任意cpu核上跑,同一个线程也可能会不断跳到不同核上运行
runtime.GOMAXPROCS(MULTICORE)
// 启动MULTICORE个goroutine来执行test()
for i := 0; i < MULTICORE; i++ {
go test()
}
// sleep 10s是为了让主进程等待所有goroutine都运行退出
time.Sleep(10*time.Second)
}
func test() {
for i := 0; i < 10; i++ {
fmt.Printf("test\n")
}
}
可以看出,启动一个goroutine很容易,只需要在(匿名)函数前面加个go关键字就行,还能够指定运行核数,以期充分利用机器的计算能力。
golang相较于传统语言,为了更好适应现代多核、高并发场景,独创了自己的协程模型,采用m:n模型,即m个gorountine(简称为G)映射到n个用户态上下文(简称为P)上,一个P向上维护多个G组成的一个队列,一个P向下与一个内核态工作线程(简称为M)相绑定。
-
G(Gorountine):一个G可以看成一个执行任务,是对一段待执行golang代码的封装,维护任务执行所需的栈、程序计数器以及它所在M的信息。程序启动时,会创建一个主G,而每使用一次go关键字也创建一个G。未处理完就挂起的G放入一个全局等待队列中。
-
P(Processor):P向上维护了G队列以管理并发任务,向下为M提供执行资源,比如对象分配内存、本地任务队列等。P数量一般建议和CPU核数一致,可以通过runtime.GOMAXPROCS()事先指定生成多少个P,最多不能超过256个,指定完P数目就不变了。一开始P都是空闲的,不挂有任何G。运行时,一个P上面挂N个G,并把这些G存入一个队列,一个P下面对应一个M。
-
M(Machine):M是工作线程,是真正的执行体(G和P不是)。G是要执行的任务,而P维护了G队列,为了执行任务,M必须要绑定P才能执行,M无可用P绑定时,进入休眠状态。M数量可能会大于P数量,M数目可以通过pstree查看。程序启动时,会创建第一个M,这个M是监控线程,不是工作线程。
golang实现的协程调度器,其实就是在维护一个G、P、M三者间关系的队列。
每当一个G要开始执行时,调度器判断当前M的数量是否可以很好处理完G:如果M少G多且有空闲P,则新建一个新M或唤醒一个sleep M,并指定使用某个空闲P;如果M应付得来,G被负载均衡放入一个现有P+M中。
当M处理完其身上的所有G后,会再去全局等待队列中找G,如果没有就从其他P中偷几个G(以便保证各个M处理G的负载大致相等),如果还没有,M就去sleep了,对应的P变为空闲P。
在M进入sleep期间,调度器可能会给其P不断放入G,等M醒后(比如超时):如果G数量不多,则M直接处理这些G;如果M觉得G太多且有空闲P,会先主动唤醒其他sleep的M来分担G,如果没有其他sleep的M,调度器创建新M来分担。
如果一个G不主动让出cpu或被动block,所属P中的其他G会一直等待顺序执行。
一个G执行IO时可能会进入waiting状态,主动让出CPU,被移到所属P中的其他G后面,等待下一次轮到执行。
一个G调用了runtime.Gosched()会进入runnable状态,主动让出CPU,并被放到全局等待队列中。
一个G调用了runtime.Goexit(),该G将会被立即终止,然后把已加载的defer(有点类似析构)依次执行完。
一个G调用了允许block的syscall,此时G及其对应的P、其他G和M都会被block起来,监控线程(上图深绿块,由程序启动时自动创建)会定时扫描所有P,一旦发现某个P处于block syscall状态,则通知调度器让另一个M来带走P(这里的另一个M可能是新创建的,因此随着G被不断block,M数量会不断增加,最终M数量可能会超过P数量),这样P及其余下的G就不会被block了,等被block的M返回时发现自己的P没有了,也就不能再处理G了,于是将G放入全局等待队列等待空闲P接管,然后M自己sleep。
通过实验,当一个G运行了很久(比如进入死循环),会被自动切到其他CPU核,可能是因为超过时间片后G被移到全局等待队列中,后面被其他CPU核上的M处理。
由于golang诞生在互联网时代,因此它天生具备了去中心化、分布式等特性,具体表现之一就是提供了丰富便捷的网络编程接口,比如socket用net.Dial(基于tcp/udp,封装了传统的connect、listen、accept等接口)、http用http.Get/Post()、rpc用client.Call('class_name.method_name', args, &reply),等等。
初始化阶段直接分配一块大内存区域,大内存被切分成各个大小等级的块,放入不同的空闲list中,对象分配空间时从空闲list中取出大小合适的内存块。内存回收时,会把不用的内存重放回空闲list。空闲内存会按照一定策略合并,以减少碎片。
GC过程是:先stop the world,扫描所有对象判活,把可回收对象在一段bitmap区中标记下来,接着立即start the world,恢复服务,同时起一个专门gorountine回收内存到空闲list中以备复用,不物理释放。物理释放由专门线程定期来执行。
GC瓶颈在于每次都要扫描所有对象来判活,待收集的对象数目越多,速度越慢。一个经验值是扫描10w个对象需要花费1ms,所以尽量使用对象少的方案,比如我们同时考虑链表、map、slice、数组来进行存储,链表和map每个元素都是一个对象,而slice或数组是一个对象,因此slice或数组有利于GC。
GC性能可能随着版本不断更新会不断优化,这块没仔细调研,团队中有HotSpot开发者,应该会借鉴jvm gc的设计思想,比如分代回收、safepoint等。
函数定义时可以在入参后面再加(a,b,c),表示将有3个返回值a、b、c。这个特性在很多语言都有,比如python。
这个语法糖特性是有现实意义的,比如我们经常会要求接口返回一个三元组(errno,errmsg,data),在大多数只允许一个返回值的语言中,我们只能将三元组放入一个map或数组中返回,接收方还要写代码来检查返回值中包含了三元组,如果允许多返回值,则直接在函数定义层面上就做了强制,使代码更简洁安全。
语言交互性指的是本语言是否能和其他语言交互,比如可以调用其他语言编译的库。
golang可以和C程序交互,但不能和C++交互,可以有两种替代方案:1)先将c++编译成动态库,再由go调用一段c代码,c代码通过dlfcn库动态调用动态库(记得export LD_LIBRARY_PATH);2)使用swig(没玩过)
golang不支持try...catch这样的结构化的异常解决方式,因为觉得会增加代码量,且会被滥用,不管多小的异常都抛出。golang提倡的异常处理方式是:
-
普通异常:被调用方返回error对象,调用方判断error对象。
-
严重异常:指的是中断性panic(比如除0),使用defer...recover...panic机制来捕获处理。严重异常一般由golang内部自动抛出,不需要用户主动抛出,避免传统try...catch写得到处都是的情况。当然,用户也可以使用panic('xxxx')主动抛出,只是这样就使这一套机制退化成结构化异常机制了。
defer类似java的finally,可以延迟执行代码,函数执行完时,将自动执行defer里的代码。一种场景,我们new完要delete,但有时会忘记delete,更严重的情况是重复delete(比如在一个函数里多次delete相同的对象,或不同函数中对同一个对象进行delete),defer提供了一种可能避免重复delete,比如new下一行马上就加defer delete,等函数执行完时自动执行defer里的代码,这样能极大避免我们把delete写得到处都是然后导致重复delete,因为我们只要记得new完立马加defer delete,就不用在其他地方再加delete了(当然,前提是你要记得加defer delete)。
编译涉及到两个问题:编译速度和依赖管理
目前Golang具有两种编译器,一种是建立在GCC基础上的Gccgo,另外一种是分别针对64位x64和32位x86计算机的一套编译器(6g和8g)。笔者感觉编译还是挺快的,在网上也并未找到编译速度有明显缺陷的报告。
依赖管理方面,由于golang绝大多数第三方开源库都在github上,在代码的import中加上对应的github路径就可以使用了,库会默认下载到工程的pkg目录下。
另外,编译时会默认检查代码中所有实体的使用情况,凡是没使用到的package或变量,都会编译不通过。这是golang挺严(qi)谨(pa)的一面。
-
类型定义:支持
var abc = 10
这样的语法,让golang看上去有点像动态类型语言,但golang实际上时强类型的,前面的定义会被自动推导出是int类型 -
一个类型只要实现了某个interface的所有方法,即可实现该interface,无需显式去继承
-
不能循环引用:即如果a.go中import了b,则b.go要是import a会报import cycle not allowed。好处是可以避免一些潜在的编程危险,比如a中的func1()调用了b中的func2(),如果func2()也能调用func1(),将会导致无限循环调用下去。
至此,本文介绍了golang的并发编程、网络编程等大概10个特性,看得出,无论在使用层面还是实现层面,golang引入了不少设计思路,从中能看出业内对其推崇的大道至简精神。不过,作为一门年轻的语言,golang还有很多路要走,未来会如何,期待吧!
- https://github.com/golang
- 《Go语言编程》 许式伟,吕桂华等编著
- 《Go学习笔记》
- golang中文论坛