3.10 GMP 偷取 G 为什么不需要加锁?

在之前的文章中,想必你已经知道了 GMP 模型的工作原理,其中有一个非常重要的问题,这个问题非常的细节,但非常值得拿出来讲一下。

P 从全局队列里取 G 的时候,由于可能有多个 P 同时取G 的情况,因此需要加锁,这很容易理解。然后 P 从本地队列里取 G 的时候,正常情况下,只有自己取 G,不用加锁也没关系。但问题就在于当有其他的 P 处于自旋状态的时候,就有可能来自己这边偷。

如此看来,P 的本地队列也有并发竞争的问题,可为什么网上的文章都说从 P 本地队列里的时候,也不用加锁呢?

难道是这些人,瞎说的?

其实啊,这些人说的并没有错,只是他们没把事情说清楚。

原来 P 从本地队列取 G 的这个操作,是一个 CAS 操作,它具有原子性,是由硬件直接支持的,不需要并发的竞争关系。

而我们常见的加锁操作来避免并发的竞争问题,是从操作系统层面来实现的。

因此 GMP 中偷取 G 的过程也是不需要加锁的噢。

CAS 的原子操作虽然可以让程序员变得简单,有时也要付出一定的代价,使用 CAS 有两个小问题:

  1. 使用 CAS 为保证执行成功,程序需要用 for 循环不断尝试,直到成功才返回,因此如果 CAS 长时间不成功,就会阻塞其他硬件对CPU的访问,开销比较大。

  2. 每次使用 CAS 会原子操作时,一般只能对一个共享变量做操作,若要对多个共享变量操作,循环 CAS 可能就不太做,不过应该可以通过把多个变量放在一个对象里来进行 CAS 操作。

为了让你对 CAS 有一个直观的理解,这里直接放上网上找的一段使用 atomic 包实现的 CAS 操作代码

package main

import (
"fmt"
"sync"
"sync/atomic"
)

var (
    counter int32//计数器
    wg sync.WaitGroup //信号量
)

func main() {
    threadNum := 5 //1. 五个信号量
    wg.Add(threadNum) //2.开启5个线程
    for i := 0; i < threadNum; i++ {
        go incCounter(i)
    }
    //3.等待子线程结束
    wg.Wait()
    fmt.Println(counter)
}

func incCounter(index int) {
    defer wg.Done()
    spinNum := 0
    for {
        //2.1原子操作
        old := counter
        ok := atomic.CompareAndSwapInt32(&counter, old, old+1)
        if ok {
            break
        } else {
            spinNum++
        }
    }
    fmt.Printf("thread,%d,spinnum,%d\n",index,spinNum)
}