入门09_Go语言高级编程

阅读:
Go语言高级编程(Advanced Go Programming):https://chai2010.gitbooks.io/advanced-go-programming-book/content/

1.3 数组、字符串和切片

对于类型,和数组的最大不同是,切片的类型和长度信息无关,只要是相同类型元素构成的切片均对应相同的切片类型
在切片的开头添加元素:

1
2
var a = []int{1,2,3}  
a = append([]int{0}, a...) // 在开头添加1个元素

在开头一般都会导致内存的重新分配,而且会导致已有的元素全部复制1次。因此,从切片的开头添加元素的性能一般要比从尾部追加元素的性能差很多

切片内存技巧
在本节开头的数组部分我们提到过有类似[0]int的空数组,空数组一般很少用到。但是对于切片来说,len为0但是cap容量不为0的切片则是非常有用的特性。当然,如果len和cap都为0的话,则变成一个真正的空切片,虽然它并不是一个nil值的切片。在判断一个切片是否为空时,一般通过len获取切片的长度来判断,一般很少将切片和nil值做直接的比较。

避免切片内存泄漏
如前面所说,切片操作并不会复制底层的数据。底层的数组会被保存在内存中,直到它不再被引用。但是有时候可能会因为一个小的内存引用而导致底层整个数组处于被使用的状态,这会延迟自动内存回收器对底层数组的回收。

1
2
3
4
func FindPhoneNumber(filename string) []byte {  
b, _ := ioutil.ReadFile(filename)
return regexp.MustCompile("[0-9]+").Find(b)
}

这段代码返回的[]byte指向保存整个文件的数组。因为切片引用了整个原始数组,导致自动垃圾回收器不能及时释放底层数组的空间。一个小的需求可能导致需要长时间保存整个文件数据。这虽然这并不是传统意义上的内存泄漏,但是可能会拖慢系统的整体性能。
要修复这个问题,可以将感兴趣的数据复制到一个新的切片中(数据的传值是Go语言编程的一个哲学,虽然传值有一定的代价,但是换取的好处是切断了对原始数据的依赖):

1
return append([]byte{}, b...)

在删除切片元素时可能会遇到。假设切片里存放的是指针对象,那么下面删除末尾的元素后,被删除的元素依然被切片底层数组引用,从而导致不能及时被自动垃圾回收器回收(这要依赖回收器的实现方式):

1
2
var a []*int{ ... }  
a = a[:len(a)-1] // 被删除的最后一个元素依然被引用, 可能导致GC操作被阻碍

保险的方式是先将需要自动内存回收的元素设置为nil,保证自动回收器可以发现需要回收的对象,然后再进行切片的删除操作:

1
2
3
var a []*int{ ... }  
a[len(a)-1] = nil // GC回收最后一个元素内存
a = a[:len(a)-1] // 从切片删除最后一个元素

当然,如果切片存在的周期很短的话,可以不用刻意处理这个问题。因为如果切片本身已经可以被GC回收的话,切片对应的每个元素自然也就是可以被回收的了。

1.4 函数、方法和接口

1.4.1 函数

Go语言程序的初始化和执行总是从main.main函数开始的。但是如果main包导入了其它的包,则会按照顺序将它们包含进main包里(这里的导入顺序依赖具体实现,一般可能是以文件名或包路径名的字符串顺序导入)。如果某个包被多次导入的话,在执行的时候只会导入一次。

如果一个包有多个init函数的话,实现可能是以文件名的顺序调用,同一个文件内的多个init则是以出现的顺序依次调用(init不是普通函数,可以定义有多个,所以不能被其它函数调用)最终,在main包的所有包常量、包变量被创建和初始化,并且init函数被执行后,才会进入main.main函数,程序开始正常执行

要注意的是,在main.main函数执行之前所有代码都运行在同一个goroutine,也就是程序的主系统线程中。因此,如果某个init函数内部用go关键字启动了新的goroutine的话,新的goroutine只有在进入main.main函数之后才可能被执行到

因为切片中的底层数组部分是通过隐式指针传递(指针本身依然是传值的,但是指针指向的却是同一份的数据),所以被调用函数是可以通过指针修改掉调用参数切片中的数据。除了数据之外,切片结构还包含了切片长度和切片容量信息,这2个信息也是传值的。如果被调用函数中修改了Len或Cap信息的话,就无法反映到调用参数的切片中,这时候我们一般会通过返回修改后的切片来更新之前的切片。这也是为何内置的append必须要返回一个切片的原因。

