资讯一个 golang 并发的问题

2021-05-11 20:28:24 +08:00
 ng29
func main() {
	runtime.GOMAXPROCS(1)
	ch := make(chan int)
	go count(ch, 10000)
	go count(ch, 10001)
	time.Sleep(10000 * time.Millisecond)
	fmt.Printf("exit\n")
}

func count(r chan int, who int) {
	for {
		if who%2 == 0 {
			r <- who
			fmt.Printf("|write <- who|%d\n", who)
		} else {
			<-r
			fmt.Printf("| <-r recv|%d\n", who)
		}
	}
}

输出是
| <-r recv|10001
| <-r recv|10001
|write <- who|10000
|write <- who|10000
为什么不是一个一个交替的形式
| <-r recv|10001
|write <- who|10000
| <-r recv|10001
|write <- who|10000
3419 次点击
所在节点    Go 编程语言
46 条回复
joesonw
2021-05-11 20:46:52 +08:00
cpu 太快, i/o 太慢
AngryPanda
2021-05-11 20:48:56 +08:00
chan 操作和 printf 不是原子操作
GoLand
2021-05-11 21:51:37 +08:00
两个 goroutine 谁先“抢到” processor 谁运行,至于这个谁先抢到谁后抢到,是调度器决定的,和你代码谁先 go 没关系,不能用简单的同步思维去预期结果。
BeautifulSoap
2021-05-11 22:34:22 +08:00
你不能用 sync 的想法去想这种 async 问题,go 的协程和通道之后的代码你是不能确定它们什么时候会被执行的。通道虽然能保证数据一入一出,但是并不能保证塞入数据之后的代码一定会比接收数据之后的代码先执行

一个例子
# goroutine 1
ch <- 100
// code 1

# goroutine 2
data := <- ch
// cdoe 2

在这里,通道能确保 ch <- 100 比 data <- ch 先执行,但是并不能保证 // code 1 比 // code 2 先执行。这点要搞清楚

以及 lz 的代码无论是在我的 windows 还是 wsl2 还是 goplay ground,结果都是

|write <- who|
| <-r recv|
| <-r recv|
|write <- who|
no1xsyzy
2021-05-11 22:43:47 +08:00
上面都不看 runtime.GOMAXPROCS(1) 的吗?
这种情况下两个 goroutine 都靠同一个 chan 形成同步,是完全稳定的输出,毫无竞态(当然,写出来的代码如果依赖这一行为确实是不好的)
而且 chan 和 printf 都是原子的,golang 默认没有 buffer 。

一句话解释:因为是非抢占式调度。阻塞会立刻转移数据但不会提前取走调度权。每次某个 goroutine 先运行一轮满足另一 goroutine 的阻塞,再运行一轮到自己再阻塞,这才能顺利移交调度权。

长解释:数字太长了我就写 0 和 1 了。先起了两个 go routine:0 和 1 但不会运行,因为 main() 还没交出调度权,直到 time.Sleep 才交出调度权。
因为某种实现细节,playground 先执行了 1 (竟然是先入后出?),运行到 <- r,阻塞,交出调度权。
这时候 0 开始运行,到 r<-who 塞进去了以后这个数据立刻被 1 拿走,但这时候 0 还没交出调度权,继续运行,运行到第二次 r<-who 的时候阻塞,交出调度权。
这时候发现 1 可以运行了,就运行 1,先把上次阻塞的 <- r 处理了,运行一遍,再拿掉 0 阻塞着的那个 who,再运行一遍再阻塞。
nguoidiqua
2021-05-12 00:15:35 +08:00
要在通道后进行手动移交调度,才能达到你的想法。

可以在 r <- who 或者 <- r 前面打印下标识,研究下调度问题,根据我的观察来看和 #no1xsyzy 楼说的一致。

阻塞的读取方可以立即读到写入通道的数据(但不会同时移交调度给读取方),所以每一轮其实可以发送两个数据,一个直接给阻塞的读取方读了,一个阻塞在通道,如果通道缓存为 1,那么就是一轮三个,以此类推。
BeautifulSoap
2021-05-12 00:42:57 +08:00
@no1xsyzy
啊,多谢老哥提醒。有段时间没用通道都忘了通道是执行到下一次通道操作再阻塞而不是结束当前代码块的执行后阻塞了
goushenggege
2021-05-12 09:14:55 +08:00
gmp 模型了解下;老版本好像是可以交替,新版后变了
ng29
2021-05-12 09:46:01 +08:00
@no1xsyzy 解释很详细,感谢
index90
2021-05-12 11:36:37 +08:00
@no1xsyzy 不对吧,楼主说的是先输出两个 1,再输出两个 0 。
如果先输出两个 1,代表 chan 已经接收了两次,代表 0 也成功发送了两次,但无法解释,返送两次之间的 print 没有输出。
index90
2021-05-12 11:39:01 +08:00
@no1xsyzy 我知道了,楼主的输出没有写全,正确的是:
|write <- who|10000
| <-r recv|10001
| <-r recv|10001
|write <- who|10000
|write <- who|10000
lesismal
2021-05-12 14:03:58 +08:00
@no1xsyzy 这个解释是错误的

