Go 协程访问 Chan 的疑问

遇到的题目

使用 Channel 进行任务编排的题,你可以尝试做一下:有四个 goroutine,编号为 1、2、3、4。每秒钟会有一个 goroutine打印出它自己的编号,要求你编写一个程序,让输出的编号总是按照 1、2、3、4、1、2、3、4、……的顺序打印出来。

解决

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

func printer() {

ch := make(chan struct{})

for i :=1; i <= 4; i++ {
go func(i int) {
time.sleep(time.Duration(i * 10) * time.Millisecond)
for {
<-ch

fmt.Prinln(i)

time.sleep(time.second)

ch <- struct{}{}
}
}(i)
}

ch <- struct{}{}

time.sleep(time.Minute)
}

疑问产生的地方

解释

  • 创建一个阻塞的 Chan
  • 创建四个 Goroutine,有前后顺序,保证了第一个(i = 1) Goroutine 可以首先访问 Chan
  • 主 Goroutine 往 Chan 中添加第一个元素
  • 由于子 Goroutine 启动之后,会 sleep,所以 i=1Goroutine 会最先访问 Chan, 访问之后呢,(其他的 Goroutine 就会阻塞)。 然后输出,输出之后 sleep,在加入元素。

这段代码在输出之后的确是 1,2,3,4 循环打印。疑惑点在哪里呢?就是在第一个 Goroutine 结束之后,为什么 2,3,4 号 Goroutine 没有去争抢 ch,应该是 2,3,4 随机输出。可是并没有出现这种情况,而是顺序输出。

疑惑解决

在此之前,先看一下 src/runtime/chan.gochan 的结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type hchan struct {
qcount uint // total data in the queue
dataqsiz uint // size of the circular queue
buf unsafe.Pointer // points to an array of dataqsiz elements
elemsize uint16
closed uint32
elemtype *_type // element type
sendx uint // send index
recvx uint // receive index
recvq waitq // list of recv waiters
sendq waitq // list of send waiters

// lock protects all fields in hchan, as well as several
// fields in sudogs blocked on this channel.
//
// Do not change another G's status while holding this lock
// (in particular, do not ready a G), as this can deadlock
// with stack shrinking.
lock mutex // 说明 channel 底层也是基于 mutex 实现的
}

主要看 recvqsendq, 这两个是什么呢?

  • recvq : 协程队列,等待读取消息的协程队列
  • sendq : 协程队列,等待发送消息的协程队列

这能说明什么呢?这里首先明确下 recvq 是队列结构。再来返回来看上面那段代码,启动 Goroutine 其实是延迟的,说明他们是顺序启动的,在 1 号获取到 ch 的值之后,首先阻塞的便是 2 号,那么 2 号便会进入recvq队列,三号四号依次进入队列,阻塞等待唤醒。

如上面所说,结果是顺序的。因为在 1Goroutine 启动之后,它使用 time.sleep。导致其他 Goroutine 获取 ch 的时候会阻塞。这个阻塞说明什么呢? 如果没有 time.sleep的话,结果又是怎么样的。在这之前先说一下老生常谈的话题

GMP

GMP 一直是学习 GO 必须学习的话题。到处都是解析。这里也不做深入解释了。主要看一下 GMP 模型.

  • G: 代表 goroutine
  • M: 代表 系统线程
  • P: 代表 与调度相关的context

每一个 M 都拥有一个 PP 也维护一个能运行的 Goroutine 队列,由该 P 绑定的线程执行。

阻塞说明什么

1 号创建之后,会立即获取 ch 的值,因为主 goroutine 已经往 ch 里面加入元素,所以此时 ch 是可读。由 GMP 模型可以知道,1 号由 runtime 调度为 running。来执行获取 ch,之后 1 号 time.sleep 阻塞。然后变成 waiting 状态。

还记得 ch 的两个队列吗?发送和读取,此时 1 号会被放入到发送消息的队列等待唤醒。由于 ch 目前是空的,在 time.sleep 之后才会加入元素。此时2号被创建并且被调度去获取 ch 的值,发现 ch 阻塞之后会被放入 recvq 队列,就是等待读取的队列。3 4 号依次。在 1 秒之后,1 号变为可发送状态,向 ch 中发送元素,然后被加入 recvq 队列尾部,此时队列头部的 2 号会立即被唤醒来获取,然后被加入发送队列。如此重复,就可以顺序打印。

没有 time.sleep 的结果

1 号 Goroutine 的访问调度和上一步是一样的,主要是在下一步。如果 1 号没有阻塞的话,会发生什么呢?ch 会立马变成可读。那么此时 2 3 4 号 Goroutine 会怎么样?根据 M:N 的原则, 2 3 4 应该会被随机分配的 M 调度的 G 队列里面,此时就会发生争抢?那么争抢的结果就是访问是随机的。也就说 2 3 4 中谁先访问了 ch,是不确定的。所以,当你去掉 time.sleep 之后,顺序会变成随机。当然是按照一定的随机顺序打印。因为在确定 2 3 4 访问之后 读取的队列就是确定了,打印的顺序结果就是队列的顺序。

详细的 GMP 是如何调度请查看 https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-goroutine/#65-%E8%B0%83%E5%BA%A6%E5%99%A8