Go语言函数的递归调用深度逻辑上没有限制,函数调用的栈是不会出现溢出错误的,因为Go语言运行时会根据需要动态地调整函数栈的大小。每个goroutine刚启动时只会分配很小的栈(4或8KB,具体依赖实现),根据需要动态调整栈的大小,栈最大可以达到GB级(依赖具体实现,在目前的实现中,32位体系结构为250MB,64位体系结构为1GB)。
Go语言函数的栈会自动调整大小,所以普通Go程序员已经很少需要关心栈的运行机制的。在Go语言规范中甚至故意没有讲到栈和堆的概念。
我们无法知道函数参数或局部变量到底是保存在栈中还是堆中,我们只需要知道它们能够正常工作就可以了。看看下面这个例子:

1
2
3
4
5
6
7
8
func f(x int) *int {  
return &x
}

func g() int {
x = new(int)
return *x
}

第一个函数直接返回了函数参数变量的地址——这似乎是不可以的,因为如果参数变量在栈上的话,函数返回之后栈变量就失效了,返回的地址自然也应该失效了。但是Go语言的编译器和运行时比我们聪明的多,它会保证指针指向的变量在合适的地方。第二个函数,内部虽然调用new函数创建了*int类型的指针对象,但是依然不知道它具体保存在哪里。对于有C/C++编程经验的程序员需要强调的是:不用关心Go语言中函数栈和堆的问题,编译器和运行时会帮我们搞定;同样不要假设变量在内存中的位置是固定不变的,指针随时可能会变化,特别是在你不期望它变化的时候。

1.4.2 方法

在C++语言中方法对应一个类对象的成员函数,是关联到具体对象上的虚表中的。但是Go语言的方法却是关联到类型的,这样可以在编译阶段完成方法的静态绑定。
面向对象编程更多的只是一种思想,很多号称支持面向对象编程的语言只是将经常用到的特性内置到语言中了而已。
每种类型对应的方法必须和类型的定义在同一个包中,因此是无法给int这类内置类型添加方法的(因为方法的定义和类型的定义不在一个包中)。对于给定的类型,每个方法的名字必须是唯一的,同时方法和函数一样也不支持重载。
Go语言不支持传统面向对象中的继承特性,而是以自己特有的组合方式支持了方法的继承。Go语言中,通过在结构体内置匿名的成员来实现继承:

不过这种方式继承的方法并不能实现C++中虚函数的多态特性。所有继承来的方法的接收者参数依然是那个匿名成员本身,而不是当前的变量。

1
2
3
4
5
6
7
8
9
10
11
type Cache struct {  
m map[string]string
sync.Mutex
}

func (p *Cache) Lookup(key string) string {
p.Lock()
defer p.Unlock()

return p.m[key]
}

Cache结构体类型通过嵌入一个匿名的sync.Mutex来继承它的Lock和Unlock方法. 但是在调用p.Lock()和p.Unlock()时, p并不是Lock和Unlock方法的真正接收者, 而是会将它们展开为p.Mutex.Lock()和p.Mutex.Unlock()调用. 这种展开是编译期完成的, 并没有运行时代价.

1.4.3 接口

Go语言中,对于基础类型(非接口类型)不支持隐式的转换,我们无法将一个int类型的值直接赋值给int64类型的变量,也无法将int类型的值赋值给底层是int类型的新定义命名类型的变量。Go语言对基础类型的类型一致性要求可谓是非常的严格,但是Go语言对于接口类型的转换则非常的灵活。对象和接口之间的转换、接口和接口之间的转换都可能是隐式的转换。可以看下面的例子:

1
2
3
4
5
6
var (  
a io.ReadCloser = (*os.File)(f) // 隐式转换, *os.File 满足 io.ReadCloser 接口
b io.Reader = a // 隐式转换, io.ReadCloser 满足 io.Reader 接口
c io.Closer = a // 隐式转换, io.ReadCloser 满足 io.Closer 接口
d io.Reader = c.(io.Reader) // 显式转换, io.Closer 不满足 io.Reader 接口
)

有时候对象和接口之间太灵活了,导致我们需要人为地限制这种无意之间的适配。常见的做法是定义一个含特殊方法来区分接口。比如runtime包中的Error接口就定义了一个特有的RuntimeError方法,用于避免其它类型无意中适配了该接口:

1.5 面向并发的内存模型

1.5.1 Goroutine和系统线程

1.5.2 原子操作

用互斥锁来保护一个数值型的共享资源,麻烦且效率低下。标准库的sync/atomic包对原子操作提供了丰富的支持。
atomic.AddUint64函数调用保证了total的读取、更新和保存是一个原子操作,因此在多线程中访问也是安全的。