“上面都不看 runtime.GOMAXPROCS(1) 的吗?”
—— 跟 runtime.GOMAXPROCS(1) 没关系,尝试下 runtime.GOMAXPROCS(大于 1),也一样会出现两两一组的日志

“一句话解释:因为是非抢占式调度。”
—— golang 好像是 1.2 版中开始引入比较初级的抢占式调度,然后好像是 1.14 做得更彻底,即使 for{} 也能释放调度权了

这个代码的 chan 虽然是无缓冲的,但只能保证 chan 的 send 和 recv 单句代码的原子性,记得那句话吗——不要通过共享内存来通信,而应该通过通信来共享内存。这句话主要是指用 chan 来保证内存的一致性,因为传统的方法用锁、锁在复杂的业务场景更烧脑并且一不小心容易死锁

用 chan 保证内存的一致性,进一步就可以做到一些业务逻辑的串行化,通常用 chan 也主要是用来做内存读写和逻辑的串行化从而保证一致性,但这并不是承诺多个协程同一个 chan 前后代码段的所有代码执行顺序

楼主代码中的实验是用 printf 打印,而 printf 本身就可能触发调度权的出让,所以其实现象不是 chan 直接导致的,而是由于 printf 时的出让顺序导致的

举个例子(对应代码注释中的 1 、2 、3 、4 ):

func count(r chan int, who int) {
for {
if who%2 == 0 {
r <- who // 1
fmt.Printf("|write <- who|%d\n", who) // 2
} else {
<-r // 3
fmt.Printf("| <-r recv|%d\n", who) // 4
}
}
}

1 执行后立刻执行了 2,打印了 write,出让
3 执行,4 执行前出让
又执行了一组 1 、2,再次打印了 write,出让
4 继续执行,打印了一个 recv,出让
1 执行,2 执行前出让调度
3 执行,4 执行,再次打印了一个 recv
...
依次类推,每次 printf 前都可能出让

顺便宣传下自己两个项目,欢迎来玩玩
https://v2ex.com/t/755862
lesismal
2021-05-12 14:08:22 +08:00
@no1xsyzy
"而且 chan 和 printf 都是原子的,golang 默认没有 buffer 。"
—— 跟 printf 有没有 buffer 也没关系,即使有 buffer,先调用 printf 的也是先入 buffer
tairan2006
2021-05-12 14:32:48 +08:00
现在版本已经实现了抢占式调度……

不建议研究这些东西,语言内部实现机制本来就会变化,各版本之间可能毫不兼容…调度器后面肯定还会再优化。

你要是对这些感兴趣不如研究 C++,undefined behavior 一堆。
baiyi
2021-05-13 09:37:43 +08:00
这主要是由于 channel 的内部实现机制,channel 的 send 或 recv 操作在发现有等待中的接收器( chan.recvq )或发送器( chan.sendq )时,会直接把值交给它,并向其设置为下一个要唤醒的 goroutine 。然后在循环中再次的操作,才会阻塞,阻塞时自己也会在相应的等待队列中。所以每次的 send 或 recv 操作,都会执行两次,输出的形式也是连着输出,但输出和访问 send 和 recv 函数的顺序是不一样的,因为存在阻塞。

这个是比较直观一些的代码,因为调度不一致,所以我用 runtime.Gosched() 方法保证一定是 recv 的 goroutine 先运行。
https://play.golang.org/p/wmU0fpTt5uf

这里是带有 Go Channel 源码部分的一些打印:

------------- call unbuffered channel recv ---------------- # 接收器先启动,调用 recv 时阻塞并将自己放入队列
------------- call unbuffered channel send ---------------- # 发送器启动,调用 send,并尝试获取
------------- unbuffered channel send ---------------- # 发现队列中有可用的接收器,尝试直接发送
------------- unbuffered channel sent ---------------- # 成功
written0 #继续向下执行,打印 witten
------------- call unbuffered channel send ---------------- # for 执行到第二次,调用 send,阻塞了
received # 发送器在发送时,直接修改了接收器的执行栈,所以接收器不需要从阻塞那里再次运行,而是得到值,并继续向下执行,也就是打印
------------- call unbuffered channel recv ---------------- # for 执行到第二次,调用 recv
------------- unbuffered channel receive ---------------- # 发现队列中有可用的发送器,所以直接取值
------------- unbuffered channel received ---------------- # 成功
received # 继续向下执行,打印 received
------------- call unbuffered channel recv ---------------- # 再次调用 recv,但此时队列已经空了,所以阻塞
written1 # 此处的打印是上面被阻塞的 send 的向下执行,同样发送器也不需要在阻塞处继续执行,而是向下执行,所以直接打印第二次循环的输出
------------- call unbuffered channel send ---------------- # 下面就不用详细讲了,都是一样的流程
------------- unbuffered channel send ----------------
------------- unbuffered channel sent ----------------
written2
------------- call unbuffered channel send ----------------
received
------------- call unbuffered channel recv ----------------
------------- unbuffered channel receive ----------------
------------- unbuffered channel received ----------------
received
------------- call unbuffered channel recv ----------------
written3
------------- call unbuffered channel send ----------------
------------- unbuffered channel send ----------------
------------- unbuffered channel sent ----------------
written4
received
------------- call unbuffered channel recv ----------------

