《Go 程序员面试笔试宝典》学习整理
什么是CSP
虽然可以使用共享内存进行数据交换,但是共享内存在不同的goroutine中容易发生竞态问题。为了保证数据交换的正确性,必须使用互斥量对内存进行加锁,这种做法势必造成性能问题。
golang主张通过通信来实现内存共享而非通过共享内存来实现通信。这就是 Go 的并发哲学,它依赖 CSP 模型(Communicating Sequential Processes),基于 channel 实现。
CSP 全称是 “Communicating Sequential Processes”(通信顺序进程),是 Tony Hoare 在 1978 年发表在 ACM 的一篇论文。论文里指出一门编程语言应该重视 input 和 output 的原语,尤其是并发编程的代码。
Go 是第一个将 CSP 的这些思想引入,并且发扬光大的语言。仅管内存同步访问控制在某些情况下大有用处,Go 里也有相应的 sync 包支持,但是这在大型程序很容易出错。
大多数的编程语言的并发编程模型是基于线程和内存同步访问控制,Go 的并发编程的模型则用 goroutine 和 channel 来替代。Goroutine 和线程类似,channel 和 mutex (用于内存同步访问控制)类似。
Channel 则天生就可以和其他 channel 组合。我们可以把收集各种子系统结果的 channel 输入到同一个 channel。Channel 还可以和 select, cancel, timeout 结合起来。而 mutex 就没有这些功能。
Go 的并发原则非常优秀,目标就是简单:尽量使用 channel;把 goroutine 当作免费的资源,随便用。
channel底层数据结构
1 | // go 1.9.2 |
buf
指向底层循环数组,只有缓冲型的 channel 才有。sendx
,recvx
均指向底层循环数组,表示当前可以发送和接收的元素位置索引值(相对于底层数组)。sendq
,recvq
分别表示被阻塞的 goroutine,这些 goroutine 由于尝试读取 channel 或向 channel 发送数据而被阻塞。waitq
是sudog
的一个双向链表,而sudog
实际上是对 goroutine 的一个封装:
1 | type waitq struct { |
lock
用来保证每个读 channel 或写 channel 的操作都是原子的。
创建channel
1 | // var 变量 chan 元素类型 |
通道是引用类型,通道类型的空值是nil,声明的通道后需要使用make函数初始化之后才能使用。
1 | // make(chan 元素类型, [缓冲大小]) |
最终创建 chan 的函数是 makechan
:
1 | func makechan(t *chantype, size int64) *hchan |
从函数原型来看,创建的 chan 是一个指针。所以我们能在函数间直接传递 channel,而不用传递 channel 的指针。
1 | const hchanSize = unsafe.Sizeof(hchan{}) + uintptr(-int(unsafe.Sizeof(hchan{}))&(maxAlign-1)) |
channel操作
简单使用
通道有发送(send)、接收(receive)和关闭(close)三种操作。
发送和接收都使用<-符号。
1 | // PS: 下面就是个写法示例,不是用来跑的 |
常见操作情况总结:
channel | nil | 空 | 满 | closed |
---|---|---|---|---|
接收 | 阻塞 | 阻塞 | 正常接收 | 有值就接着正常读取,没有值返回零值和false |
发送 | 阻塞 | 正常发送 | 阻塞 | panic |
关闭 | panic | 关闭成功 | 关闭成功 | panic |
无缓冲通道
无缓冲通道上的发送操作会阻塞,直到另一个goroutine在该通道上执行接收操作,这时值才能发送成功,两个goroutine将继续执行。
相反,如果接收操作先执行,接收方的goroutine将阻塞,直到另一个goroutine在该通道上发送一个值。
使用无缓冲通道进行通信将导致发送和接收的goroutine同步化。因此,无缓冲通道也被称为同步通道。
1 | func main() { |
有缓冲通道
使用make函数初始化通道的时候为其指定通道的容量
1 | func main() { |
只要通道的容量大于零,那么该通道就是有缓冲的通道,通道的容量表示通道中能存放元素的数量,当然如果容量等于零仍然会阻塞。
可以使用内置的len函数获取通道内元素的数量,使用cap函数获取通道的容量,不过一般不会这样去做。
单向通道
限制通道在函数中只能发送或只能接收
1 | // chan<- int是一个只能发送的通道,可以发送但是不能接收 |
优雅地关闭channel
- 对一个关闭的通道再发送值就会导致panic。
- 对一个关闭的通道进行接收会一直获取值直到通道为空。
- 对一个关闭的并且没有值的通道执行接收操作会得到对应类型的零值。
- 关闭一个已经关闭的通道会导致panic。
有一条广泛流传的关闭 channel 的原则:
don’t close a channel from the receiver side and don’t close a channel if the channel has multiple concurrent senders.
(很经典也很重要)
不要从一个 receiver 侧关闭 channel,也不要在有多个 sender 时关闭 channel。
比较好理解,向 channel 发送元素的就是 sender,因此 sender 可以决定何时不发送数据,并且关闭 channel。但是如果有多个 sender,某个 sender 同样没法确定其他 sender 的情况,这时也不能贸然关闭 channel。
但是上面所说的并不是最本质的,最本质的原则就只有一条:
don’t close (or send values to) closed channels.
有两个不那么优雅地关闭 channel 的方法:
- 使用 defer-recover 机制,放心大胆地关闭 channel 或者向 channel 发送数据。即使发生了 panic,有 defer-recover 在兜底。
- 使用 sync.Once 来保证只关闭一次。
根据 sender 和 receiver 的个数,分下面几种情况:
- 一个 sender,一个 receiver
- 一个 sender, M 个 receiver
- N 个 sender,一个 reciver
- N 个 sender, M 个 receiver
对于 1,2,只有一个 sender 的情况就不用说了,直接从 sender 端关闭就好了,没有问题。重点关注第 3,4 种情况。
1 | func main() { |
上面的代码并没有明确关闭 dataCh,在 Go 语言中,对于一个 channel,如果最终没有任何 goroutine 引用它,不管 channel 有没有被关闭,最终都会被 gc 回收。所以,在这种情形下,所谓的优雅地关闭 channel 就是不关闭channel,让 gc 代劳。
第四种情况,这里有 M 个 receiver,如果直接还是采取第 3 种解决方案,由 receiver 直接关闭 stopCh 的话,就会重复关闭一个 channel,导致 panic。因此需要增加一个中间人,M 个 receiver 都向它发送关闭 dataCh 的“请求”,中间人收到第一个请求后,就会直接下达关闭 dataCh 的指令(通过关闭 stopCh,这时就不会发生重复关闭的情况,因为 stopCh 的发送方只有中间人一个)。另外,这里的 N 个 sender 也可以向中间人发送关闭 dataCh 的请求(这里发送请求后关闭的同样是stopCh,多个sender的情况下是不会由sender主动关闭dataCh的)
1 | func main() { |
这里将 toStop 声明成了一个 缓冲型的 channel。假设 toStop 声明的是一个非缓冲型的 channel,那么第一个发送的关闭 dataCh 请求可能会丢失。因为无论是 sender 还是 receiver 都是通过 select 语句来发送请求,如果中间人所在的 goroutine 没有准备好,那 select 语句就不会选中,直接走 default 选项,什么也不做。这样,第一个关闭 dataCh 的请求就会丢失。
如果,我们把 toStop 的容量声明成 Num(senders) + Num(receivers),那发送 dataCh 请求的部分可以改成更简洁的形式:
1 | // toStop部分 |
直接向 toStop 发送请求,因为 toStop 容量足够大,所以不用担心阻塞,自然也就不用 select 语句再加一个 default case 来避免阻塞。
Channel引发的泄漏
Channel 可能会引发 goroutine 泄漏。
泄漏的原因是 goroutine 操作 channel 后,处于发送或接收阻塞状态,而 channel 处于满或空的状态,一直得不到改变。
同时,垃圾回收器也不会回收此类资源,进而导致 gouroutine 会一直处于等待队列中。
另外,程序运行过程中,对于一个 channel,如果没有任何 goroutine 引用了,gc 会对其进行回收操作,不会引起内存泄漏。
情境一:select-case
误用导致的内存泄露
1 | // out: |
根据输出结果(开始有两个 goroutine,结束时有三个 goroutine),我们可以知道,直到测试函数结束前,仍有一个 goroutine 没有退出。原因是由于 (1) 处创建的 errCh 是不含缓存队列的 channel,由于没有发送方往 errCh 发送数据,所以 (4) 处代码一直阻塞。直到 (3) 处超时后,打印“超时”,函数退出,(4) 处代码都未接收成功。而 (2) 处的所在的 goroutine 在“超时”被打印后,才开始发送。由于外部的 goroutine 已经退出了,errCh 没有接收者,导致 (2) 处一直阻塞。因此 (2) 处代码所在的协程一直未退出,造成了内存泄漏。如果代码中有许多类似的代码,或在 for 循环中使用了上述形式的代码,随着时间的增长会造成多个未退出的 gorouting,最终导致程序 OOM。
解决办法:为 channel 增加一个缓存队列,即把 (1) 处代码改为 errCh := make(chan error, 1)
情景二:for-range
误用导致的内存泄露
上述示例中只有一个发送者,且只发送一次,所以增加一个缓存队列即可。但在其他情况下,可能不止有一个发送者(或者不只发送一次),所以这个方案要求,缓存队列的容量需要和发送次数一致。一旦缓存队列容量被用完后,再有发送者发送就会阻塞发送者 goroutine。如果恰好此时接收者退出了,那么仍然至少会有一个 goroutine 无法退出,从而造成内存泄漏。就比如下面的代码。不知道经过上面的讲解,读者是否能够发现其中的问题。
1 | func TestLeakOfMemory2(t *testing.T) { |
我们聪明地使用了 channel 的缓存队列。我们以为我们循环发送,发完之后就会把 channel 关闭。而且我们使用 for range 获取 channel 的值,会一直获取,直到 channel 关闭。但在代码 (1) 处,接收者的 goroutine 中,我们加了一个判断语句。这会让代码 (2) 处的 channel 还没被接收完就退出了接收者 goroutine。尽管代码 (3) 处有缓存,但是因为发送 channel 在 for 循环中,缓存队列很快就会被占满,阻塞在第 101 的位置。所以这种情况我们要使用一个额外的 stop channel 来终结发送者所在的 goroutine。方式如下:
1 | func TestLeakOfMemory2(t *testing.T) { |
总之,通常情况下,我们只会遇到这两种 go channel 造成内存泄漏的情况(一个发送者导致的内存泄漏和多个发送者导致的内存泄漏)
不论发送者发送一次还是多次,如果接收者所在 goroutine 能够在接收完 channel 中的数据之后结束,那么就不会造成内存泄漏;或者说接收者能够在发送者停止发送后再结束,就不会造成内存泄露。
如果接收者需要在 channel 关闭之前提前退出,为防止内存泄漏,在发送者与接收者发送次数是一对一时,应设置 channel 缓冲队列为 1;在发送者与接收者的发送次数是多对多时,应使用专门的 stop channel 通知发送者关闭相应 channel
其实归根到底是一方不再对channel操作时没有向另一方发送信号,导致另一方阻塞,也就是说关闭channel的方式不对(并不特指close,包括不再操作由gc处理的情况),所以优雅关闭channel还是很重要的
Channel应用
- 停止信号(见优雅关闭channel)
- 定时任务(与timer结合)
- 实现超时控制
- 定期执行某个任务
- 解耦生产方和消费方
- 控制并发数
定时任务
1 | select { |
等待 100 ms 后,如果 s.stopc 还没有读出数据或者被关闭,就直接结束。这是来自 etcd 源码里的一个例子,这样的写法随处可见。
1 | func worker() { |
每隔 1 秒种,执行一次定时任务。
解耦生产方和消费方
服务启动时,启动 n 个 worker,作为工作协程池,这些协程工作在一个 for {}
无限循环里,从某个 channel 消费工作任务并执行:
1 | func main() { |
5 个工作协程在不断地从工作队列里取任务,生产方只管往 channel 发送任务即可,解耦生产方和消费方。
控制并发数
有时需要定时执行几百个任务,例如每天定时按城市来执行一些离线计算的任务。但是并发数又不能太高,因为任务执行过程依赖第三方的一些资源,对请求的速率有限制。这时就可以通过 channel 来控制并发数。
1 | var limit = make(chan int, 3) |