原子操作配合互斥锁可以实现非常高效的单件模式。互斥锁的代价比普通整数的原子读写高很多,在性能敏感的地方可以增加一个数字型的标志位,通过原子检测标志位状态降低互斥锁的使用次数来提高性能。
sync/atomic包对基本的数值类型及复杂对象的读写都提供了原子操作的支持。atomic.Value原子对象提供了Load和Store两个原子方法,分别用于加载和保存数据,返回值和参数都是interface{}类型,因此可以用于任意的自定义复杂类型。

1.5.3 顺序一致性内存模型

在Go语言中,同一个Goroutine线程内部,顺序一致性内存模型是得到保证的。但是不同的Goroutine之间,并不满足顺序一致性内存模型,需要通过明确定义的同步事件来作为同步的参考。如果两个事件不可排序,那么就说这两个事件是并发的。为了最大化并行,Go语言的编译器和处理器在不影响上述规定的前提下可能会对执行语句重新排序(CPU也会对一些指令进行乱序执行)。

比如下面这个程序:

1
2
3
func main() {  
go println("你好, 世界")
}

根据Go语言规范,main函数退出时程序结束,不会等待任何后台线程。因为Goroutine的执行和main函数的返回事件是并发的,谁都有可能先发生,所以什么时候打印,能否打印都是未知的。
解决问题的办法就是通过同步原语来给两个事件明确排序:

1
2
done <- 1,    <-done  
mu.Lock() mu.Unlock() mu.Lock()

1.5.5 Goroutine的创建

go语句会在当前Goroutine对应函数返回前创建新的Goroutine. 例如:

1
2
3
4
5
6
7
8
9
10
var a string  

func f() {
print(a)
}

func hello() {
a = "hello, world"
go f()
}

执行go f()语句创建Goroutine和hello函数是在同一个Goroutine中执行, 根据语句的书写顺序可以确定Goroutine的创建发生在hello函数返回之前, 但是新创建Goroutine对应的f()的执行事件和hello函数返回的事件则是不可排序的,也就是并发的。调用hello可能会在将来的某一时刻打印”hello, world”,也很可能是在hello函数执行完成后才打印。

1.6 常见的并发模式

1.6.1 并发版本的Hello world

1
2
3
mu.Lock()   mu.Unlock()  
mu.Lock() mu.Unlock() mu.Lock()
<-done done <- 1

对管道的缓存大小太敏感:如果管道有缓存的话,就无法保证main退出之前后台线程能正常打印了。更好的做法是将管道的发送和接收方向调换一下,这样可以避免同步事件受管道缓存大小的影响:
done <- 1 <-done

1.6.2 生产者消费者模型

1.6.3 发布订阅模型

1.6.4 控制并发数

1.6.5 赢者为王

1.6.7 并发的安全退出

我们通过select和default分支可以实现一个Goroutine的退出控制
但是管道的发送操作和接收操作是一一对应的,如果要停止多个Goroutine那么可能需要创建同样数量的管道,这个代价太大了。其实我们可以通过close关闭一个管道来实现广播的效果,所有从关闭管道接收的操作均会收到一个零值和一个可选的失败标志。
我们通过close来关闭cancel管道向多个Goroutine广播退出的指令。不过这个程序依然不够稳健:当每个Goroutine收到退出指令退出时一般会进行一定的清理工作,但是退出的清理工作并不能保证被完成,因为main线程并没有等待各个工作Goroutine退出工作完成的机制。我们可以结合sync.WaitGroup来改进:

1.6.8 context包

附录A:Go语言常见坑

可变参数是空接口类型

当参数的可变参数是空接口类型时,传入空接口的切片时需要注意参数展开的问题。

1
2
3
4
5
6
func main() {  
var a = []interface{}{1, 2, 3}

fmt.Println(a)
fmt.Println(a...)
}

不管是否展开,编译器都无法发现错误,但是输出是不同的:

1
2
[1 2 3]  
1 2 3

数组是值传递

在函数调用参数中,数组是值传递,无法通过修改数组类型的参数返回结果。

map遍历是顺序不固定

map是一种hash表实现,每次遍历的顺序都可能不一样。

返回值被屏蔽

在局部作用域中,命名的返回值内同名的局部变量屏蔽:

1
2
3
4
5
6
func Foo() (err error) {  
if err := Bar(); err != nil {
return
}
return
}

recover必须在defer函数中运行

recover捕获的是祖父级调用时的异常,直接调用时无效:

1
2
3
4
func main() {  
recover()
panic(1)
}

直接defer调用也是无效:

1
2
3
4
func main() {  
defer recover()
panic(1)
}

defer调用时多层嵌套依然无效:

1
2
3
4
5
6
func main() {  
defer func() {
func() { recover() }()
}()
panic(1)
}

必须在defer函数中直接调用才有效:

1
2
3
4
5
6
func main() {  
defer func() {
recover()
}()
panic(1)
}

main函数提前退出

后台Goroutine无法保证完成任务。

1
2
3
func main() {  
go println("hello")
}

通过Sleep来回避并发中的问题
休眠并不能保证输出完整的字符串:

1
2
3
4
func main() {  
go println("hello")
time.Sleep(time.Second)
}

类似的还有通过插入调度语句:

1
2
3
4
func main() {  
go println("hello")
runtime.Gosched()
}

独占CPU导致其它Goroutine饿死

Goroutine是协作式抢占调度,Goroutine本身不会主动放弃CPU:
解决的方法是在for循环加入runtime.Gosched()调度函数:
或者是通过阻塞的方式避免CPU占用:

不同Goroutine之间不满足顺序一致性内存模型

闭包错误引用同一个变量

1
2
3
4
5
6
7
func main() {  
for i := 0; i < 5; i++ {
defer func() {
println(i)
}()
}
}

改进的方法是在每轮迭代中生成一个局部变量:

1
2
3
4
5
6
7
8
func main() {  
for i := 0; i < 5; i++ {
i := i
defer func() {
println(i)
}()
}
}

或者是通过函数参数传入:

1
2
3
4
5
6
7
func main() {  
for i := 0; i < 5; i++ {
defer func(i int) {
println(i)
}(i)
}
}

在循环内部执行defer语句

defer在函数退出时才能执行,在for执行defer会导致资源延迟释放:

1
2
3
4
5
6
7
8
9
func main() {  
for i := 0; i < 5; i++ {
f, err := os.Open("/path/to/file")
if err != nil {
log.Fatal(err)
}
defer f.Close()
}
}

解决的方法可以在for中构造一个局部函数,在局部函数内部执行defer:

1
2
3
4
5
6
7
8
9
10
11
func main() {  
for i := 0; i < 5; i++ {
func() {
f, err := os.Open("/path/to/file")
if err != nil {
log.Fatal(err)
}
defer f.Close()
}()
}
}

切片会导致整个底层数组被锁定

切片会导致整个底层数组被锁定,底层数组无法释放内存。如果底层数组较大会对内存产生很大的压力。

空指针和空接口不等价

比如返回了一个错误指针,但是并不是空的error接口:

1
2
3
4
5
6
7
func returnsError() error {  
var p *MyError = nil
if bad() {
p = ErrBad
}
return p // Will always return a non-nil error.
}

内存地址会变化

Go语言中对象的地址可能发生变化,因此指针不能从其它非指针类型的值生成:

1
2
3
4
5
6
7
8
func main() {  
var x int = 42
var p uintptr = uintptr(unsafe.Pointer(&x))

runtime.GC()
var px *int = (*int)(unsafe.Pointer(p))
println(*px)
}

当内存发送变化的时候,相关的指针会同步更新,但是非指针类型的uintptr不会做同步更新。

同理CGO中也不能保存Go对象地址。

Goroutine泄露

Go语言是带内存自动回收的特性,因此内存一般不会泄漏。但是Goroutine确存在泄漏的情况,同时泄漏的Goroutine引用的内存同样无法被回收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func main() {  
ch := func() <-chan int {
ch := make(chan int)
go func() {
for i := 0; ; i++ {
ch <- i
}
} ()
return ch
}()

for v := range ch {
fmt.Println(v)
if v == 5 {
break
}
}
}

上面的程序中后台Goroutine向管道输入自然数序列,main函数中输出序列。但是当break跳出for循环的时候,后台Goroutine就处于无法被回收的状态了。

我们可以通过context包来避免这个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func main() {  
ctx, cancel := context.WithCancel(context.Background())

ch := func(ctx context.Context) <-chan int {
ch := make(chan int)
go func() {
for i := 0; ; i++ {
select {
case <- ctx.Done():
return
case ch <- i:
}
}
} ()
return ch
}(ctx)

for v := range ch {
fmt.Println(v)
if v == 5 {
cancel()
break
}
}
}

当main函数在break跳出循环时,通过调用cancel()来通知后台Goroutine退出,这样就避免了Goroutine的泄漏。

go入门系列
入门02_IDE安装
入门03_工具链
入门04_入门demo和基本类型
入门05_go升级版本
入门06_教程biancheng
入门08_教程编程时光
入门09_Go语言高级编程
入门10_包导入
入门11_方法接口和嵌入类型
入门12_数组和切片

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×