为什么选择Go? 引用Gokit官网的一段原文:
Go is designed from first principles to advance the practice of software engineering. It’s easy to learn, easy to master, and — most importantly — easy to maintain, by large and dynamic teams of engineers. And with highly-efficient concurrency, an expansive standard library, and a steadily-improving runtime, it’s practically the perfect language for writing microservices.
提到为什么选择Go,Gopher会有很多理由,比如它优秀的出身,Go的作者之一是C语言和Unix之父-Ken Thompson,网传Ken对Go的定位是Better C,当然诞生自Google也是另一个优秀基因的证明; 再如它的易于上手和使用,优于C的编译速度,并行和Channel的设计,丰富的第三方开源组件和优秀社区,当然在这一点上,其他语言如python、php也不甘示弱,当然还有它优秀的执行效率。
当然Go也有很多另人不满的地方,有一些是习惯性问题,比如“不允许左括号另起一行”、 “行号默认加分号”、 “变量名和变量类型的定义顺序”等,网上有一篇很火的文章“我为什么放弃Go语言”,文中列举了Go和Go社区的N宗罪。 当然Go最为人诟病的应该就是GC问题,幸运的时在1.6之后,这个问题已经基本解决了,不过任何语言都有自己的应用场景,对于时延要求一定<1ms的场景,目前的测试数据Go还是不太推荐的。
我选择Go的原因,主要有以下三个:
- 优秀的并行效率和channel机制
Go的routine设计十分优雅,当然这种优雅是建立在运行时高效基础之上的。Go的并行能力非常的出色,所以下面会有专门一个小节去介绍Go这方面的能力。
- Web服务友好
Go的http库十分强大,只需要几行代码就可以搭建一个http服务,而且性能是传统FASTCGI的3-5倍,后面会有详细的测试数据。 有了这种能力,我们可以非常便捷的在任何Service\MicroService中增加http服务,用于查看当前服务的运行情况,如长连接数、并发数、处理时延、内存处理量等。
- 好奇心+业界成功产品及前景
最重要的一点是好奇心,另外是看到Go在业界越来越被广泛的认可,相信未来工作中也会产出价值。Go有很多成功的产品,国外出名的开源项目如Docker,Nats,Beego等,国内Go的开源项目并不多,但商用项目应该在不断的普及开来。国内最早的商用产品应该是七牛云,很多中文书籍都是由七牛云的许式伟先生牵头翻译的,Go在七牛云中也被大规模使用,另外随着云计算的普及,越来越多提供云服务的公司,比如腾讯云、金山云等也在使用Go。其他的一些领域如web服务、在线游戏也有不少人开源尝试,如西山居出品的剑3就是采用Go语言,腾讯游戏的官网也逐渐开始使用Go替换传统的Apache减少Web层的机器数。有理由相信,Go语言的特点已经在某些特定的业务场景中成为最佳实践。
在介绍GoRoutine前,我们再回顾下并发和并行的概念。提到并发,一般的认识是“同时做很多事情”,比如常见的程序设计中单进程单线程异步服务器,可以“同时处理”多个任务,在客户端角度来看是并发的,但实质上只用到一个内核线程,我们可以认为这是“并发”而非“并行”。如果改为单进程多线程服务,而多个任务之间没有依赖关系,各个线程在多个内核线程上独立执行,我们一般称之为“并行”。
关于并发和并行,Go大神RobPike的一篇文章“Concurrency is not Parallelism”,使这个定义更加的清晰。文中提到的核心观点是“并发关乎设计,并行关乎执行”。
并发(Concurrency): 同时处理很多事情,将程序分解成小片段独立执行的程序设计方法,关乎设计。 Concurrency is dealing a lot of things at once. It is about structure.
并行 (Parallelism): 同时完成很多事情,关乎执行。 Parallelism is doing a lot of things at once. It is about excution.
在RobPike的演讲中,核心观点就是“Concurrency is not Parallelism, but better!”
Go1.5之前,GOMAXPROCS默认设置为1,即Go程序在执行时只能使用一个内核线程,并发而没有并行,在1.5之后GOMAXPROCS默认设置为CPU核数,即在多核机器上,Go程序在运行期“可能”是并行的,这里说“可能”是因为依赖于程序的设计是不是符合并发的理念。
当然,Go也支持在运行时指定使用的CPU核心数量,这里我们使用flag库来获取命令行参数,使用runtime库来设置运行时使用的CPU核心数。
import (
"flat"
"runtime"
)
in main() {
var numCores = flag.Int("n", 2, "number of CPU cores to use")
flag.Parse()
runtime.GOMAXPROCS(*numCores)
}
上面提到Go有丰富的库以及对web服务十分友好,下面我们先来看一下,如何写一个webserver.
import (
"fmt"
"log"
"net/http"
)
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "URL.Path = %q\n", r.URL.Path)
}
func main() {
http.HandleFunc("/index/", handler)
ipport := "10.12.234.153:8080"
err := http.ListenAndServe(ipport, nil)
if err != nil {
log.Fatal("failed to start http, bind on ", ipport, err)
}
}
是不是很简单,这里在浏览器里输入http://10.12.234.153:8080/index/,webserver会返回URL.Path = “/index/”。
如果我们需要加入一些统计信息,比如讨论服务器启动后一共接收到多少个请求,当然,实现的方法有很多,这里我们采用一个变量来进行累加,为了避免出现竞争写的情况,使用互斥量加锁,代码如下:
package main
import (
"fmt"
"log"
"net/http"
"sync"
)
var mu sync.Mutex
var count int
func handler(w http.ResponseWriter, r *http.Request) {
mu.Lock()
count++
mu.Unlock()
fmt.Fprintf(w, "URL.Path = %q\n", r.URL.Path)
}
func stat(w http.ResponseWriter, r *http.Request) {
mu.Lock()
fmt.Fprintf(w, "Count %d\n", count)
mu.Unlock()
}
func main() {
http.HandleFunc("/index/", handler)
http.HandleFunc("/stat/", stat)
ipport := "10.12.234.236:8080"
err := http.ListenAndServe(ipport, nil)
if err != nil {
log.Fatal("failed to start http, bind on ", ipport, err)
}
}
为什么要加锁?可能会有这种疑问,原因是Go会为每一个http请求启动一个独立的routine,这样服务器就可以在同一时间处理多个请求,达到并发的能力,因此就需要避免多个routine对count同时进行读写,采用了常用的互斥量机制。 当然这里还有其他的方式进行保证,比如可以用原子增的方式处理,我们知道在32位系统中,int32的赋值、增、减可以实现原子操作,这里引入atomic库,可以做以下改造,代码也会更加简洁。
package main
import (
"fmt"
"log"
"net/http"
"sync/atomic"
)
var count int32
func handler(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&count, 1)
fmt.Fprintf(w, "URL.Path = %q\n", r.URL.Path)
}
func stat(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Count %d\n", atomic.LoadInt32(&count))
}
func main() {
atomic.StoreInt32(&count, 0)
http.HandleFunc("/index/", handler)
http.HandleFunc("/stat/", stat)
ipport := "10.12.234.153:8080"
err := http.ListenAndServe(ipport, nil)
if err != nil {
log.Fatal("failed to start http, bind on ", ipport, err)
}
}
上面的代码看上去,简洁很多,但上面的统计维度,对一个webserver还远远不够,比如,运维人员不只关于服务启动以后的总请求量,更多时候希望能关注到当前的负载,比如类似近5秒钟的请求总量这样的统计维度,以及内存使用情况等,更能说明当前的服务能力和健康情况,这里我们针对上面的代码增加一些统计信息。
package main
import (
"fmt"
"log"
"net/http"
"sync/atomic"
"time"
)
type Stats struct {
req_total int32 //总请求量
req_last_5s int32 //上一个5S的请求总个数
req_cur_5s int32 //当前5S的请求总个数
}
var stats Stats
//5秒定时器
func Timer5s() {
timer5s := time.NewTicker(5 * time.Second)
select {
case <-timer5s.C:
//保存当前5秒数据,用于统计显示
atomic.StoreInt32(&stats.req_last_5s, stats.req_cur_5s)
atomic.StoreInt32(&stats.req_cur_5s, 0)
go Timer5s()
}
}
func handler(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&stats.req_total, 1)
atomic.AddInt32(&stats.req_cur_5s, 1)
fmt.Fprintf(w, "URL.Path = %q\n", r.URL.Path)
}
func stat(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "ReqTotal %d\n", atomic.LoadInt32(&stats.req_total))
fmt.Fprintf(w, "ReqLast5s %d\n", atomic.LoadInt32(&stats.req_last_5s))
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Fprintf(w, "Mem %d kb\n", m.Alloc/1024)
}
func gc(w http.ResponseWriter, r *http.Request) {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Fprintf(w, "gc mem before: %d kb\n", m.Alloc/1024)
runtime.GC()
runtime.ReadMemStats(&m)
fmt.Fprintf(w, "gc mem after: %d kb\n", m.Alloc/1024)
}
func main() {
//初始化
atomic.StoreInt32(&stats.req_total, 0)
atomic.StoreInt32(&stats.req_last_5s, 0)
atomic.StoreInt32(&stats.req_cur_5s, 0)
//设置定时器,用于数据统计
go Timer5s()
http.HandleFunc("/index/", handler)
http.HandleFunc("/stat/", stat)
http.HandleFunc("/gc/", gc)
ipport := "10.12.234.153:8080"
err := http.ListenAndServe(ipport, nil)
if err != nil {
log.Fatal("failed to start http, bind on ", ipport, err)
}
}
在这个例子中,我们用time库结合goroutine实现了一个5s的定时器,主要用于每隔5s把最近5秒的请求量req_cur_5s备份到req_last_5s中用于页面展示,并把当前5s的请求累计变量req_cur_5s清0。
routine的使用看上去是不是很简单,事实上了是如此,但这里我们先不详细简介routine的使用,先来看下上面的websrv的性能测试。
在上面的例子中,我们使用了一个routine,并且用select的模式监听一个定时器的结束,进行了一些统计,下面我们用apache自带的ab工具进行一下测试。测试方法是并发5000,共计5w的请求量,来看下处理时延和最大的平均处理能力,测试客户端和服务器运行在两台同机房独立物理机上。
Benchmarking 10.12.234.236 (be patient)
Completed 5000 requests
Completed 10000 requests
Completed 15000 requests
Completed 20000 requests
Completed 25000 requests
Completed 30000 requests
Completed 35000 requests
Completed 40000 requests
Completed 45000 requests
Completed 50000 requests
Finished 50000 requests
Server Software:
Server Hostname: 10.12.234.236
Server Port: 8080
Document Path: /index/
Document Length: 21 bytes
Concurrency Level: 5000
Time taken for tests: 8.686 seconds
Complete requests: 50000
Failed requests: 0
Write errors: 0
Total transferred: 6900828 bytes
HTML transferred: 1050126 bytes
Requests per second: 5756.38 [#/sec] (mean)
Time per request: 868.601 [ms] (mean)
Time per request: 0.174 [ms] (mean, across all concurrent requests)
Transfer rate: 775.86 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 36 103 163.7 70 1095
Processing: 0 229 825.4 72 8483
Waiting: 0 203 811.8 54 8483
Total: 79 331 845.5 144 8594
Percentage of the requests served within a certain time (ms)
50% 144
66% 149
75% 154
80% 198
90% 231
95% 1122
98% 3572
99% 3607
100% 8594 (longest request)
ab测试的结果显示,平均每秒处理请求在5756个,90%的时延在231ms以内。我们看下服务器当时的负载情况如下, 服务器CPU占用在35%-45%之间,因为手头没有更多的物理机测试,但按预估,吞吐量达到1W/s是没有问题的。
procs -----------memory---------- ---swap-- -----io---- --system-- -----cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
0 0 122320 7752188 659036 4753532 0 0 0 12 0 0 0 0 99 0 0
4 0 581344 6411088 478856 6652080 0 0 0 20 47676 15450 6 8 85 0 0
1 0 581344 6411736 478856 6652080 0 0 0 16 137013 37817 20 27 52 0 1
3 0 581344 6410812 478856 6652080 0 0 0 8 140511 37157 18 27 54 0 1
0 0 581344 6410896 478864 6652072 0 0 0 72 89958 24302 13 20 65 0 1
受限于只有一台客户端物理机器,我们设置10w总量,5000的并发,但这时ab无法输出正确结果,只能在浏览器中查看服务器的监控数据,开始运行后5S后,在浏览器中输入http://10.12.234.153:8080/stat/, 得到以下结果:
ReqTotal 181328
ReqLast5s 52972
Mem 13124 kb
另外,在上面的例子中,我们也可以通过调用runtime.gc(),显式的触发垃圾回收,但这种方式只在内存资源不足的情况下使用,它会在函数的执行点的立即释放一大片内存。例如,我们执行http://10.12.234.153:8080/gc/,输出如下信息:
gc mem before: 13126 kb
gc mem after: 508 kb
5.1 GoRoutine 如果你熟悉线程概念的话,那么只需要像使用线程一样去使用goroutine就好,差异就在goroutine更轻量,可以在一个物理机上启动十万级以上的goroutine,而不用太担心开销问题。启动一个goroutine十分简单,常见的单线程同步代码块如下:
func f(){
}
func main(){
f();
while(true){
}
}
这样,程序执行到f()后会等待f()执行完闭后再返回,但如果改为
//f();
go f();
程序执行到go f()时,会启动一个goroutine执行,而主goroutine会立刻返回,继续执行后续代码,用法是不是很简单。 5.2Goroutine和线程的差异 goroutine的使用和线程十分的接近,但为何可以如此的轻量,这里的差异主要有两个原因: 一、栈空间大小,Linux系统中,线程有独立的栈空间,栈空间大小固定(一般是2M),我们可以通过可以在调用 pthread_create 的时候用pthread_attr_getstacksize 设置栈的大小,或者直接用 ulimit -s 设置栈的大小, 但在goroutine中,采用一般动态伸缩的机制,一个goroutine一般采用一个很小的栈开始生理周期,一般只需要2KB,根据需要进行伸缩,最大可以达到1GB,这一点和C相比也更加灵活。 二、线程间切换由内核调度,每次切换需要保存完整的上下文,而Go运行了自己的调度器,一般一个goroutine调用了sleep、或者是阻塞在channel上叶,调度器会使其休眠本按照策划去唤醒另一个goroutine,这种机制不需要进入内核的上下文,因此代价也会更低。
5.3 Channel Go语言中有多种方式在多个goroutne之间共享数据,比如上面例子中采用的内存变量等,但最符合Go语言设计哲学的应该还是用Channel的机制。Channel的首要功能是数据通信(Communication),但除此之外,还可以实现如“监听一个goroutine是否退出”、“事件通知”,如在Nats中,main goroutine负责创建接收goroutine的socket,goroutine创建后直接阻塞在channel的数据接收上,main goroutine创建后,通过channel通知接收goroutine开始从socket中接收数据。 另外,Go的Channel使用十分的便捷,写入和读出不需要做序列化和反序列化的过程,直接把一个结构对象“塞入”即可,下面这个例子将展示这方面的能力。
package main
import (
"fmt"
)
type chatMsg struct{
id int
msg string
}
func chanWrite(intch chan int, msgch chan chatMsg) {
x := 1
intch <- x
var msg chatMsg
msg.id = 2
msg.msg = "hello"
msgch <- msg
}
func chanRead(intch chan int, msgch chan chatMsg, done chan struct{}) {
x := <-intch
msg := <-msgch
fmt.Printf("%d %d %s\n", x, msg.id, msg.msg)
close(done)
}
func main() {
intch := make(chan int)
msgch := make(chan chatMsg)
defer close(intch)
defer close(msgch)
var done = make(chan struct{})
go chanRead(intch, msgch, done)
go chanWrite(intch, msgch)
<- done
}
在上面的例子中,main goroutine启动后,创建了两个通道,一个传输int类型,另一个传输结构体,然后创建了一个命名为done的通道,用来阻塞main goroutine,在两个子goroutine中分别实现数据的写入和读出,并在数据读出后关闭done channel,最终进程退出。