go 的竞态检测机制 (race)

1. golang 中的 race 检测

由于 golang 中的 go 是非常方便的, 加上函数又非常容易隐藏 go。

所以很多时候, 当我们写出一个程序的时候, 我们并不知道这个程序在并发情况下会不会出现什么问题。

所以在本质上说, goroutine 的使用增加了函数的危险系数​。比如一个全局变量, 如果没有加上锁, 我们写一个比较庞大的项目下来, 就根本不知道这个变量是不是会引起多个 goroutine 竞争。

官网的文章​ ​Introducing the Go Race Detector ​​给出的例子就说明了这点:

package main

import(
    "time"
    "fmt"
    "math/rand"
)

func main() {
    start := time.Now()
    var t *time.Timer
    t = time.AfterFunc(randomDuration(), func() {
        fmt.Println(time.Now().Sub(start))
        t.Reset(randomDuration())
    })
    time.Sleep(5 * time.Second)
}

func randomDuration() time.Duration {
    return time.Duration(rand.Int63n(1e9))
}

这个例子看起来没任何问题, 但是实际上, time.AfterFunc 是会另外启动一个 goroutine 来进行计时和执行 func()

由于 func 中有对 t(Timer) 进行操作 t.Reset, 而主 goroutine 也有对 t 进行操作 t=time.After

这个时候, 其实有可能会造成两个 goroutine 对同一个变量进行竞争的情况。

这个例子可能有点复杂, 我们简化一下, 使用一个更为简单的例子:

1  package main
2  
3  import(
4      "time"
5      "fmt"
6  )
7
8 func main() {
9     a := 1
10     go func(){
11         a = 2
12     }()
13     a = 3
14    fmt.Println("a is ", a)
15 
16     time.Sleep(2 * time.Second)
17 }

在上面的例子中, 看代码, 我们其实看的出来, 这里的 go func 触发的 goroutine 会修改 a

主 goroutine 也会对 a 进行修改。但是我们如果只 go run 运行, 我们可能往往不会发现什么太大的问题。

runtime  go run race1.go
a is  3

可喜的是, golang 在 1.1 之后引入了竞争检测的概念。我们可以使用 go run -race 或者 go build -race 来进行竞争检测。

golang 语言内部大概的实现就是同时开启多个 goroutine 执行同一个命令, 并且纪录每个变量的状态。

如果用 race 来检测上面的程序, 我们就会看到输出:

runtime  go run -race race1.go
a is  3
==================
WARNING: DATA RACE
Write by goroutine 5:
  main.func·001()
      /Users/yejianfeng/Documents/workspace/go/src/runtime/race1.go:11 +0x3a

Previous write by main goroutine:
  main.main()
      /Users/yejianfeng/Documents/workspace/go/src/runtime/race1.go:13 +0xe7

Goroutine 5 (running) created at:
  main.main()
      /Users/yejianfeng/Documents/workspace/go/src/runtime/race1.go:12 +0xd7
==================
Found 1 data race(s)
exit status 66

这个命令输出了 Warning, 告诉我们, goroutine5 运行到第 11 行和 main goroutine 运行到 13 行的时候触发竞争了。

而且 goroutine5 是在第 12 行的时候产生的。

这样我们根据分析这个提示就可以看到这个程序在哪个地方写的有问题了。

当然这个参数会引发 CPU 和内存的使用增加, 所以基本是在测试环境使用, 不是在正式环境开启。

2. go 的竞态检测机制 race

Go 工具套件在 Go 版本 1.1 引入了一个竞态检测工具 (race detector), 这个竞态检测工具是在编译流程中内置到你程序的代码, 一旦你的程序开始运行, 它能够发现和报告任何他所检测到的竞态情况:

package main

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

var Wait sync.WaitGroup
var Counter int = 0

func main() {
    for routine := 1; routine <= 2; routine++ {
        Wait.Add(1)
        go Work(routine)
    }

    Wait.Wait()
    fmt.Printf("Final Counter: %d\n", Counter)
}

func Work(id int) {
    for i := 0; i < 10; i++ {
        Counter++
        time.Sleep(1 * time.Nanosecond)
    }

    Wait.Done()
}

使用 go build -race -o main.out main.go(也可以直接使用 go run -race), 然后运行:

[root@admin go-race]# go build -race -o main.out main.go
[root@admin go-race]# ls
[root@admin go-race]# ./main.out 
==================
WARNING: DATA RACE
Read at 0x000000609908 by goroutine 8:
  main.Work()
      /root/code/go_work/project/gotour/go-race/main.go:24 +0x47

Previous write at 0x000000609908 by goroutine 7:
  main.Work()
      /root/code/go_work/project/gotour/go-race/main.go:24 +0x64

Goroutine 8 (running) created at:
  main.main()
      /root/code/go_work/project/gotour/go-race/main.go:15 +0x75

Goroutine 7 (running) created at:
  main.main()
      /root/code/go_work/project/gotour/go-race/main.go:15 +0x75
==================
Final Counter: 20
Found 1 data race(s)

加锁, 修改之:

package main

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

