golang的 data race 分析

golang的data race

一、名词解析

1、data race: Any race is a bug
定义: ①多个线程(协程)对于同一个变量、②同时地、③进行读/写操作、并且④至少有一个线程进行写操作。(也就是说,如果所有线程都是只进行读操作,那么将不构成数据争用)

后果: 如果发生了数据争用,读取该变量时得到的值将变得不可知(根据内存模型),使得该多线程程序的运行结果将完全不可预测,有一定可能会导致直接崩溃。

如何防止: 对于有可能被多个线程同时访问的变量使用排他访问控制,具体方法包括使用mutex(互斥量)或者使用atomic变量。

race condition: 读取到数据中间状态的的情形就是 race condition。相对于数据争用(data race),竞态条件(race condition)指的是更加高层次的更加复杂的现象,一般需要在设计并行程序时进行细致入微的分析,才能确定。(也就是隐藏得更深).

定义:受各线程上代码执行的顺序和时机的影响,程序的运行结果产生(预料之外)的变化。

后果:如果存在竞态条件(race condition),多次运行程序对于同一个输入将会有不同的结果,但结果并非完全不可预测,它将由输入数据和各线程的执行顺序共同决定。

如何预防:竞态条件产生的原因很多是对于同一个资源的一系列连续操作并不是原子性的,也就是说有可能在执行的中途被其他线程抢占,同时这个“其他线程”刚好也要访问这个资源。解决方法通常是:将这一系列操作作为一个critical section(临界区)。

2、undefined behavior: 未定义行为是指执行某种计算机代码所产生的结果,这种代码在当前程序状态下的行为在其所使用的语言标准中没有规定。在 Go 的内存模型中,有 race 的 Go 程序的行为是未定义行为

3、go run/build -race :golang在1.1之后引入了竞争检测的概念。我们可以使用go run -race 或者 go build -race 来进行竞争检测. -race 选项打开了 data race detector 用来检查这个错误而关闭了相关的编译器优化。go 编译器认为race代码是 dead code,可能直接优化掉。

参考连接

4、原子性: 一个或者多个操作在 CPU 执行的过程中不被中断的特性,称为原子性(atomicity。这些操作对外表现成一个不可分割的整体,他们要么都执行,要么都不执行,外界不会看到他们只执行到一半的状态。

5、原子操作: 原子操作(atomic operation)指的是由多步操作组成的一个操作。如果该操作不能原子地执行,则要么执行完所有步骤,要么一步也不执行,不可能只执行所有步骤的一个子集。

在单核系统里,单个的机器指令可以看成是原子操作(如果有编译器优化、乱序执行等情况除外),在单核CPU中, 能够在一个指令中完成的操作都可以看作为原子操作, 因为中断只发生在指令间;

在多核系统中,单个的机器指令就不是原子操作,因为多核系统里是多指令流并行运行的,一个核在执行一个指令时,其他核同时执行的指令有可能操作同一块内存区域,从而出现数据竞争现象。多核系统中的原子操作通常使用内存栅障(memory barrier)来实现,即一个CPU核在执行原子操作时,其他CPU核必须停止对内存操作或者不对指定的内存进行操作,这样才能避免数据竞争问题。在多核CPU的时代, 体系中运行着多个独立的CPU, 即使是可以在单个指令中完成的操作也可能会被干扰. 典型的例子就是decl指令(递减指令), 它细分为三个过程: “读->改->写”, 涉及两次内存操作. 如果多个CPU运行的多个进程在同时对同一块内存执行这个指令, 那情况是无法预测的.


二、代码分析

1、race 多协程写分析

golang在1.1之后引入了竞争检测的概念。我们可以使用go run -race 或者 go build -race 来进行竞争检测。
golang语言内部大概的实现就是同时开启多个goroutine执行同一个命令,并且纪录每个变量的状态。

package main

import(
    "time"
    "fmt"
)

func main() {
    a := 1
    go func(){
        a = 2
    }()
    a = 3
    fmt.Println("a is ", a)

    time.Sleep(2 * time.Second)
}

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


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

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

Goroutine 5 (running) created at:
  main.main()
      /Users/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行的时候产生的。

2、多协程读写分析

package main

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

var i = 0

func main() {
    runtime.GOMAXPROCS(2)

    go func() {
        for {
            fmt.Println("i is", i)
            time.Sleep(time.Second)
        }
    }()

    for {
        i += 1
    }
}

go run race时可以看到有明显的竞态出现:

==================
WARNING: DATA RACE
Read at 0x0000005e4600 by goroutine 6:
  main.main.func1()
      /root/gofourge/src/lab/cmd.go:15 +0x63

Previous write at 0x0000005e4600 by main goroutine:
  main.main()
      /root/gofourge/src/lab/cmd.go:20 +0x7b

Goroutine 6 (running) created at:
  main.main()
      /root/gofourge/src/lab/cmd.go:13 +0x4f
==================
i is: 8212
i is: 54959831
i is: 109202117

2、map代码分析

package main

import (
        "encoding/json"
        "fmt"
        "time"
)

type config struct {
        Main map[int]int `json:"main"`
}

var cfg = &config{
        Main: map[int]int{
                1: 2,
        },
}

func main() {
        for i := 0; i < 1; i++ {
                go func() {
                        for {
                                fmt.Println(cfg.Main[0])
                                time.Sleep(time.Millisecond)
                        }
                }()
        }

        go func() {
                for {
                        temp := new(config)
                        json.Unmarshal([]byte(`{"main" : {"2" : 3}}`), temp)
                        cfg = temp

                        // json.Unmarshal([]byte(`{"main" : {"2" : 3}}`), cfg)
                }
        }()
        time.Sleep(time.Hour)
}

加锁保证不会出现data race.


三、指针赋值的原子性

1、cpu的原子性:首先处理器会保证基本的内存操作的原子性,比如从内存读取或者写入一个字节是原子的,但对于读-改-写、或者是其它复杂的内存操作是不能保证其原子性的,又比如跨总线跨多个缓存行跨页表的访问,这时候需要处理器提供总线锁缓存锁两个机制来保证复杂的内存操作原子性。Intel处理器有多种机制保证内存操作的原子性:

  • 直接读写内存的原子性由电路保证,也就是说一个CPU读写的时候是不会有data race的。

  • 复杂内存操作,比如跨页读写、超总线宽度读写,有两种锁来保证操作原子性:

  • 总线锁:处理器HLOCK引脚发信号给总线,其他CPU就不能通过总线去读写内存了。

  • 缓存锁:总线锁性能代价很高,假如数据在L1~L3 cache里,而且长度不超过cache line,那CPU只会锁 住这个cache line不会锁总线。注意一个神奇的地方,如果这块内存即使同时在其他CPU的cache line里, 所有的cache line都会被锁,因为一块芯片上的不同核心的cache有个ring bus,锁一个line等于锁全部。

2、cpu 执行指令的过程:

(1) 根据寄存器中pc计数器的值,从内存中获取要执行的指令命令

(2) 解析指令

(3) cpu 执行指令

(4) 访问内存获取数据

(5) 将结果写回 内存/寄存器

以上,4和5 访问内存不是必须的,如果4,5中包含多次内存的操作,显然就不会是原子的。

3、在什么情况下讨论原子操作和非原子操作才是有意义的。

(1) 只有存在需要访问共享的资源的时候,原子操作和非原子操作才有差别。当两段访问相同资源的代码(A和B,你也可以认为是两个CPU,不影响陈述)造成竞争时,原子操作能够保证结果不是A就是B,也就是说它最终状态是确定的,而非原子性操作不能保证–例如,A需要写地址[0–100],而B也需要写地址[0-100],A写完[0–50]时,然后B开始写[0–100],最后A继续写[51–100],结果就成了前50字节为B的结果,后50字节为A结果。

(2) 当不存在需要访问共享资源的时候,所有的操作都是原子操作。假设A只需要写的地址为[0–100],B只需要写的地址为[200–400],当A和B都运行完成的时候,地址[0–100]以及[200–400]的内容都是唯一确定的。谁敢说他们不是原子性的。如果你要提中断,那么很明显,如果中断也需要操作[0–100],那么中断程序在运行的时候也就变成了(1)中的B了;若中断程序不需要访问[0–100],那即使它打断了A程序,但是等A程序运行完的时候,它的结果也是唯一确定的(我称它为可打断的原子操作),与不可打断的原子操作结果是一样的。【实际上关于原子操作的确是可以打断的,它的不可打断是在逻辑层面上的。】

因此,当不存在资源竞争时,谈原子操作和非原子操作没有意义,因为所有操作都是原子操作。

此时,我们就得到了第1条用于判断一个操作是否是原子操作的规则:

  • 不存在任何资源竞争的操作都是原子操作

这条规则能够说明,对于不会与其他人竞争资源的操作,它一定是原子的。但是它不能确定与别人存在资源竞争的操作是否是原子的。所以,需要额外的规则。

  • 每一个load、store到硬件层面都是有序的,因此如果单一store/load指令就能完成的操作肯定是原子性的

处理器会自动保证基本的内存操作的原子性。处理器保证从系统内存当中读取或者写入一个字节是原子的,意思是当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址

  • 查找对应指令文档,若里面说明这条指令使原子性的,即使它消耗的时间足足为一个小时(仅仅是为了表达长时间的意思),并且需要和所有的其他指令竞争资源,那么它还是原子操作

4、代码执行是否时原子的?

一条语句的执行不一定是原子操作。如果你的代码最终编译为一条机器码指令,则处理器会保证他的原子性, 如果编译成了多条机器码指令,则处理保证不了它的原子性操作.那么我们就需要一些额外的手段来是它成为原子操作, 通常我们用的最多的就是锁这种机制

  • 比如:一个i++;:它涉及到三个动作,读取i变量到寄存器,对其加1,然后保存结果到i变量,它不是原子性的,要保证原子性需要用到锁。

  • *i = *i + 1. 但是这一行在编译器编译之后也是好几个机器指令,所以并发问题并不会解决。

所以解决的办法就是:加锁,保证排他。没有发现任何竞态条件。

Lock.Lock()
*Counter = *Counter + 1
Lock.Unlock()
  • 在 Go(甚至是大部分语言)中,一条普通的赋值语句其实并不是一个原子操作(语言规范同样没有定义 i++ 是原子操作, 任何变量的赋值都不是原子操作)。例如,在 32 位机器上写 int64类型的变量是有中间状态的,它会被拆成两次写操作 MOV —— 写低 32 位和写高 32 位.
package main
import "fmt"
type myType struct {
	A int
}
func main() {
	c := make(chan bool)
	x := new(myType)
	go func() {
		x = new(myType) // write to x
		c <- true
	}()
	_ = *x // read from x
	<-c
	fmt.Println("end")
}

通常情况下指针的大小是小于等于系统的 machine word 的,比如 32 位的系统指针 size 是小于等于 32 bit,64位的系统指针 size 是小于等于 64 bit。 CPU 寄存器的大小一定大于一个 machine word 。也就是说数据复制的最小单元大于一个 machine word。所以在复制指针的时候不可能出现复制到一半的中间状态.


四、双重检验锁的安全性

golang对于双重校验锁执行 go build -race 会出现data race的情况。

实例1:懒汉式单例

package main

import (
	"fmt"
	"os"
	"strconv"
	"time"
)

var config map[string]string

func main() {
	count, _ := strconv.Atoi(os.Args[1])
	for x := 0; x < count; x++ {
		go getConfig()
	}
	<-time.After(time.Second)
}

func getConfig() map[string]string {
	if config == nil {    //多协程读
		lock.Lock()
		defer lock.Unlock()
		if config != nil {
			return config
		}
		config = map[string]string{}  //协程写
		fmt.Println("init config")
		return config
	}
	return config
}

实例2:说明了对于64位数据的写入和读取是非原子操作

package main
 
import (
    "sync"
    "testing"
)
 
var (
    instance *int
    lock      sync.Mutex
)
 
func getInstance() *int {
    if instance == nil {  //多协程读
        lock.Lock()
        defer lock.Unlock() 
        if instance == nil {
            i := 1
            instance = &i //协程写
        }
    }
    return instance
}
 
// 用于下边基准测试
func BenchmarkSprintf(b *testing.B){
 
    for i:=0;i<b.N;i++{
        go getInstance()
    }
}

线程安全改进

  • 1、sync.Once
package main
 
import (
    "sync"
    "testing"
)
 
var (
    instance *int
    once      sync.Once
)
 
func getInstance() *int {
    once.Do(func(){
        if instance == nil {
            i := 1
            instance = &i
        }
    })
    return instance
}
 
func BenchmarkSprintf(b *testing.B){
 
    for i:=0;i<b.N;i++{
        go getInstance()
    }
}

分析:sync.Once使用变量 done 来记录函数的执行状态,使用 sync.Mutexsync.atomic 来保证线程安全的读取 done 。sync.Once使用一个32位无符号整数表示共享变量,即使是32位变量的读写操作都需要atomic包方法来实现原子性,更说明了go里边指针的读写不能保证原子性

func (o *Once) Do(f func()) {
	if atomic.LoadUint32(&o.done) == 1 {
		return
	}
	// Slow-path.
	o.m.Lock()
	defer o.m.Unlock()
	if o.done == 0 {
		defer atomic.StoreUint32(&o.done, 1)
		f()
	}
}
  • 2、用atomic类: 原子操作即是进行过程中不能被中断的操作,针对某个值的原子操作在被进行的过程中,CPU绝不会再去进行其他的针对该值的操作。原子性就是指该操作是不可再分的。不论是多核还是单核,具有原子性的量,同一时刻只能有一个线程来对它进行操作。互斥锁是一种数据结构,使你可以执行一系列互斥操作。而原子操作是互斥的单个操作,这意味着没有其他线程可以打断它。
import (
	"fmt"
	"os"
	"strconv"
	"sync/atomic"
	"time"
)

var config atomic.Value

func main() {
	count, _ := strconv.Atoi(os.Args[1])
	for x := 0; x < count; x++ {
		go getConfig()

	}
	<-time.After(time.Second * 2)
}
func getConfig() map[string]string {
	if config.Load() == nil {
		fmt.Println("init config")
		config.Store(map[string]string{})
		return config.Load().(map[string]string)
	}
	return config.Load().(map[string]string)
}

分析: mutex 由操作系统实现,而 atomic 包中的原子操作则由底层硬件直接提供支持。在 CPU 实现的指令集里,有一些指令被封装进了 atomic 包,这些指令在执行的过程中是不允许中断(interrupt)的,因此原子操作可以在 lock-free 的情况下保证并发安全,并且它的性能也能做到随 CPU 个数的增多而线性扩展。若实现相同的功能,后者通常会更有效率,并且更能利用计算机多核的优势。所以,以后当我们想并发安全的更新一些变量的时候,我们应该优先选择用 atomic 来实现。

总结: 只要是对于共享变量的并发访问,一定要注意安全性,go更推崇避免共享变量,使用chan来交流信息,如果无法避免共享内存,优先使用atomic实现,其次sync,安全第一!

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值