Parts of Effective Go
Concurrency
通过通信共享内存
Go
将共享的值通过信道传递
多个独立执行的线程从不会主动共享
在任意给定的时间点,只有一个Go
程能访问该值
数据竞争从设计上就被杜绝了Do not communicate by sharing memory; instead, share memory by communicating
不要通过共享内存来通信,而应该通过通信来共享内存。
当在典型的单线程运行在单CPU上的时候,无需使用同步语句
当多个线程进行通信时,若通信过程是同步的,也就完全不需要其它同步了
这种设计模型完美契合Unix
管道
Goroutines
Go
程是与其它Go
程并发运行在同一地址空间的函数Go
程很廉价Go
程在多线程操作系统上可实现多路复用,因此若一个线程阻塞,比如等待I/O
,那么其它线程就会运行Go
程的设计隐藏了线程创建和管理的诸多复杂性
在函数或方法前添加go
关键字能够在新的Go
程中调用它
当调用完成后,该Go
程也会安静地退出(类似于Unix Shell
中的&
符号,能让命令在后台运行)
1 | // 并发运行,无需等它结束 |
在Go
中,函数是闭包
保证了函数内引用变量的生命周期与函数的生命周期相同
Channels
信道与映射一样,需要通过make
来分配内存
其结果值是对底层数据结构的引用
若提供一个可选的整数形参,可为该信道设置缓冲区大小
默认值是零,表示不带缓冲的或者同步的信道
无缓冲信道在通信时会同步交换数据———同步通信
1 | ci := make(chan int) // 整数类型的无缓冲信道 |
接收者在收到数据之前会一直阻塞
若信道是不带缓冲的,那么在接收者收到值前,发送者也会一直阻塞
若信道是带缓冲的,则发送者仅会在值被复制到缓冲区前阻塞(若缓冲区满,发送者会一直等待直到某个接收者取出一个值为止)
数据同步发生在信道的接收端
1 | // 带缓冲的信道可被用作信号量 |
管理资源的另一个好方法就是启动固定数量的handler Go程
,一起从请求信道中读取数据Go程
的数量限制了同时调用process
的数量Serve
同样会接收一个通知退出的信道,在启动所有Go程
后,它将阻塞并暂停从信道中接收消息
1 | func handle(queue chan *Request) { |
信道中的信道Channels of channels
1 | type Request struct { |
并行化 Parallelization
1 | // 在多核CPU上实现并行计算 |
Go
运行时默认并不会并行执行代码,它只为用户层代码提供单一的处理核心。任意数量的Go程
都可能在系统调用中被阻塞,而在任意时刻默认只有一个会执行的用户层代码。
若希望CPU
并行执行,就必须告诉系统,你希望同时有多少Go程
能执行代码:
- 在运行时将
GOMAXPROCS
环境变量设为想要使用的核心数 - 导入
runtime
包并调用runtime.GOMAXPROCS(NCPU)
,调用runtime.NumCPU()
会返回当前机器的逻辑CPU
核心数
并发是可独立执行的组件构造程序的方法structuring a program as independently executing components
并行是为了效率在多CPU上平行进行计算executing calculations in parallel for efficiency on multiple CPUs
尽管Go
的并发特性能够让某些问题更容易构造成并行计算,但Go
仍然是种并发而非并行的语言,且Go
的模型并不适合所有并行问题
可能泄漏的缓冲区 A leaky buffer
1 | // 并发编程工具很容易表达非并发的思想 |
客户端试图从freeList
中获取缓冲区,若没有缓冲区可用,就分配个新的。服务端将b
放回空闲列表freeList
中直到列表已满,此时缓冲区将被丢弃,并被垃圾回收器回收。select
语句中的default
子句在没有条件符合时执行,也就意味着selects
永远不会被阻塞。
构建一个可能导致缓冲区槽位泄漏的空闲列表,只依靠带缓冲的信道和垃圾回收器的记录。
Errors
调用库通常会向调用者返回某种类型的错误提示Go
的多值返回特性,使得能在返回结果值的同时,还能返回详细的错误描述
按照约定,错误的类型通常为error
,这是一个内建的接口
1 | type error interface { |
库的编写者通过更丰富的底层模型可以实现这个接口,使得不仅能看见错误,还能提供上下文
1 | type PathError struct { |
PathError
的Error
会生成如下的错误信息open /etc/passwx: no such file or directory
这种错误类型包含了出错的文件名、操作和触发的操作系统错误
错误字符串应尽可能地指明它们的来源,例如产生该错误的包名前缀
若调用者关心错误的完整细节,可使用类型选择或者类型断言来查看特定的错误,并抽取其中的细节
对于PathErrors
,它应该还包含检查内部的Err
字段以进行可能的错误恢复
1 | for try := 0; try < 2; try++ { |
Panic
向调用者报告错误的一般方式就是将error
作为额外的值返回
标准的Read
方法会返回一个字节流和一个error
如果错误不可恢复,程序就不能继续运行
内建的panic
函数,会产生一个运行时的错误并终止程序panic
函数接受一个任意类型的实参(一般为字符串),并在程序终止时打印
1 | // 用牛顿法实现立方根计算 |
在实际的库函数中应避免panic
若问题可以被屏蔽或解决,最好让程序继续运行而不是终止整个程序
一个反例就是初始化:若某个库真的不能让自己工作,且有足够的理由产生panic
,就由它去吧
1 | var user = os.Getenv("USER") |
Recover
当panic
被调用后,程序将立刻终止当前函数的执行,并开始回溯Go程
的栈,运行任何被推迟的函数。若回溯到达Go程栈
的顶端,程序就会终止。
可以使用内建的recover
函数来重新获取Go程
的控制权限并使其恢复正常执行。
调用recover
将停止回溯过程,并返回传入panic
的实参。
由于回溯时只有被推迟的函数中的代码在运行,因此recover
只能在被推迟的函数中才有效。recover
的一个应用就是在服务器中终止失败的Go程
而无需杀死其它正在执行的Go程
。
1 | func server(workChan <-chan *Work) { |
若do(work)
触发了panic
,其结果就会被记录,而该Go程
会被干净利落地结束,不会干涉到其它Go程
。
无需在推迟的闭包中做任何事情,recover
函数会处理好一切。
由于直接从被推迟的函数中调用recover
时不会返回nil
,因此被推迟的代码能够调用本身使用了panic
和recover
的库函数而不会失败。
例如在safelyDo
中,被推迟的函数可能在调用recover
函数之前先调用了记录函数,而该记录函数应当不受panic
状态的代码的影响。
通过恰当地使用恢复模式,do
函数可通过调用panic
来避免更坏的结果。
可以利用这种思想来简化复杂软件中的错误处理。
1 | // regexp包中的理想化版本 |
若doParse
触发了Panic
,恢复块会将返回值设为nil
,被推迟的函数能够修改已命名的返回值。
在err
赋值过程中,可以通过断言err
是否拥有局部类型Error
来检查它。若没有,类型断言将会失败,此时会产生运行时错误,并继续栈的回溯,就像一切从未中断过一样。
该检查意味着若发生了一些像索引越界之类的意外,那么即便使用了panic
和recover
来处理解析错误,代码仍然会失败。
通过适当的错误处理,error
方法能让报告解析错误变得更容易,从而无需手动处理回溯的解析栈error
方法是一个绑定到具体类型的方法,因此即便它与内建的error
类型名字相同也没有关系
1 | if pos == 0 { |
尽管这种模式很有用,但它应当仅在包内使用。Parse
会将其内部的panic
调用转为error
值,并不会向调用者暴露出panic
。
这种重新触发panic
的惯用法会在产生实际错误时改变panic
的值,然而,不管是原始的还是新的错误都会在崩溃报告中显示,因此问题的根源仍然是可见的。