var Wait sync.WaitGroup
var Counter int = 0
var CounterLock sync.Mutex

func main() {
    for routine := 1; routine <= 2; routine++ {
        Wait.Add(1)
        go Work(routine)
    }

    Wait.Wait()
    fmt.Printf("Final Counter: %d\n", Counter)
}

func Work(id int) {
    for i := 0; i < 10; i++ {
        CounterLock.Lock()
        Counter++
        CounterLock.Unlock()
        time.Sleep(1 * time.Nanosecond)
    }

    Wait.Done()
}

再次运行, 检测通过。

2.1. go 语言常见并发问题

参考一篇文章, 里面列举了 go 容易犯的并发问题: Uber 工程师对真实世界并发问题的研究

2.1.1. 并发访问 map

map 并发访问是非安全的。

2.1.2. 循环变量被捕获

老生常谈的问题了。

for _ , job := range jobs {
    go func() {
       ProcessJob(job)
    }()
}

2.1.3. err 变量被捕获

x , err := Foo ()
if err != nil {
    ...
}

go func () {
    var y int
    y , err = Bar ()
    if err != nil {
        ...
    }
}()

var z string
z , err = Baz ()
if err != nil {
    ...
}

2.1.4. 命名返回值被捕获

func NamedReturnCallee () ( result int) {
    result = 10
    if ... {
        return // this has the effect of " return 10"
    }

    go func () {
        ... = result // read result
    }()

    return 20 // this is equivalent to result =20
}

func Caller () {
    retVal := NamedReturnCallee ()
}

2.1.5. defer 同理

func Redeem ( request Entity ) ( resp Response , err error ) {
    defer func () {
        resp , err = c.Foo (request , err)
    }()

    err = CheckRequest ( request )
    ... // err check but no return
    go func () {
        ProcessRequest (request, err != nil)
    }()
   
    return // the defer function runs after here
}

2.1.6. 传值和传引用的问题

比如 go 的并发库中的 mutex 等不允许被 copy, 所以传递时需要使用指针的方式。

3. golang 数据竞态

Golang 以构建高并发容易、性能优异而闻名。但是, 伴随着并发的使用, 可能发生可怕的数据争用 data race 问题。
而一旦遇到 data race 问题, 由于其不知道什么时候发生, 这将会是难以发现和调试的错误之一。

3.1. 数据竟态示例

下面是一个发生数据竟态的示例:

func main() {
	fmt.Println(getNumber())
}

func getNumber() int {
	var i int
	go func() {
		i = 5
	}()

	return i
}

在上面的示例中, getNumber 先声明一个变量 i, 之后在 goroutine 中单独对 i 进行设置, 而这时程序也正在从函数中返回 i, 由于不知道 goroutine 是否已完成对 i 值的修改, 因此, 将会有两种操作发生:

(1) goroutine 先完成对 i 值的修改, 最后返回的 i 值被设置为 5;
(2) 变量 i 的值从函数返回, 结果为默认值 0。

现在, 根据这两个操作中的哪一个先完成, 输出的结果将是 0(默认整数值)或 5。

这就是为什么将其称为数据竟态: 从返回的值 getNumber 根据 (1) 或 (2) 哪个操作先完成而得名。

3.2. 检查竟态

Go(从 v1.1 开始)具有内置的数据竞争检测器, 可以使用它来查明潜在的数据竞争条件。

使用它就像 -race 在普通的 Go 命令行工具中添加标志一样简单。

  • 运行时检查竟态的命令: go run -race main.go
  • 构建时检查竟态的命令: go build -race main.go
  • 测试时检查竟态的命令: go test -race main.go

所有避免产生竟态背后的核心原则是防止对同一变量或内存位置同时进行读写访问。

3.2.1. 避免竟态的方式

一旦您最终发现烦人的数据竞赛, 您将很高兴知道 Go 提供了许多解决方案。所有这些解决方案都有助于确保如果我们正在写入变量, 则该变量的访问将被阻止。

(1) WaitGroup 等待
解决数据竟态的最直接方法是阻止读取访问, 直到完成写操作为止, 这时可以使用

func getNumber() int {
	var i int
	// 初始化一个 WaitGroup 变量
	var wg sync.WaitGroup
	// Add(1) 表示有一个任务需要等待, 等待任务数增加一个
	wg.Add(1)
	go func() {
		i = 5
		// 调用 wg.Done 表示完成一个处于等待队列中的任务, 出入等待中的任务减少一个 
		wg.Done()
	}()
	// 调用 wg.Wait 阻塞等待, 直到 wg.Done 与通过 wg.Add 添加到任务队列中的的任务数一样, 也即一直等到等待队列中的任务数减为 0
	wg.Wait()
	return i
}

(2) 用 channel 阻塞等待
该方法在原则上与最后一种方法类似, 除了我们使用通道而不是等待组:

func getNumber() int {
	var i int
	// 创建一个类型为结构体的 channel, 并初始化为空结构体
	done := make(chan struct{})
	go func() {
		i = 5
		// 一旦完成前面修改 i 值的工作, 就推送一个空结构体到 done 中
		done <- struct{}{}
	}()
	// 该方式使程序处于阻塞状态, 直到从 channel 类型变量 done 中获取到推送的值
	<-done
	return i
}