如果想看源码来调试或了解,推荐看这里: https://golang.design/under-the-hood/zh-cn/part1basic/ch03lang/chan/
lesismal
2021-05-13 11:16:33 +08:00
@baiyi 这样解释应该是不对的。
“然后在循环中再次的操作,才会阻塞,阻塞时自己也会在相应的等待队列中。所以每次的 send 或 recv 操作,都会执行两次,输出的形式也是连着输出”
—— 你都说了,再次的操作会阻塞,比如 A 的再次操作阻塞了,然后就暂时没走到 print,需要等 B 触发 A 的运行后才能 print,也就是说,A 的两次之间,只有一次 print,然后 B 触发了之后才能再次 print,这两次中间阻塞过、并不是连着输出。

你看 #11 的,通常一开始的时候日志是这样的,第一句他就不是连续的两次:
|write <- who|10000 // 不是连续的两次,只有一次
| <-r recv|10001
| <-r recv|10001
|write <- who|10000
|write <- who|10000

再举个例子,简单点,我开 4 个协程

runtime.GOMAXPROCS(1)
ch := make(chan int)
go count(ch, 10000)
go count(ch, 10001)
go count(ch, 10002)
go count(ch, 10003)

然后某段日志里:

| <-r recv|10003
| <-r recv|10003
| <-r recv|10001 // 不是连续的两次,只有一次
|write <- who|10002 // 不是连续的两次而是三次
|write <- who|10002 // 不是连续的两次而是三次
|write <- who|10002 // 不是连续的两次而是三次
| <-r recv|10001
| <-r recv|10001
|write <- who|10002
|write <- who|10002

runtime 的调度不可能是这样简单对 chan 稳定的两次然后就调度,即使是因为楼主代码的例子场景这两个协成比较均衡导致基本是出现两次,但这也并不是 runtime 提供的保证

看下我 #12 楼的解释
baiyi
2021-05-13 16:14:44 +08:00
@lesismal #16 你可以看下我的输出日志,连续两个的 print 中,第一个永远是上一次 for 的继续执行,而不是这一次 for 的两次执行中的打印,所以我说“但输出和访问 send 和 recv 函数的顺序是不一样的,因为存在阻塞”,因为两次中的后一次,要等待阻塞结束后来后才能输出。只是表现形式是连续两次。

这里只要 runtime.GOMAXPROCS 设置为 1,那么除了第一次和最后一次(如果存在的话)外,其他的输出绝对是连续两次的,这与 goroutine 的调度没有关系,因为只有一个 p 在运行。
也与抢占式调度没有关系,因为每次都是依靠 chan 进行手动调度。
baiyi
2021-05-13 16:19:53 +08:00
@lesismal #16
以 recv 举例,for 因为阻塞被断开了:
第一次执行 0.5,阻塞。
回来后继续执行 0.5 (输出),然后执行完整的 1 个 for (输出),然后再次执行 0.5,阻塞。

虽然 call recv 函数的次数是 2,但因为阻塞,for 中的整体逻辑被分割了。
lesismal
2021-05-13 16:48:14 +08:00
@baiyi 你多跑几次例子试试,至少我这里中间可以遇到这样的日志:

| <-r recv|10001
| <-r recv|10001
|write <- who|10000
|write <- who|10000
| <-r recv|10001
| <-r recv|10001
|write <- who|10000 ///////////// 不是连续的两次,也不是第一次和最后一次
| <-r recv|10001 ///////////// 不是连续的两次,也不是第一次和最后一次
|write <- who|10000
|write <- who|10000
| <-r recv|10001
| <-r recv|10001
|write <- who|10000
|write <- who|10000
| <-r recv|10001
| <-r recv|10001
lesismal
2021-05-13 16:53:53 +08:00
@baiyi golang 的内存模型,无缓冲的 chan,比如两个 goroutine 分别 send 、recv 之间,可以保证这两个 A chan op 前段代码先于 B chan op 后段代码执行,但不能保证 A 和 B op 后段代码的执行顺序,因为 chan op 之后的代码随时也可能被调度

比如

goroutine A:

some code... // 1
chan <- v
some code... // 3
some code... // 4

goroutine B:
<-chan
some code... // 2
some code... // 5
some code... // 6

这里能保证的是 1 先于 2/5/6 执行,但是不能保证 3 和 4,因为 3 和 4 执行之前就可能被调度了

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/776327

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX