×

「golang系列」浅谈Go语言

96
star24
2016.05.25 14:29* 字数 3982

导语

Go语言(也称为Golang)是google在2009年推出的一种编译型编程语言。相对于大多数语言,golang具有编写并发或网络交互简单、丰富的数据类型、编译快等特点,比较适合于高性能、高并发场景。本文主要基于笔者的亲身实践和总结,介绍golang 1.3(目前最新版本是1.5)的一些特性,重点介绍并发的实现和使用,希望能引发读者一些启发或兴趣。

1、Golang简介

Go语言(也称为Golang)是google在2009年推出的一种编译型编程语言。相对于大多数语言,golang具有编写并发或网络交互简单、丰富的数据类型、编译快等特点,比较适合于高性能、高并发场景。2015年度的TIOBE指数计算机语言份额排名中,golang排在60+名,16年3月已上升到top 50。

笔者曾于15年上半年在2个实际项目中引入golang 1.3,代码量大概在3000行左右,最大的体会是并发编程入门容易。本文将以1.3来介绍,但最新版本是1.5,其主要改动是在语言级别的实现上用golang替换了C、引入更多lib和tool,而在很多特性的思想上与1.3并无多大差异,事实上两者的发布时间相差不过一年,因此介绍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几个值得一说的特性和精髓,如果读者能从中受到某些启发,就足够了。

2、并发编程

通过实践,笔者认为golang在并发编程方面比绝大多数语言要简洁不少,这一点是其最大亮点之一,也是其在未来进入高并发高性能场景的重要筹码。

不同于传统的多进程或多线程,golang的并发执行单元是一种称为goroutine的协程。协程这个概念已被引入到不少语言中,比如golang、python、lua等。协程经常被理解为轻量级线程,一个线程可以包含多个协程,共享堆不共享栈。协程间一般由应用程序显式实现调度,上下文切换无需下到内核层,高效不少。协程间一般不做同步通讯,而golang中实现协程间通讯有两种:1)共享内存型,即使用全局变量+mutex锁来实现数据共享;2)消息传递型,即使用一种独有的channel机制进行异步通讯。

由于在共享数据场景中会用到锁,再加上GC,其并发性能有时不如异步复用IO模型,因此相对于大多数语言来说,golang的并发编程简单比并发性能更具卖点。

2.1、示例说明

下面是一段用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关键字就行,还能够指定运行核数,以期充分利用机器的计算能力。

2.2、底层实现

golang的m:n线程模型

golang采用了m:n线程模型,即m个gorountine(简称为G)映射到n个用户态进程(简称为P)上,多个G对应一个P,一个P对应一个内核线程(简称为M)。

  • G:一个G就是一个gorountine,保存了协程的栈、程序计数器以及它所在M的信息。程序启动时,会创建一个主G,而每使用一次go关键字也创建一个G。未处理完就挂起的G放入一个全局等待队列中。

  • P:P通过runtime.GOMAXPROCS()可以指定事先生成多少个P,最多不能超过256个,指定完P数目就不变了。一开始P都是空闲的,不挂有任何G。运行时,一个P上面挂N个G,并把这些G存入一个队列,一个P下面对应一个M。

  • M:M数量可能会大于P数量,M数目可以通过pstree查看。程序启动时,会创建第一个M,这个M是监控线程,不是工作线程。

golang实现的协程调度器,其实就是在维护一个G、P、M三者间关系的队列。

2.2.1、同一个M上的P和所有G如何调度

每当一个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来分担。

2.2.2、同一个P上的所有G如何调度

如果一个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处理。

3、网络编程

由于golang诞生在互联网时代,因此它天生具备了去中心化、分布式等特性,具体表现之一就是提供了丰富便捷的网络编程接口,比如socket用net.Dial(基于tcp/udp,封装了传统的connect、listen、accept等接口)、http用http.Get/Post()、rpc用client.Call('class_name.method_name', args, &reply),等等。

4、内存分配

初始化阶段直接分配一块大内存区域,大内存被切分成各个大小等级的块,放入不同的空闲list中,对象分配空间时从空闲list中取出大小合适的内存块。内存回收时,会把不用的内存重放回空闲list。空闲内存会按照一定策略合并,以减少碎片。

5、内存回收(GC)

GC过程是:先stop the world,扫描所有对象判活,把可回收对象在一段bitmap区中标记下来,接着立即start the world,恢复服务,同时起一个专门gorountine回收内存到空闲list中以备复用,不物理释放。物理释放由专门线程定期来执行。

GC瓶颈在于每次都要扫描所有对象来判活,待收集的对象数目越多,速度越慢。一个经验值是扫描10w个对象需要花费1ms,所以尽量使用对象少的方案,比如我们同时考虑链表、map、slice、数组来进行存储,链表和map每个元素都是一个对象,而slice或数组是一个对象,因此slice或数组有利于GC。

GC性能可能随着版本不断更新会不断优化,这块没仔细调研,团队中有HotSpot开发者,应该会借鉴jvm gc的设计思想,比如分代回收、safepoint等。

6、函数多返回值

函数定义时可以在入参后面再加(a,b,c),表示将有3个返回值a、b、c。这个特性在很多语言都有,比如python。

这个语法糖特性是有现实意义的,比如我们经常会要求接口返回一个三元组(errno,errmsg,data),在大多数只允许一个返回值的语言中,我们只能将三元组放入一个map或数组中返回,接收方还要写代码来检查返回值中包含了三元组,如果允许多返回值,则直接在函数定义层面上就做了强制,使代码更简洁安全。

7、语言交互性

语言交互性指的是本语言是否能和其他语言交互,比如可以调用其他语言编译的库。

golang可以和C程序交互,但不能和C++交互,可以有两种替代方案:1)先将c++编译成动态库,再由go调用一段c代码,c代码通过dlfcn库动态调用动态库(记得export LD_LIBRARY_PATH);2)使用swig(没玩过)

8、异常处理

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)。

9、编译

编译涉及到两个问题:编译速度和依赖管理

目前Golang具有两种编译器,一种是建立在GCC基础上的Gccgo,另外一种是分别针对64位x64和32位x86计算机的一套编译器(6g和8g)。笔者感觉编译还是挺快的,在网上也并未找到编译速度有明显缺陷的报告。

依赖管理方面,由于golang绝大多数第三方开源库都在github上,在代码的import中加上对应的github路径就可以使用了,库会默认下载到工程的pkg目录下。

另外,编译时会默认检查代码中所有实体的使用情况,凡是没使用到的package或变量,都会编译不通过。这是golang挺严(qi)谨(pa)的一面。

10、其他一些有趣的特性

  • 类型定义:支持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(),将会导致无限循环调用下去。

12、结语

至此,本文介绍了golang的并发编程、网络编程等大概10个特性,看得出,无论在使用层面还是实现层面,golang引入了不少设计思路,从中能看出业内对其推崇的大道至简精神。不过,作为一门年轻的语言,golang还有很多路要走,未来会如何,期待吧!

13、参考文献

[1] https://github.com/golang
[2]《Go语言编程》 许式伟,吕桂华等编著
[3]《Go学习笔记》第4版
[4] golang中文论坛

技术原创
Web note ad 1