如果想重复调用 getNumber 这个函数, 在函数内部进行阻塞虽然很简单, 但却会带来麻烦。下一种方法遵循更灵活的阻塞方法。

(3) 返回 channel 通道
下面的方式是代替上面的第 (2) 种使用通道来阻塞函数的方式, 我们可以返回一个 channel, 一旦获得结果, 就可以通过该通道推送结果。与前两种方法不同, 此方法本身不会进行任何阻塞。相反, 它保留了阻塞调用代码的时机。

// 返回一个 int 型的 channel 来代替返回 int
func getNumberChan() <-chan int {
	// 创建一个 int 型 channel
	c := make(chan int)
	go func() {
		// 推送一个 int 值到 channel
		c <- 5
	}()
	// 立即返回 channel 变量
	return c
}

之后, 在需要使用的时候可以从调用代码中的通道获取结果:

func main() {
	// 代码被阻塞直到从被推入的返回 channel 中取出值, 与前面的方法相反, 在 main 函数中阻塞, 而不是函数本身
	i := <-getNumberChan()
	fmt.Println(i)
}

这种方法更加灵活, 因为 它允许更高级别的功能决定自己的阻塞和并发机制, 而不是将 getNumber 功能视为同步功能。

(4) 使用互斥锁
上面 3 种方式解决的是 i 在写操作完成后才能读取的情况。现在有以下情况: 不管读写顺序如何, 只要求它们不能同时发生。针对这种场景, 应该考虑使用互斥锁:

// 首先, 创建一个结构体, 其中包含我们想要返回的值以及一个互斥实例
type SafeNumber struct {
	val int
	m   sync.Mutex
}

func (i *SafeNumber) Get() int {
	// The `Lock` method of the mutex blocks if it is already locked
	// if not, then it blocks other calls until the `Unlock` method is called
	// Lock 方法
	// 调用结构体对象的 Lock 方法将会锁定该对象中的变量; 如果没有, 将会阻塞其他调用, 直到该互斥对象的 Unlock 方法被调用
	i.m.Lock()
	// 直到该方法返回, 该实例对象才会被解锁
	defer i.m.Unlock()
	// 返回安全类型的实例对象中的值
	return i.val
}

func (i *SafeNumber) Set(val int) {
	// 类似于上面的 getNumber 方法, 锁定 I 对象直到写入"i.val"的值完成
	i.m.Lock()
	defer i.m.Unlock()
	i.val = val
}

func getNumber() int {
	// 创建一个`SafeNumber`的示例
	i := &SafeNumber{}
	// 使用"Set"和"Get"来代替常规的复制修改和读取值, 这样就可以确保只有在写操作完成时我们才能进行阅读, 反之亦然
	go func() {
		i.Set(5)
	}()
	return i.Get()
}

然后, GetNumber 可以像其他情况一样使用。乍一看, 这种方法似乎毫无用处, 因为我们仍然无法保证其值 i。

当有多个写入与读取操作混合在一起, 使用 Mutex 互斥可以保证读写的值与预期结果一致。

3.3. 结论

当运行带有 -race 标志的命令时, 以上方法都可以防止出现数据竟态的警告。每种方法都有不同的权衡和复杂性, 因此在使用之前需要根据实际场景权衡利弊之后再做决定。

通常来说, 使用 WaitGroup 可以以最少的麻烦解决问题, 但使用时需要小心, 必须保证 AddDone 方法出现的次数一致, 最后调用 Wait 等待添加的任务都执行完毕。如果 AddDone 数量不一致, 就会一直阻塞程序, 无限制地消耗内存等资源, 直到资源耗尽服务宕机。

以上解决数据竟态的几种方法背后的核心原则是防止对同一变量或内存位置同时进行读写访问。

3.4. 参考

4. Linux 下竞态分析和避免

1.Linux 产生竞态主要三种情况: 中断、抢占、多处理器。

一个 cpu 运行在进程上下文或者中断上下文(且这段上下文是临界区域)的时候可能

被中断打断, 且这个中断要访问临界资源;

被其它进程抢占, 且这个进程要访问临界资源;

或者其它的 cpu 也要访问临界区域。

这个时候就会发生竞态。

2.cpu 运行在进程上下文的情况

避免中断产生的竞态, 可以提前关闭中断, 处理完成后在开启中断;

避免抢占产生的竞态, 可以使用 spin_lock, 因为自旋锁锁住的临界区域是不可抢占的, 但是要求临界区域尽量的短。另外在关闭中断的情况下抢占的竞态也不会发生, 因为 linux 内核的进程调度也依赖中断实现;

针对多处理器产生的竞态, 通常也是使用 spin_lock, 或者互斥锁也可以, 至于什么时候使用互斥锁, 什么时候使用自旋锁, 参考第 5 条。

(另外 2.6.35 后, 取消了中断嵌套, 所以中断与中断产生的竞态可以不用担心了)

3.cpu 运行在中断上下文的情况。

