为什么互斥锁代码会阻止另一个整个go协程?

3
var m *sync.RWMutex
func main() {
    m = new(sync.RWMutex)
    n := 100
    go func() {
        for i := 0; i < n; i++ {
            write("WA", i)
        }
    }()

    go func() {
        for i := 0; i < n; i++ {
            write("WB", i)
        }
    }()

    select {}
}
func write(tag string, i int) {
    m.Lock()
    fmt.Printf("[%s][%s%d]write start \n", tag, tag, i)
    time.Sleep(100 * time.Millisecond)
    fmt.Printf("[%s][%s%d]write end \n", tag, tag, i)
    m.Unlock()

    // time.Sleep(1 * time.Millisecond)
}

在控制台中的结果:

运行 mutex.go
[WB][WB0]写入开始
[WB][WB0]写入结束
[WB][WB1]写入开始
[WB][WB1]写入结束
[WB][WB2]写入开始
[WB][WB2]写入结束
[WB][WB3]写入开始
[WB][WB3]写入结束
[WB][WB4]写入开始
[WB][WB4]写入结束
[WB][WB5]写入开始
[WB][WB5]写入结束
[WB][WB6]写入开始
[WB][WB6]写入结束
[WB][WB7]写入开始
[WB][WB7]写入结束
[WB][WB8]写入开始
[WB][WB8]写入结束
[WB][WB9]写入开始
[WB][WB9]写入结束 ...

> go version
go version go1.5.2 windows/amd64

问题是:为什么"[WA]"的go例程没有机会?为什么互斥代码会停止另一个整个的go例程?
我知道肯定有一个关于它的故事或理论。请给我一个url阅读和学习。

互斥锁并不能阻止另一个 goroutine 的执行。但是,调度器安排 goroutine 的顺序没有任何保证。 - kostya
2
请观看Rob Pikes的“Go并发模式”以获取正确的解决方案:https://www.youtube.com/watch?v=f6kdp27TYZs幻灯片:https://talks.golang.org/2012/concurrency.slide#1此外,为了保证Go的可靠性,请仔细阅读“Go内存模型”:https://golang.org/ref/mem - 0x434D53
1
这里的行为实际上是你想要的。你希望一个CPU绑定的goroutine专注于任务并以其CPU缓存热状态完成,而不是到处跳动。 - Zan Lynx
感谢大家的回答和解释。我认为它们都很有道理。 - hardPass
作为额外的好处,Go从v1.14开始支持抢占式调度 https://go.dev/doc/go1.14#runtime ,这意味着现在打印行不再按顺序。 - hao
3个回答

2

Go使用协作式多任务处理,而不是抢占式多任务处理:计算机多任务处理。您需要在锁之间给调度程序运行的机会。例如,通过调用Gosched()函数。

package main

import (
    "fmt"
    "runtime"
    "sync"
    "time"
)

var m *sync.RWMutex

func main() {
    m = new(sync.RWMutex)
    n := 100
    go func() {
        for i := 0; i < n; i++ {
            write("WA", i)
        }
    }()

    go func() {
        for i := 0; i < n; i++ {
            write("WB", i)
        }
    }()

    select {}
}

func write(tag string, i int) {
    m.Lock()
    fmt.Printf("[%s][%s%d]write start \n", tag, tag, i)
    time.Sleep(100 * time.Millisecond)
    fmt.Printf("[%s][%s%d]write end \n", tag, tag, i)
    m.Unlock()
    runtime.Gosched()
}

输出:

[WB][WB0]write start 
[WB][WB0]write end 
[WA][WA0]write start 
[WA][WA0]write end 
[WB][WB1]write start 
[WB][WB1]write end 
[WA][WA1]write start 
[WA][WA1]write end 
[WB][WB2]write start 
[WB][WB2]write end 
[WA][WA2]write start 
[WA][WA2]write end 
[WB][WB3]write start 
[WB][WB3]write end 
[WA][WA3]write start 
[WA][WA3]write end 

3
我认为这个答案并不完全准确。Go调度器是部分抢占式的,例如它可以在函数调用时进行抢占。请参见 https://github.com/golang/go/issues/11462 。 - kostya
@kostya:抢占式调度:指在不需要程序主动放弃控制权的情况下,从正在运行的程序中夺取控制权。合作式调度:指在程序主动放弃控制权时,从正在运行的程序中获取控制权。Go调度器是一种合作式调度器。Go程序明确或隐含地放弃控制权。runtime.Gosched()函数明确地放弃控制权。文件I/O和其他阻塞操作以及某些情况下的函数调用会隐含地放弃控制权。 - peterSO
@kostya:奥斯汀选择的措辞不太恰当,容易引起混淆。 - peterSO
1
谢谢您的解释,我明白了。虽然我仍然不确定在这个例子中协作调度器是否是一个问题。调用write会插入一个抢占点,但由于Lock在Unlock之后发生得非常快,因此在此间隔期间将控制权交给另一个goroutine的可能性极小。这在抢占式调度器的情况下也是如此。 - kostya

2
这种情况被称为活锁。即使两个goroutine(A和B)在等待此锁被释放时,您调用m.Unlock(),调度程序也可以自由地唤醒它们中的任何一个来继续执行。
在Go中,调度程序的当前实现看起来没有快速切换到goroutine A以便其获取互斥锁。在此之前,goroutine B重新获取了互斥锁。
如果您将time.Sleep调用移动到m.Unlock调用之后,则A和B goroutine将同时运行。
希望这有意义。

1

@peterSO的答案是正确的。稍微解释一下调度,那个 for 循环是一个顺序紧密循环。这意味着从该循环编译出的指令将占用整个线程来完成。同时,除非它们有一些由 runtime.Gosched() 或 sleep 提供的时间表周期,在循环中间,其他低级指令的位将被阻塞。这就是为什么它们实际上没有机会赶上 sync.Mutex (BTW 在声明和实例化时都应该是 sync.Mutex):

go func() {
    for i := 0; i < n; i++ {
        runtime.Gosched()
        write("WA", i)
    }
}()

go func() {
    for i := 0; i < n; i++ {
        runtime.Gosched()
        write("WB", i)
    }
}()

Go调度器在指令级别上不是抢占式的(像Erlang一样)。这就是为什么最好使用通道来编排执行路径的原因。
注意:我是通过艰苦的方式学习到这一点的(并不是低级Go编译器专家)。通道以更清洁的方式在Go协程(和那些额外的周期)上提供了编排。换句话说,sync.Mutex应该仅用于监督对东西的访问,而不是用于编排。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接