golang中的channel及踩坑
Walter Lv1

golang中的channel及踩坑

通道channel是golang的高并发法宝之一,作为一个go程序员,如果不会使用channel做并发操作实在是有点说不过去。

通道的结构

数据结构

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
type hchan struct {
//channel分为无缓冲和有缓冲两种。
//对于有缓冲的channel存储数据,借助的是如下循环数组的结构
qcount uint // 循环数组中的元素数量
dataqsiz uint // 循环数组的长度
buf unsafe.Pointer // 指向底层循环数组的指针
elemsize uint16 //能够收发元素的大小


closed uint32 //channel是否关闭的标志
elemtype *_type //channel中的元素类型

//有缓冲channel内的缓冲数组会被作为一个“环型”来使用。
//当下标超过数组容量后会回到第一个位置,所以需要有两个字段记录当前读和写的下标位置
sendx uint // 下一次发送数据的下标位置
recvx uint // 下一次读取数据的下标位置

//当循环数组中没有数据时,收到了接收请求,那么接收数据的变量地址将会写入读等待队列
//当循环数组中数据已满时,收到了发送请求,那么发送数据的变量地址将写入写等待队列
recvq waitq // 读等待队列
sendq waitq // 写等待队列


lock mutex //互斥锁,保证读写channel时不存在并发竞争问题
}

image

读写通道的过程

发送流程:

向一个channel中写数据简单过程如下:

  1. 如果等待接收队列recvq不为空,说明缓冲区中没有数据或者没有缓冲区,此时直接从recvq取出G,并把数据写入,最后把该G唤醒,结束发送过程;
  2. 如果缓冲区中有空余位置,将数据写入缓冲区,结束发送过程;
  3. 如果缓冲区中没有空余位置,将待发送数据写入G,将当前G加入sendq,进入睡眠,等待被读goroutine唤醒;

简单流程图如下:

image

接收流程:

从一个channel读数据简单过程如下:

  1. 如果等待发送队列sendq不为空,且没有缓冲区,直接从sendq中取出G,把G中数据读出,最后把G唤醒,结束读取过程;
  2. 如果等待发送队列sendq不为空,此时说明缓冲区已满,从缓冲区中首部读出数据,把G中数据写入缓冲区尾部,把G唤醒,结束读取过程;
  3. 如果缓冲区中有数据,则从缓冲区取出数据,结束读取过程;
  4. 将当前goroutine加入recvq,进入睡眠,等待被写goroutine唤醒;

简单流程图如下:

image

关闭channel

关闭channel时会把recvq中的G全部唤醒,本该写入G的数据位置为nil。把sendq中的G全部唤醒,但这些G会panic。

除此之外,panic出现的常见场景还有:

  1. 关闭值为nil的channel
  2. 关闭已经被关闭的channel
  3. 向已经关闭的channel写数据

无缓冲通道和缓冲为1的通道

在无缓冲通道中,读和写是相互依赖的,任意一个读或写操作没有交付,都会一直阻塞。

而缓冲为1的通道不同,以往channel发送数据为例,数据会优先交付给接收队列中等待的goroutine,如果等待队列为空,就会将数据到缓冲区;如果缓冲区满的话,才会阻塞到发送队列。

相比之下,无缓冲通道有更强的一致性,而有缓冲通道则更灵活。

在下面这个例子中,可以更好理解二者的区别:

仅使用一个管道,交替打印1-100这100个数字。

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
26
27
28
func main() {
ch := make(chan int, 1)

wg := &sync.WaitGroup{}
wg.Add(2)

go printFn(wg, 1, ch)
go printFn(wg, 2, ch)

ch <- 1

wg.Wait()
close(ch)
}

func printFn(wg *sync.WaitGroup, id int, cur chan int) {
defer wg.Done()
for i := id; i <= 100; {
val := <-cur
if val != id {
cur <- val
continue
}
fmt.Printf("goroutine%v: %v\n", id, i)
cur <- (id % 2) + 1 // 交替传递信号
i += 2
}
}

使用缓冲区为1的管道可以正常打印:

goroutine1: 1
goroutine2: 2

goroutine1: 99
goroutine2: 100

但是如果将chanel更改为无缓冲管道。结果将是先正常打印数字,然后死锁:

goroutine2: 98
goroutine1: 99
goroutine2: 100
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [semacquire]:
sync.runtime_Semacquire(0x14000002101?)
/usr/local/go/src/runtime/sema.go:62 +0x2c

原因是,无论那个goroutine先结束,另一个goroutine都会阻塞在 cur <- (id % 2) + 1写管道这里。

例如,当 goroutine 1 打印 99 之后,发送信号给 goroutine 2,希望它打印 100。此时 goroutine 2 打印 100 后,会再次尝试发送信号给 goroutine 1。

但是,goroutine 1 已经没有数字需要打印了,因此它不会再从通道中读取信号,也不会继续工作。

goroutine 2 此时被阻塞在发送信号的操作上,等待 goroutine 1 接收,但 goroutine 1 已经结束等待,程序进入死锁状态。

而有缓冲通道则没有这个问题,goroutine直接将数据放入缓冲区buf中即可。

 评论