中断和抢占产生的竞态都不需要考虑, 因为, 2.6.35 后, 中断嵌套被取消, 且中断上下文的优先级要高于进程上下文(抢占本身是进程抢占, 运行在进程上下文)。

需要考虑的是多处理器产生的竞态, 参考上一条, 多处理器产生的竞态可以用 spin_lock 或者互斥锁来避免, 但是中断不允许睡眠(互斥锁在拿不到锁的情况下会进入睡眠)所以必须使用 spin_lock。

4.spin_lock 在单处理器的情况下, 自动退化为互斥锁(宋宝华说的, 不理解其中原委)。而实际上, spin_lock 在单处理器情况下没有任何意义。

想象一下单处理器运行在进程上下文, spin_lock 锁住的区域可能产生竞态的情况只有中断, 如果中断中也要访问这段临界区域, 假如中断拿不到锁, 那就彻底挂了(因为他会自旋, 且我们只有一个 cpu), 如果顺利拿到锁, 说实在的在单处理器中没有任何意义。

  1. 什么情况下用自旋锁, 什么情况下用互斥锁?

首先, 在临界区较小, 占用 cpu 时间较短, 或者任务的实时性要求较高的情况下使用自旋锁, 可避免 CPU 频繁调度产生的开销。
其次, 有可能引起阻塞的临界区绝对不可以使用自旋锁, 阻塞意味着进程切换, 切换到的进程如果也要 spin_lock 加锁, 死锁就产生了。
最后, 在中断和软中断中的临界区域, 尽量使用自旋锁(这种情况也符合第一条, 因为中断是实时性要求较高的任务), 如果一定要用互斥锁, 也要 try_lock, 因为中断程序不能睡眠。

5. Go 语言学习-竟态相关

同步的两个用途:

  1. 避免多个线程在同一时刻操作同一个数据块
  2. 协调多个线程, 以避免它们在同一时刻执行同一个代码块

施加保护的重要手段之一, 就是使用实现了某种同步机制的工具, 也称为同步工具。在 Go 语言中, 可供我们选择的同步工具并不少, 本章节中主要介绍锁、信号量同步工具。

5.1. sync.Mutex&&sync.RWMutex

其中, 最重要且最常用的同步工具当属互斥量 (mutual exclusion, 简称 mutex)

使用互斥锁的注意事项:

  1. 不要重复锁定互斥锁;
  2. 不要忘记解锁互斥锁, 必要时使用 defer 语句;
  3. 不要对尚未锁定或者已解锁的互斥锁解锁;
  4. 不要在多个函数之间直接传递互斥锁。

如果一个流程在锁定了某个互斥锁之后分叉了, 或者有被中断的可能, 那么就应该使用 defer 语句来对它进行解锁, 而且这样的 defer 语句应该紧跟在锁定操作之后。这是最保险的一种做法。
我们总是应该保证, 对于每一个锁定操作, 都要有且只有一个对应的解锁操作, 否则会导致 panic。

注意:

  1. 不要重复锁定或忘记解锁, 因为这会造成 goroutine 不必要的阻塞, 甚至导致程序的死锁。
  2. 不要传递互斥锁, 因为这会产生它的副本, 从而引起歧义并可能导致互斥操作的失效。

5.2. 读写锁

它是互斥锁的一种扩展一个读写锁中同时包含了读锁和写锁, 由此也可以看出它对于针对共享资源的读操作和写操作是区别对待的。我们可以基于这件事, 对共享资源实施更加细致的访问控制。

  • 读锁: 可以同时进行多个协程读操作, 不允许写操作
  • 写锁: 只允许同时有一个协程进行写操作, 不允许其他写操作和读操作
  • 方法:
    • RLock: 获取读锁
    • RUnLock: 释放读锁
    • Lock: 获取写锁
    • UnLock: 释放写锁

5.3. sync.Cond

条件变量与互斥锁

条件变量是基于互斥锁的, 它必须有互斥锁的支撑才能发挥作用。

  • 条件变量并不是被用来保护临界区和共享资源的(锁干的事情)
  • 它是用于协调想要访问共享资源的那些线程(条件变量)
  • 条件变量的初始化离不开互斥锁, 并且它的方法有的也是基于互斥锁的。

条件变量有三个方法:

  1. 等待通知 wait
  2. 单发通知 signal
  3. 广播通知 broadcast

注意:

  1. 我们在利用条件变量等待通知的时候, 需要在它基于的那个互斥锁保护下进行。
  2. 而在进行单发通知或广播通知的时候, 却是恰恰相反的, 也就是说, 需要在对应的互斥锁解锁之后再做这两种操作。
func main() {
    var mailbox uint8//0,1 代表是否有信息
    var lock sync.RWMutex//信封上的读写锁
    sendCond := sync.NewCond(&lock)//基于读写锁使用 sync.NewCond 初始化
    recvCond := sync.NewCond(lock.RLocker())
    var wg sync.WaitGroup
    wg.Add(2)
    go func(wg *sync.WaitGroup) {
        lock.Lock()
        for mailbox == 1 {
            fmt.Println("send waiting")
            sendCond.Wait()
        }
        fmt.Println("send success")
        mailbox = 1
        lock.Unlock()
        recvCond.Signal()
        wg.Done()
    }(&wg)

    go func(wg *sync.WaitGroup) {
        lock.RLock()
        for mailbox == 0 {
            fmt.Println("receive waiting")
            recvCond.Wait()
        }
        fmt.Println("receive success")
        mailbox = 0
        lock.RUnlock()
        sendCond.Signal()
        wg.Done()
    }(&wg)
    wg.Wait()
}

