Go Introduction
目录
语言基础
基础语法
语言特性
从Go语言的演化来看,可以分为三条主线:从C语言继承了基本数据类型、表达式语法、指针与函数。从Pascal-Oberon-2继承了包管理和面向对象声明语法。从CSP中继承管道通信。
支持特性:代码规范 多返回值 静态类型 符合类型 匿名函数 垃圾回收 包管理系统 UTF8字符串 闭包 系统调用
不支持特性:隐式类型转换 静态变量 指针算术 函数/运算符重载 默认参数 函数修饰构造/析构函数 继承 多态/泛型 异常 宏 线程局部存储 动态链接库
另外Go语言的注释并不总是注释,例如导入C包时,注释可以用来写C语言代码,这就导致注释不能随意清理。
保留关键字
keywords: break default func interface select case defer go map struct chan else goto package switch const fallthrough if range type continue for import return var constant: true false iota nil type: int int8-64 uint8-64 float32-64 complex64-128 bool byte rune uintptr string error function: make len cap new append copy close delete complex real imag panic recover
数据类型
- 基本类型
变量声明时变量名在类型名之前,支持多变量定义及赋值,多变量赋值的要点是先计算后赋值。简短声明需要注意作用域问题,在内嵌块中用简短声明同名变量时会产生新的变量,这种特性有可能产生很难调试的问题。
整数类型
int
的长度由CPU而定,另一个和机器相关的类型是uintptr
。字符类型rune
等价于uint32
,同样byte
也是int8
的等价类型。Go语言不支持隐式类型转换,即便是整型向布尔转换也不行。对于无类型常量,编译器提供至少32字节的运算精度,这个精度大于Go语言中整型量。将无类型常量复制给变量时,可能会发生隐式转换,因为无类型常量也可以认为有一个最佳匹配类型。
字符串被设计为不可变对象,也就是不能修改字符,字符串末尾没有空字符。字符串支持切片操作。用
``
定义的字符串不对转义字符转义,支持跨行,这种形式称作原生字符串。要修改字符串可以转换为[]rune
或[]byte
数组再修改,转换会复制内存。遍历字符串用range
完成,要按字节遍历可以直接用下标访问。用len()
对字符串计算长度得到的是字符串的字节数。数组被设计为数值类型,赋值和参数传递会复制整个数组。指针数组
[n]*T
和数组指针*[n]T
表达上看星号位置。因为数组是值传递,所以实际上很少会用到,大多情况都使用切片。指针不支持算术运算,但支持解引用和成员操作符,成员操作符统一用点操作,箭头不再具有取成员的功能。比较特别的是返回局部变量的指针是安全的,这种情况编译器会做逃逸分析并视情况选择是否在堆上分配。
函数
new()
用于创建对象,并返回一个指针。由于返回自动变量的指针是安全的,所以new()
本身可有可无,好处是可以让代码更简洁一点。关键字
type
用于定义新类型,和C语言的typedef
相似。但是需要强调即便是用同一个类型衍生的两个类型,也表示不同的类型,因此相互之间不能直接比较和运算。 - 复合类型
切片类型和数组在形式上的区别是切片没有指定长度,并且切片不能像数组一样直接进行比较。对数组执行切片操作,就能得到一个切片类型,为了节省内存,切片之后并不会复制内存。
struct slice { byte* array; // actual data uintgo len; // number of elements uintgo cap; // allocated number of elements }; array := [...]int{0, 1, 2, 3, 4, 5, 6} slice := array[1:4:5] // [beg:end:cap]
- append(s, x…)
- 用于向slice尾部添加数据,超出cap就会重新分配并复制。
- copy(dst, src)
- 复制以长度小的为准,复制本身不会分配内存
对切片是否为空的测试应利用长度来判断,而不是直接和
nil
比较。因为存在长度为0但是本身却不为nil
的情况。映射是基于哈希表的引用类型,支持index、delete操作。索引取回的是复制品,对成员修改并不会作用到映射中的数据,只能是用一个新的值去替换。迭代时可以删除,但是不要新增。
过程控制
- 表达式
Go语言在运算符优先级上也做了较大简化,如下所示,一共只有五级。
% * / << >> &(AND) &^ + - |(OR) ^(NOT, XOR) == != < <= > >= && ||
对于
&^
更倾向于认为他们是两个操作的叠加,将^
解释为单目按位取反操作。逻辑操作&&
和||
可以更形象比作逻辑乘法和逻辑加法。乘除法运算时:若两个参数都是整数,则执行整数运算,否则执行浮点数运算。取模的原则是:结果的符号和被取模的数符号一致。
算术运算溢出时,高位被丢弃。有符号类型情况比较复杂,如对于
int8
执行127 + 1
得到的结果将是-128
。不支持运算符重载,另外注意
++
和--
是语句,而不是表达式。按位取反运算采用^
算符,而不是C语言中的~
算符。 - 语句
对分支语句添加了初始化语句支持,没有
?:
操作符。if init; condition { statements } else if condition { statements } else { statements } switch expr { case value: statements default: statements } switch { case condition: statements default: statements }
迭代语句
range
所返回的对象是复制对象,而不是原生对象,当使用引用类型时,底层数据不会被复制。Go语言对for语句做了大量重载工作,将原来属于while语句的写法都移植到for语句中了。
for idx, item := range seqs { // like for in Python statements } for init; condition; post { // like for in C++ statements } for condition { // like while statements } for { // like while(1) statements }
迭代语句
range
对不同的序列返回不同:string (i, s[i]) array/slice (i, a[i]) map (key, m[key]) channel e
分支语句
switch
不再需要写break
了,要继续则要显示注明fallthrough
。不带表达式的裸swtich
语句可以看作是更清爽的if...else if...
语句。跳转语句break/continue支持带标签形式。
- 函数
函数不支持嵌套、重载和默认参数。但支持匿名函数和闭包。
变参本质上是切片,只能放于最后。使用切片作为变参时,需要展开为
s...
。多返回值只能用多变量接收,不能用容器对象接收,但是可以直接传递给函数参数。对于命名返回参数,如果被局部变量遮蔽,就需要显式返回。
func add(x, y int) (z int) { { var z = x + y return // ERROR: z is shadowed during return return z } }
命名返回参数支持
defer
延迟通过闭包修改。func add(x, y int) (z int) { defer func() { z += 100 }() z = x + y return }
匿名函数可以在channel中传送。
fc := make(chan func() string, 2) fc <- func() string { return "Hello, World!" } println((<-fc)())
多个
defer
采用FILO顺序执行。延迟调用主要负责释放资源和错误处理。不要滥用defer
功能,滥用defer功能容易引发性能问题。用
panic
抛出错误,用recover
修复错误。对recover
的调用有非常严格的限制,必须在defer
函数内直接调用才会终止错误,其它地方调用仅返回nil。当匿名函数需要递归调用时,需要先声明变量:
var visitAll func(items []string) visitAll = func(items []string) { visitAll(m[item]) // ok }
visitAll := func(items []string) { visitAll(m[item]) // compile error: undefined: visitAll }
在循环中使用匿名函数时要格外小心,下面的问题在于循环变量的作用域,当运行匿名函数时循环已经结束,此时所有线程都是针对最后一个值进行操作:
var rmdirs []func() for _, dir := range tempDirs() { os.MkdirAll(dir, 0755) // ok rmdirs = append(rmdirs, func() { os.RemoveAll(dir) // NOTE: incorrect, dir is the always last one! }) } for _, rmdir := range rmdirs { rmdir() }
正确的操作是:在循环内部声明一个变量,或者定义带参数的匿名函数。
面向对象
- 数据
结构体定义用如下格式:
type Newtype struct { member Int; } var temp Newtype; // define variable
结构体顺序初始化必须包括全部字段,结构体支持匿名字段,匿名成员的真正用途是提供对基类方法调用的简短写法,因此不要滥用匿名成员。
- 方法
方法只不过多了一个接收对象,可以理解为函数的第一个隐式参数。对于方法的查找,要注意不支持多级指针查找,也就是说想通过多级指针调用方法,就必须显式声明接收对象为多级指针。
通过匿名字段可以获得和继承类似的复用能力,利用编译器查找次序可以实现重写。
对方法调用有两种形式:
instance.method(args...) // method value <type>.func(instance, args...) // method expression
method value会复制接收对象,下面的代码中,如果
Print()
的接收对象是指针,将打印修改后的值,如果Print()
的接收对象是值,将打印修改前的值。func main() { u := User{1, "Tom"} uprint := u.Print // copy receiver u.id, u.name = 2, "Jack" uprint() }
- 接口
接口本质上就是方法的集合,只要一个类实现了接口中的所有函数,那么就是提供了该接口。如下所示,所有动物都可以定一个Speak()接口。
type Animal interface { Speak() string } func (c Cat) Speak() string { return "Meow!" } func (j JavaProgrammer) Speak() string { return "Design patterns!" }
项目管理
标准库
包名 | 说明 |
---|---|
bufio | 有缓冲的I/O操作 |
bytes | 对[]byte操作,类似strings包 |
类似IO缓冲,也实现了Reader/Buffer基于[]byte的缓冲 | |
builtin | 内建标识符文档 |
database.sql | 提供SQL数据库接口 |
encoding | 字节/文本转换接口:base64 binary csv json |
errors | 实现了创建错误值的函数 |
expvar | 提供公共变量的标准接口,读写操作都是原子级的 |
flag | 命令行标签解析 |
fmt | 实现了类似C语言printf和scanf的格式化I/O |
io | 对I/O原语的基本接口 |
io.ioutil | 实现I/O常用辅助函数 |
log | 简单日志服务 |
log.syslog | 提供一个简单的系统日志服务的接口 |
math.rand | 伪随机数生成器 |
net | 提供了可移植的网络I/O接口 |
包括TCP/IP、UDP、域名解析和Unix-socket | |
net.http | HTTP客户端和服务端的实现 |
net.http.pprof | HTTP服务返回runtime的统计数据 |
os | 操作系统函数的不依赖平台的接口 |
os.exec | 执行外部命令 |
path | 实现了对斜杠分隔的路径的实用操作函数 |
path.filepath | 兼容各操作系统的文件路径的实用操作函数 |
reflect | 运行时反射 |
regexp | 正则表达式搜索 |
sort | 为切片及用户定义的集合的排序操作提供了原语 |
strconv | 基本数据类型和其字符串表示的相互转换 |
strings | 用于操作字符的简单函数 |
sync | 提供了互斥锁这类的基本的同步原语 |
sync.atomic | 提供了底层的原子性内存原语 |
time | 时间的显示和测量用的函数 |
unsafe | 包含有关于Go程序类型安全的所有操作 |
环境工具
常用环境变量如下:
- GOROOT
- 安装位置
- GOARCH
- 目标处理器架构
- GOOS
- 目标主机操作系统
- GOBIN
- 编译器安装位置
- GOHOSTOS
- 目标机器操作系统
- GOHOSTARCH
- 目标机器处理器架构
- GOPATH
- 主路径
常用工具和命令
go get -u -v github.com/nsf/gocode go get -u -v github.com/rogpeppe/godef go get -u -v github.com/golang/lint/golint go get -u -v golang.org/x/tools/cmd/...
go version # 查看版本 go env # 查看环境变量 go list # 列出当前安装的包 godoc -http=:4001 # 生成本地文档 gofmt -w -s -e . # 格式化代码 go get -u -v package # 下载并更新包 go tool fix # 优化更新老版本代码 go tool vet dirs|files # 代码检查 go tool vet -shadow dirs|files # 检查变量覆盖 go test # 运行测试
依赖管理
每个包对应一个独立名字空间,包内的名字如果以大写字符开头那么该名字就是导出的。如果文件被声明为package main
就是指定该文件为主程序,当一个包包含多个文件时,构建工具根据文件名排序发给编译器,编译器再逐个初始化。包文件中可以定义一个init()
函数来指定初始化顺序。
导入包的几种写法:
import "sample/test" // test.A import M "sample/test" // M.A import . "sample/test" // A import _ "sample/test" // 仅执行初始化函数
质量管理
测试文件需要命名为:[^.]*_test.go
。测试函数分为功能测试和性能测试:
func TestAdd1(t *testing.T) func BenchmarkAdd1(t *testing.T)
性能测试时,如果想跳过准备阶段,可以在准备之前停止计时器:
func BenchmarkAdd1(b *testing.B) { b.StopTimer() DoPreparation() b.StartTimer() for i := 0; i < b.N; i++ { Add(1, 2) } }
调试工具
使用pprof只需要两行代码
import _ "net/http/pprof" go http.ListenAndServe("0.0.0.0:8888", nil)
然后可以通过http://IP:PORT/debug/pprof
查看调试信息,也可以用pprof工具生成CPU状态分析图:
# 收集30s数据 go tool pprof http://IP:PORT/debug/pprof/profile (pprof) top10 # CPU占用最多的程序 (pprof) web # 生成调用图,需要graphviz (pprof) help # 查看帮助信息
编程实践
并发编程
并发编程
在语言层面提供了goroutine
,单个进程可以执行成千上万的并发任务,提供channel
来通讯。
channel
在内部实现提供了同步机制,能确保并发安全,默认采用同步模式,接收和发送必须配对,否则一直阻塞。
func main() { data := make(chan int) // 数据交换队列 exit := make(chan bool) // 退出通知 go func() { for d := range data { // 从队列迭代接收数据,直到 close fmt.Println(d) } fmt.Println("recv over.") exit <- true // 发出退出通知 }() data <- 1 // 发送数据 data <- 2 data <- 3 close(data) // 关闭队列 fmt.Println("send over.") <-exit // 等待退出通知 }
异步方式通过缓冲区来决定是否阻塞,利用缓冲区来提供效率,另一方面应该考虑指针来规避大数据复制。内置函数cap()可以获取缓冲大小,len()可以获取有效元素个数,在并发程序len()的值会不准确。
对已关闭的channel发送数据会引发panic,从已关闭channel接收数据会返回零值,所以关闭应该由生产方控制,接受方也可以通过第二个返回值判断信道是否被关闭,或者用range迭代来自动退出关闭信道。
对于nil channel,收发都会阻塞。
c := make(chan int, 3) var send chan<- int = c // send only var recv <-chan int = c // recv only send <- 1 <-recv
多信道选择用select完成:
for { select { case v, ok = <-a: s = "a" case v, ok = <-b: s = "b" } if ok { fmt.Println(s, v) } else { os.Exit(0) } } for i := 0; i < 5; i++ { select { case a <- i: case b <- i: } }
并发编程的通常做法是建立一个工人组(线程池),他们随时等待活干。有任务时,将任务放到任务池,工人从任务池获取任务。
Go语言runtime包有一个配置项GOMAXPROCS,可以配置Go语言线程最大数量。 Go的线程模型,有 M/P/G 三种对象,分别代表操作系统线程、协程执行令牌、协程;在任何情况下,goroutines数量是小于等于P的数量的。
如果持有P的M因syscall被阻塞,Go调度器需要很久才会找个空闲的M来抢走P。操作系统本身是灵敏的,一旦发现M被阻塞就会调度挂起,但是Go调度器为了减少干预,不会立刻把M持有的P抢走。
这就是为何GOMAXPROCS太小,也就是P的数量太少,会导致IO密集(或者syscall较多)的程序运行缓慢的原因。
如果GOMAXPROCS很大,如超过硬件线程的8倍,也是有开销的,但是远小于Go运行时迟钝的调度M来抢夺P而导致CPU利用不足的开销。
睡眠定时
打点器和time.Sleep()都是借助定时器实现的,其中time.Sleep()包括三步:
- 创建定时器
- 挂起当前进程
- 定时器到期时,使当前进程就绪
这些功能都由time包中提供,基本用例如下,需要注意timer.Stop()和ticker.Stop()不会关闭信道。
func main() { timeChan := time.NewTimer(time.Second).C tickChan := time.NewTicker(time.Millisecond * 400).C doneChan := make(chan bool) go func() { time.Sleep(time.Second * 2) doneChan <- true }() for { select { case <-timeChan: fmt.Println("Timer expired") case <-tickChan: fmt.Println("Ticker ticked") case <-doneChan: fmt.Println("Done") return } } }
问题调优
client网络连接泄漏
- client在接收http响应后,关闭而不读取Body会导致连接不被释放。占用掉所有6万多个连接后,client端将无法分配新的连接。
信道关闭准则
- 在不改变信道的情况下,无法知道信道本身的状态。而关闭一个已经关闭的信道,或者向已经关闭的信道发数据,都会会panic。
- 不要在接收端关闭信道;存在多个并发发送端时不要关闭信道。关闭准则:简单的说就是在最后一个发送端发送完数据才可以关闭。
利用recover:如果确实想在接收端或者存在并发发送端时关闭信道,可以用 recover 机制避免panic。
func SafeClose(ch chan T) ( bool) { defer func() { if err := recover(); err != nil { // panic ok = false } }() close(ch) return }
由于调用量不大,所以性能损失有限。但是还需要一个SafeSend来避免发送段panic。这种方法还有一个问题是类型不通用。
- 利用sync:也可以用全局锁来实现SafeClose()/IsClose()接口,但是很麻烦,也不通用。所以最好的实践方式是遵守Golang的关闭准则。
标准做法:
- senders(1)/receivers(n): 发送端控制关闭
senders(n)/receivers(1): 用两个信道,其中一个用于receivers通知发送端不要发送数据, receivers通过关闭信道达到通知目的。数据信道不会关闭,由GC自动回收。
// senders select { case <-stopCh: return case dataCh <- data: } // receivers for data := range dataCh { if should_stop(data) { close(stopCh) return } }
- senders(m)/receivers(n):这种情况要用三个信道,发送和接收都需要同时选择stopCh和dataCh,通过另外一个长度为1的信道来执行关闭stopCh的操作,关闭stopCh可以起到通知所有线程的作用。同样dataCh也不会关闭,由GC自动回收。
程序栈
- C/C++/goroutine stack多大呢?是固定的还是动态变化的呢?
- stack动态变化的话,什么时候扩容和缩容呢?如何实现的呢?
- 对服务有什么影响吗?如何排查栈扩容缩容带来的问题呢?
首先栈的布局从低到高:保留、inited/data、uninited/bss、堆、共享库、栈、内核。
C/C++栈:默认为8MB,相关系统参数可以通过ulimit -a
查看,超过就会报段错误。高级用户可以调高内核配置,或者创建线程的时候指定大小。但都不是一个好的方案,好的方案是在调用处插桩,满足就执行,不满足就创建新栈在执行,但是linux线程模型不支持,只能在用户态实现。
Go协程很好的实现了动态栈,初始化大小都是2KB。go1.3之前是分段栈,不足时分配新栈,使用完就释放,递归调用容易引发hot-split。 go1.3之后用的是连续栈,当前栈不足时,分配两倍大小的新栈并复制过去。
在连接数很高时,如果执行rpc调用触发扩容,会使得程序的内容占用成倍增加,有可能因此导致OOM。缩容也有栈拷贝和写屏障,对实时应用有影响。