注意:

  1. sync.Cond 不是开箱即用的。只能利用 sync.NewCond 函数创建它的指针值。这个函数需要一个 sync.Locker 类型的参数值, 其中 sync.Locker 是一个接口类型。声明中含两个方法定义即 Lock()UnLock()
  2. sync.Mutex 类型和 sync.RWMutex 类型都拥有 Lock 方法和 Unlock 方法, 是指针方法, 这两个类型的指针类型才是接口的实现类型。
  3. 条件变量是基于互斥锁的, 它必须有互斥锁的支撑才能够起作用。它会参与到条件变量的方法实现当中。
  4. 示例中 &lock 变量的 Lock Unlock 方法对应写动作的锁定和解锁
  5. lock.RLocker() 所拥有的 Lock() Unlock 内部调用了 RLock RUnlock 方法。

条件变量 wait 所做的三件事

  1. 把调用它的 goroutine(也就是当前的 goroutine) 加入到当前条件变量的通知队列中。
  2. 解锁当前的条件变量基于的那个互斥锁。
  3. 让当前的 goroutine 处于等待状态, 等到通知到来时再决定是否唤醒它。此时, 这个 goroutine 就会阻塞在调用这个 Wait 方法的那行代码上。
  4. 如果通知到来并且决定唤醒这个 goroutine, 那么就在唤醒它之后重新锁定当前条件变量基于的互斥锁。自此之后, 当前的 goroutine 就会继续执行后面的代码了。

for 语句却可以做多次检查, 直到这个状态改变为止

如果一个 goroutine 因收到通知而被唤醒, 但却发现共享资源的状态, 依然不符合它的要求, 那么就应该再次调用条件变量的 Wait 方法, 并继续等待下次通知的到来。不用 if 因为它不能重复地执行"检查状态 - 等待通知 - 被唤醒"的这个流程

5.4. Signal 方法和 Broadcast 方法

signal 只会唤醒一个因此而等待的 goroutine , 被唤醒的 goroutine 一般都是最早等待的那一个。
broadcast 通知却会唤醒所有为此等待的 goroutine。
这两个方法并不需要受到互斥锁的保护, 我们也最好不要在解锁互斥锁之前调用它们。
条件变量的通知具有即时性。当通知被发送的时候, 如果没有任何 goroutine 需要被唤醒, 那么该通知就会立即失效。

总结

互斥锁是一个很有用的同步工具, 它可以保证每一时刻进入临界区的 goroutine 只有一个。读写锁对共享资源的写操作和读操作则区别看待, 并消除了读操作之间的互斥。
条件变量主要是用于协调想要访问共享资源的那些线程。当共享资源的状态发生变化时, 它可以被用来通知被互斥锁阻塞的线程, 它既可以基于互斥锁, 也可以基于读写锁。当然了, 读写锁也是一种互斥锁, 前者是对后者的扩展。

5.5. sync.Map

Go 语言的原生字典的键类型不能是函数类型、字典类型和切片类型。由于并发安全字典内部使用的存储介质正是原生字典, 又因为它使用的原生字典键类型也是可以包罗万象的 interface{}; 所以, 我们绝对不能带着任何实际类型为函数类型、字典类型或切片类型的键值去操作并发安全字典。

接口如下

type mapInterface interface {
    Load(interface{}) (interface{}, bool)
    Store(key, value interface{})
    LoadOrStore(key, value interface{}) (actual interface{}, loaded bool)
    Delete(interface{})
    Range(func(key, value interface{}) (shouldContinue bool))
}

可以通过调用 reflect.TypeOf 函数得到一个键值对应的反射类型值

让并发安全字典只能存储某个特定类型的键 这种方案缺少灵活性

type IntStrMap struct {
    m sync.Map
}

func (iMap *IntStrMap) Delete(key int) {
    iMap.m.Delete(key)
}

func (iMap *IntStrMap) Load(key int) (value string, ok bool) {
    v, ok := iMap.m.Load(key)
    if v != nil {
        value = v.(string)
    }
    return
}

func (iMap *IntStrMap) LoadOrStore(key int, value string) (actual string, loaded bool) {
    a, loaded := iMap.m.LoadOrStore(key, value)
    actual = a.(string)
    return
}

func (iMap *IntStrMap) Range(f func(key int, value string) bool) {
    f1 := func(key, value interface{}) bool {
        return f(key.(int), value.(string))
    }
    iMap.m.Range(f1)
}

func (iMap *IntStrMap) Store(key int, value string) {
    iMap.m.Store(key, value)
}

func main() {
    var myMap IntStrMap
    myMap.Store(1, "s1")
    myMap.Store(2, "s2")
    myMap.Delete(1)
    myMap.LoadOrStore(2, "s22")
    myMap.Store(3, "s3")
    myMap.Range(func(key int, value string) bool {
        println(key, value)
        return true
    } )
}

封装的结构体类型的所有方法, 都可以与 sync.Map 类型的方法完全一致(包括方法名称和方法签名)

type ConcurrentMap struct {
    m         sync.Map
    keyType   reflect.Type
    valueType reflect.Type
}

func (c *ConcurrentMap) Load(key interface{}) (value interface{}, ok bool) {
    if reflect.TypeOf(key) != c.keyType {
        return
    }
    return c.m.Load(key)
}

func (c *ConcurrentMap) Store(key, value interface{}) {
    if reflect.TypeOf(key) != c.keyType {
        panic(fmt.Errorf("wrong key type: %v", reflect.TypeOf(key)))
    }
    if reflect.TypeOf(value) != c.valueType {
        panic(fmt.Errorf("wrong value type: %v", reflect.TypeOf(key)))
    }
    c.m.Store(key, value)
}

func (c *ConcurrentMap) LoadOrStore(key, value interface{}) (actual interface{}, loaded bool) {
    if reflect.TypeOf(key) != c.keyType {
        panic(fmt.Errorf("wrong key type: %v", reflect.TypeOf(key)))
    }
    if reflect.TypeOf(value) != c.valueType {
        panic(fmt.Errorf("wrong value type: %v", reflect.TypeOf(key)))
    }
    return c.m.LoadOrStore(key, value)
}

func (c *ConcurrentMap) Delete(key interface{}) {
    if reflect.TypeOf(key) != c.keyType {
        return
    }
    c.m.Delete(key)
}

func (c *ConcurrentMap) Range(f func(key, value interface{}) (shouldContinue bool)) {
    f1 := func(key, value interface{}) bool {
        if reflect.TypeOf(key) != c.keyType {
            return false
        }
        if reflect.TypeOf(value) != c.valueType {
            return false
        }
        return f(key, value)
    }
    c.m.Range(f1)
}

func main() {
    myMap := ConcurrentMap{
        m:         sync.Map{},
        keyType:   reflect.TypeOf(1),
        valueType: reflect.TypeOf(""),
    }
    myMap.Store(1, "s1")
    myMap.Store(2, "s2")
    myMap.Delete(1)
    myMap.LoadOrStore(2, "s22")
    myMap.Store(3, "s3")
    myMap.Range(func(key interface{}, value interface{}) bool {
        println(key.(int), value.(string))
        return true
    })
}

5.6. sync.Map 实现原理

sync.Map 类型在内部使用了大量的原子操作来存取键和值
使用了两个原生的 map 作为存储介质。
只读字典: 一个原生 map 被存在了 sync.Map 的 read 字段中, 该字段是 sync/atomic.Value 类型的, 这个原生字典可以被看作一个快照, 它总会在条件满足时, 去重新保存所属的 sync.Map 值中包含的所有键值对。
只读字典虽然不会增减其中的键, 但却允许变更其中的键所对应的值。所以, 它并不是传统意义上的快照, 它的只读特性只是对于其中键的集合而言的。
由 read 字段的类型可知, sync.Map 在替换只读字典的时候根本用不着锁。另外, 这个只读字典在存储键值对的时候, 还在值之上封装了一层。先把值转换为了 unsafe.Pointer 类型的值, 然后再把后者封装, 并储存在其中的原生字典中。如此一来, 在变更某个键所对应的值的时候, 就也可以使用原子操作了。
脏字典: sync.Map 中的另一个原生字典由它的 dirty 字段代表。 它存储键值对的方式与 read 字段中的原生字典一致, 它的键类型也是 interface{}, 并且同样是把值先做转换和封装后再进行储存的。
注意, 脏字典和只读字典如果都存有同一个键值对, 那么这里的两个键指的肯定是同一个基本值, 对于两个值来说也是如此。这两个字典在存储键和值的时候都只会存入它们的某个指针, 而不是基本值。

sync.Map 在查找指定的键所对应的值的时候, 总会先去只读字典中寻找, 并不需要锁定互斥锁。只有当确定"只读字典中没有, 但脏字典中可能会有这个键"的时候, 它才会在锁的保护下去访问脏字典。
相对应的, sync.Map 在存储键值对的时候, 只要只读字典中已存有这个键, 并且该键值对未被标记为"已删除", 就会把新值存到里面并直接返回, 这种情况下也不需要用到锁。
否则, 它才会在锁的保护下把键值对存储到脏字典中。这个时候, 该键值对的"已删除"标记会被抹去。

5.6.1. 小结

  1. read 只读字典(原子值)键值对可以变更, 但是不能增减; 只需要原子操作, 无需动用锁
  2. dirty 脏字典 键值对既可变更也能够增减, 需要使用锁来进行保护

当一个键值对应该被删除但是仍然出现在只读字典中的时候, 才会被用已删除标记, 这种方式是逻辑删除而非物理删除。这种情况可以在查询和便利键值对的时候, 已经被逻辑删除的键值对会被无视。

当删除键值对的时候 sync.Map 先检查只读字典是否有对应的键, 没有的话脏字典中可能有, 那么就可以在锁的保护下去执行删除, 最后 sync.Map 会把键值对中指向值的那个指针执为 nil, 这是另一种逻辑删除的方式。

除此之外, 还有一个细节需要注意, 只读字典和脏字典之间是会互相转换的。在脏字典中查找键值对次数足够多的时候, sync.Map 会把脏字典直接作为只读字典, 保存在它的 read 字段中, 然后把代表脏字典的 dirty 字段的值置为 nil。在这之后, 一旦再有新的键值对存入, 它就会依据只读字典去重建脏字典。这个时候, 它会把只读字典中已被逻辑删除的键值对过滤掉。理所当然, 这些转换操作肯定都需要在锁的保护下进行。

5.6.2. 总结

sync.Map 的只读字典和脏字典中的键值对集合, 并不是实时同步的, 它们在某些时间段内可能会有不同。
由于只读字典中键的集合不能被改变, 所以其中的键值对有时候可能是不全的。相反, 脏字典中的键值对集合总是完全的, 并且其中不会包含已被逻辑删除的键值对。
在读操作有很多但写操作却很少的情况下, 并发安全字典的性能往往会更好, 在几个写操作当中, 新增键值对的操作对并发安全字典的性能影响是最大的, 其次是删除操作, 最后才是修改操作。

5.7. 原子操作

  1. 条件变量主要是用于协调想要访问共享资源的那些线程(作用于线程)
  2. 互斥锁保证每一时刻进入临界区的 goroutine 只有一个(作用于临界区)

在同一时刻, 只可能有少数的 goroutine 真正地处于运行状态, 并且这个数量只会与 M 的数量一致, 而不会随着 G 的增多而增长。(GMP 模型了解一下)

cpu 负责协调换上-goroutine 由非运行态转变为运行态代码在某个 cpu 上执行 换下-使一个 goroutine 中的代码中断执行, 由运行态转变为非运行态。
中断点可以再任何语句执行的间隙, 甚至是莫挑语句执行的过程中
在临界区也是一样的, 所以互斥锁只能保证代码的串行执行, 不能保证原子性

真正能够保证原子性的是原子操作 atomic operation, 原子操作不能中断的特性是由底层 CPU 提供芯片级别的支持, 所以绝对有效。

  • 优点: 原子操作能够试下解除竞态的问题, 能够保证并发安全, 执行的速度更快(数量级)
  • 缺点: 由于原子操作不能中断所以需要简单并且要求快速, 操作系统层面只对针对二进制位或整数的原子操作提供了支持。

Go 语言的原子操作当然是基于 CPU 和操作系统的, 所以它也只针对少数数据类型的值提供了原子操作函数。这些函数都存在于标准库代码包 sync/atomic 中。

sync/atomic 原子操作:

  • 加法 (add)
  • 比较并交换 (compare and swap, 简称 CAS)
  • 加载 (load)
  • 存储 (store)
  • 交换 (swap)

这些函数针对的数据类型并不多。但是, 对这些类型中的每一个, sync/atomic 包都会有一套函数给予支持。这些数据类型有: int32、int64、uint32、uint64、uintptr, 以及 unsafe 包中的 Pointer。不过, 针对 unsafe.Pointer 类型, 该包并未提供进行原子加法操作的函数。

此外, sync/atomic 包还提供了一个名为 Value 的类型, 它可以被用来存储任意类型的值。

5.7.1. 简易自旋锁 spinlock

for 语句加 CAS 操作的假设往往是: 共享资源状态的改变并不频繁, 或者, 它的状态总会变成期望的那样。这是一种更加乐观, 或者说更加宽松的做法。

func main() {
    var num int32 = 9
    
    go func(num *int32) {
        time.Sleep(time.Second)
        *num -= 1
        fmt.Println(*num)
    }(&num)

    go func(num *int32) {
        *num += 2
        fmt.Println(*num)
    }(&num)

    for {
        if atomic.CompareAndSwapInt32(&num, 10, 0) {
            fmt.Println("The second number has gone to zero.")
            break
        }
        time.Sleep(time.Millisecond * 500)
    }
}

5.7.2. sync/atomic.Value

此类型的值相当于一个容器, 可以被用来"原子地"存储和加载任意的值。
它只有两个指针方法: StoreLoad

规则:

  • 第一条规则, 不能用原子值存储 nil。
  • 第二条规则, 我们向原子值存储的第一个值, 决定了它今后能且只能存储哪一个类型的值。

sync.Value 存储用类型可能带来的问题

func main() {
    var store atomic.Value
    arr := []int{1,2,3}
    store.Store(arr)
    arr[0] = 4 //这种操作不是并发安全的
    fmt.Println(store.Load())
}
func main() {
    var store atomic.Value
    arr := []int{1, 2, 3}
    f := func(v []int) {
        replica := make([]int, len(v))
        copy(replica, v)
        store.Store(replica)
    }
    f(arr)
    arr[0] = 4 //没有关系因为使用的是复制的数组
}

相对于原子操作函数, 原子值类型的优势很明显, 但它的使用规则也更多一些。
首先, 在首次真正使用后, 原子值就不应该再被复制了

原子值的 Store 方法对其参数值(也就是被存储值)有两个强制的约束。
一个约束是, 参数值不能为 nil。
另一个约束是, 参数值的类型不能与首个被存储值的类型不同。

  • 不要对外暴露原子变量
  • 不要传递原子值及其指针值
  • 尽量不要在原子值中存储引用类型的值

5.8. sync.once

源码可以说是很简单了

func (o *Once) Do(f func()) {
    if atomic.LoadUint32(&o.done) == 0 {
        // Outlined slow-path to allow inlining of the fast-path.
        o.doSlow(f)
    }
}

func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()
    if o.done == 0 {
        defer atomic.StoreUint32(&o.done, 1)
        f()
    }
}

执行效果

func main() {
    o := &sync.Once{}
    go do(o)
    time.Sleep(1)
}

func do(o *sync.Once){
    fmt.Println("Start do")
    o.Do(func() {
        fmt.Println("Doing something")
    })
    fmt.Println("Do end")
}

输出结果

Start do
Doing something
Start do
Do end

5.9. Context 上下文

context 接口

  1. Deadline 方法是获取设置的截止时间的意思, 第一个返回式是截止时间, 到了这个时间点, Context 会自动发起取消请求; 第二 个返回值 ok==false 时表示没有设置截止时间, 如果需要取消的话, 需要调用取消函数进行取消。
  2. Done 方法返回一个只读的 chan, 类型为 struct{}, 我们在 goroutine 中, 如果该方法返回的 chan 可以读取, 则意味着 parent context 已经发起了取消请求, 我们通过 Done 方法收到这个信号后, 就应该做清理操作, 然后退出 goroutine, 释放资源。之后, Err 方法会返回一个错误, 告知为什么 Context 被取消。
  3. Err 方法返回取消的错误原因, 因为什么 Context 被取消。
  4. Value 方法获取该 Context 上绑定的值, 是一个键值对, 所以要通过一个 Key 才可以获取对应的值, 这个值一般是线程安全的。
type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}

wg 实现

func coordinateWithWaitGroup() {
    total := 100
    stride := 3
    var num int32
    fmt.Printf("The number:%d [with sync.WaitGroup]\n", num)
    var wg sync.WaitGroup
    for i := 1; i <= total; i += stride {
        wg.Add(stride)
        for j := 0; j < stride; j++ {
            go addNum(&num, i, j, wg.Done)
        }
        wg.Wait()
    }
    fmt.Println("End.")
}

func addNum(num *int32, base, number int, done func()) {
    var mutex sync.Mutex
    mutex.Lock()
    *num += int32(1)
    fmt.Printf("The %d number goroutine exec, sum is %d\n", number, *num)
    done()
    mutex.Unlock()
}

func main() {
    coordinateWithWaitGroup()
}

context 实现

func addNum(num *int32, base, number int, done func()) {
    var mutex sync.Mutex
    mutex.Lock()
    *num += int32(1)
    fmt.Printf("The %d number goroutine exec, sum is %d\n", number, *num)
    done()
    mutex.Unlock()
}

func coordinateWithContext() {
    total := 12
    var num int32
    fmt.Printf("The number: %d [with context.Context]\n", num)
    cxt, cancel := context.WithCancel(context.TODO())
    for i := 1; i <= total; i++ {
        go addNum(&num, 0, i, func() {
            if atomic.LoadInt32(&num) == int32(total) {
                cancel()
            }
        })
    }
    <-cxt.Done()
    fmt.Println("End.")
}

func main() {
    coordinateWithContext()
}
  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
竞态条件(race condition)和数据竞争(data race)是并发编程中常见的问题,它们可能会导致程序的不确定行为和错误结果。 竞态条件指的是多个并发操作在没有适当同步的情况下,对共享资源进行读写操作,从而导致结果的不确定性。具体来说,竞态条件发生在满足以下条件时: 1. 多个协程同时访问同一个共享资源。 2. 至少有一个协程对该共享资源进行写操作。 3. 对共享资源的访问没有适当的同步机制进行保护。 数据竞争是竞态条件的一种特殊情况,它指的是多个协程同时对同一个共享变量进行读写操作,且至少有一个协程进行写操作。数据竞争会导致未定义的行为,因为读写操作之间的顺序是不确定的,可能会产生意外结果。 数据竞争可能导致以下问题: 1. 脏读(Dirty read):一个协程在另一个协程修改共享变量之前读取该变量的值,从而读取到了未完成的写操作的结果。 2. 竞态条件:多个协程通过竞争访问共享资源,导致结果的不确定性。 3. 无效的计算结果:由于数据竞争导致共享变量的值在不同协程之间不一致,可能会导致计算结果的错误。 为了避免竞态条件和数据竞争,需要使用合适的同步机制来保护共享资源,例如使用互斥锁、读写锁、通道等。同时,编写并发安全的代码也需要考虑避免共享资源的过度使用,尽量减少共享数据的需求。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

云满笔记

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值