Go并发编程实现数据竞争

目录
  • 1.前言
  • 2.数据竞争
    • 2.1 示例一
    • 2.2 循环中使用goroutine引用临时变量
    • 2.3 引起变量共享
    • 2.4 不受保护的全局变量
    • 2.5 未受保护的成员变量
    • 2.6 接口中存在的数据竞争
  • 3. 总结
  • 4 参考

1.前言

虽然在 go 中,并发编程十分简单, 只需要使用 go func() 就能启动一个 goroutine 去做一些事情,但是正是由于这种简单我们要十分当心,不然很容易出现一些莫名其妙的 bug 或者是你的服务由于不知名的原因就重启了。 而最常见的bug是关于线程安全方面的问题,比如对同一个map进行写操作。

2.数据竞争

线程安全是否有什么办法检测到呢?

答案就是 data race tag,go 官方早在 1.1 版本就引入了数据竞争的检测工具,我们只需要在执行测试或者是编译的时候加上 -race 的 flag 就可以开启数据竞争的检测

使用方式如下

go test -race main.go
go build -race

不建议在生产环境 build 的时候开启数据竞争检测,因为这会带来一定的性能损失(一般内存5-10倍,执行时间2-20倍),当然 必须要 debug 的时候除外。
建议在执行单元测试时始终开启数据竞争的检测

2.1 示例一

执行如下代码,查看每次执行的结果是否一样

2.1.1 测试

代码

package main

import (
 "fmt"
 "sync"
)

var wg sync.WaitGroup
var counter int

func main() {
 // 多跑几次来看结果
 for i := 0; i < 100000; i++ {
  run()
 }
 fmt.Printf("Final Counter: %d\n", counter)
}

func run() {
    // 开启两个 协程,操作
 for i := 1; i <= 2; i++ {
  wg.Add(1)
  go routine(i)
 }
 wg.Wait()
}

func routine(id int) {
 for i := 0; i < 2; i++ {
  value := counter
  value++
  counter = value
 }
 wg.Done()
}

执行三次查看结果,分别是

Final Counter: 399950
Final Counter: 399989
Final Counter: 400000

原因分析:每一次执行的时候,都使用 go routine(i) 启动了两个 goroutine,但是并没有控制它的执行顺序,并不能满足顺序一致性内存模型。

当然由于种种不确定性,所有肯定不止这两种情况,

2.1.2 data race 检测

上面问题的出现在上线后如果出现bug会非常难定位,因为不知道到底是哪里出现了问题,所以我们就要在测试阶段就结合 data race 工具提前发现问题。

使用

go run -race ./main.go

输出: 运行结果发现输出记录太长,调试的时候并不直观,结果如下

main.main()
      D:/gopath/src/Go_base/daily_test/data_race/demo.go:14 +0x44
==================
Final Counter: 399987
Found 1 data race(s)
exit status 66

2.1.3 data race 配置

在官方的文档当中,可以通过设置 GORACE 环境变量,来控制 data race 的行为, 格式如下:

GORACE="option1=val1 option2=val2"

可选配置见下表

配置

GORACE="halt_on_error=1 strip_path_prefix=/mnt/d/gopath/src/Go_base/daily_test/data_race/01_data_race" go run -race ./demo.go

输出:

==================
WARNING: DATA RACE
Read at 0x00000064d9c0 by goroutine 8:
  main.routine()
      /mnt/d/gopath/src/Go_base/daily_test/data_race/demo.go:31 +0x47
 
Previous write at 0x00000064d9c0 by goroutine 7:
  main.routine()
      /mnt/d/gopath/src/Go_base/daily_test/data_race/demo.go:33 +0x64
 
Goroutine 8 (running) created at:
  main.run()
      /mnt/d/gopath/src/Go_base/daily_test/data_race/demo.go:24 +0x75
  main.main()
      /mnt/d/gopath/src/Go_base/daily_test/data_race/demo.go:14 +0x3c
 
Goroutine 7 (finished) created at:
  main.run()
      /mnt/d/gopath/src/Go_base/daily_test/data_race/demo.go:24 +0x75
  main.main()
      /mnt/d/gopath/src/Go_base/daily_test/data_race/demo.go:14 +0x3c
==================
exit status 66

说明:结果告诉可以看出 31 行这个地方有一个 goroutine 在读取数据,但是呢,在 33 行这个地方又有一个 goroutine 在写入,所以产生了数据竞争。
然后下面分别说明这两个 goroutine 是什么时候创建的,已经当前是否在运行当中。

2.2 循环中使用goroutine引用临时变量

代码如下:

func main() {
 var wg sync.WaitGroup
 wg.Add(5)
 for i := 0; i < 5; i++ {
  go func() {
   fmt.Println(i)
   wg.Done()
  }()
 }
    wg.Wait()
}

输出:常见的答案就是会输出 5 个 5,因为在 for 循环的 i++ 会执行的快一些,所以在最后打印的结果都是 5
这个答案不能说不对,因为真的执行的话大概率也是这个结果,但是不全。因为这里本质上是有数据竞争,在新启动的 goroutine 当中读取 i 的值,在 main 中写入,导致出现了 data race,这个结果应该是不可预知的,因为我们不能假定 goroutine 中 print 就一定比外面的 i++ 慢,习惯性的做这种假设在并发编程中是很有可能会出问题的

正确示例:将 i 作为参数传入即可,这样每个 goroutine 拿到的都是拷贝后的数据

func main() {
 var wg sync.WaitGroup
 wg.Add(5)
 for i := 0; i < 5; i++ {
  go func(i int) {
   fmt.Println(i)
   wg.Done()
  }(i)
 }
 wg.Wait()
}

2.3 引起变量共享

代码

package main

import "os"

func main() {
 ParallelWrite([]byte("xxx"))
}

// ParallelWrite writes data to file1 and file2, returns the errors.
func ParallelWrite(data []byte) chan error {
 res := make(chan error, 2)

 // 创建/写入第一个文件
 f1, err := os.Create("/tmp/file1")

 if err != nil {
  res <- err
 } else {
  go func() {
   // 下面的这个函数在执行时,是使用err进行判断,但是err的变量是个共享的变量
   _, err = f1.Write(data)
   res <- err
   f1.Close()
  }()
 }

  // 创建写入第二个文件n
 f2, err := os.Create("/tmp/file2")
 if err != nil {
  res <- err
 } else {
  go func() {
   _, err = f2.Write(data)
   res <- err
   f2.Close()
  }()
 }
 return res
}

分析: 使用 go run -race main.go 执行,可以发现这里报错的地方是,21 行和 28 行,有 data race,这里主要是因为共享了 err 这个变量

root@failymao:/mnt/d/gopath/src/Go_base/daily_test/data_race# go run -race demo2.go
==================
WARNING: DATA RACE
Write at 0x00c0001121a0 by main goroutine:
  main.ParallelWrite()
      /mnt/d/gopath/src/Go_base/daily_test/data_race/demo2.go:28 +0x1dd
  main.main()
      /mnt/d/gopath/src/Go_base/daily_test/data_race/demo2.go:6 +0x84

Previous write at 0x00c0001121a0 by goroutine 7:
  main.ParallelWrite.func1()
      /mnt/d/gopath/src/Go_base/daily_test/data_race/demo2.go:21 +0x94

Goroutine 7 (finished) created at:
  main.ParallelWrite()
      /mnt/d/gopath/src/Go_base/daily_test/data_race/demo2.go:19 +0x336
  main.main()
      /mnt/d/gopath/src/Go_base/daily_test/data_race/demo2.go:6 +0x84
==================
Found 1 data race(s)
exit status 66

修正: 在两个goroutine中使用新的临时变量

_, err := f1.Write(data)
...
_, err := f2.Write(data)
...

2.4 不受保护的全局变量

所谓全局变量是指,定义在多个函数的作用域之外,可以被多个函数或方法进行调用,常用的如 map数据类型

// 定义一个全局变量 map数据类型
var service = map[string]string{}

// RegisterService RegisterService
// 用于写入或更新key-value
func RegisterService(name, addr string) {
 service[name] = addr
}

// LookupService LookupService
// 用于查询某个key-value
func LookupService(name string) string {
 return service[name]
}

要写出可测性比较高的代码就要少用或者是尽量避免用全局变量,使用 map 作为全局变量比较常见的一种情况就是配置信息。关于全局变量的话一般的做法就是加锁,或者也可以使用 sync.Ma

var (
service   map[string]string
serviceMu sync.Mutex
)

func RegisterService(name, addr string) {
 serviceMu.Lock()
 defer serviceMu.Unlock()
 service[name] = addr
}

func LookupService(name string) string {
 serviceMu.Lock()
 defer serviceMu.Unlock()
 return service[name]
}

2.5 未受保护的成员变量

一般讲成员变量 指的是数据类型为结构体的某个字段。 如下一段代码

type Watchdog struct{
    last int64
}

func (w *Watchdog) KeepAlive() {
    // 第一次进行赋值操作
 w.last = time.Now().UnixNano()
}

func (w *Watchdog) Start() {
 go func() {
  for {
   time.Sleep(time.Second)
   // 这里在进行判断的时候,很可能w.last更新正在进行
   if w.last < time.Now().Add(-10*time.Second).UnixNano() {
    fmt.Println("No keepalives for 10 seconds. Dying.")
    os.Exit(1)
   }
  }
 }()
}

使用原子操作atomiic

type Watchdog struct{
    last int64 

}

func (w *Watchdog) KeepAlive() {
    // 修改或更新
 atomic.StoreInt64(&w.last, time.Now().UnixNano())
}

func (w *Watchdog) Start() {
 go func() {
  for {
   time.Sleep(time.Second)
   // 读取
   if atomic.LoadInt64(&w.last) < time.Now().Add(-10*time.Second).UnixNano() {
    fmt.Println("No keepalives for 10 seconds. Dying.")
    os.Exit(1)
   }
  }
 }()
}

2.6 接口中存在的数据竞争

一个很有趣的例子 Ice cream makers and data races

package main

import "fmt"

type IceCreamMaker interface {
 // Great a customer.
 Hello()
}

type Ben struct {
 name string
}

func (b *Ben) Hello() {
 fmt.Printf("Ben says, \"Hello my name is %s\"\n", b.name)
}

type Jerry struct {
 name string
}

func (j *Jerry) Hello() {
 fmt.Printf("Jerry says, \"Hello my name is %s\"\n", j.name)
}

func main() {
 var ben = &Ben{name: "Ben"}
 var jerry = &Jerry{"Jerry"}
 var maker IceCreamMaker = ben

 var loop0, loop1 func()

 loop0 = func() {
  maker = ben
  go loop1()
 }

 loop1 = func() {
  maker = jerry
  go loop0()
 }

 go loop0()

 for {
  maker.Hello()
 }
}

这个例子有趣的点在于,最后输出的结果会有这种例子

Ben says, "Hello my name is Jerry"
Ben says, "Hello my name is Jerry"

这是因为我们在maker = jerry这种赋值操作的时候并不是原子的,在上一篇文章中我们讲到过,只有对 single machine word 进行赋值的时候才是原子的,虽然这个看上去只有一行,但是 interface 在 go 中其实是一个结构体,它包含了 type 和 data 两个部分,所以它的复制也不是原子的,会出现问题

type interface struct {
   Type uintptr     // points to the type of the interface implementation
   Data uintptr     // holds the data for the interface's receiver
}

这个案例有趣的点还在于,这个案例的两个结构体的内存布局一模一样所以出现错误也不会 panic 退出,如果在里面再加入一个 string 的字段,去读取就会导致 panic,但是这也恰恰说明这个案例很可怕,这种错误在线上实在太难发现了,而且很有可能会很致命。

3. 总结

使用 go build -race main.go和go test -race ./ 可以测试程序代码中是否存在数据竞争问题

  • 善用 data race 这个工具帮助我们提前发现并发错误
  • 不要对未定义的行为做任何假设,虽然有时候我们写的只是一行代码,但是 go 编译器可能后面做了很多事情,并不是说一行写完就一定是原子的
  • 即使是原子的出现了 data race 也不能保证安全,因为我们还有可见性的问题,上篇我们讲到了现代的 cpu 基本上都会有一些缓存的操作。
  • 所有出现了 data race 的地方都需要进行处理

4 参考

https://lailin.xyz/post/go-training-week3-data-race.html#典型案例
https://dave.cheney.net/2014/06/27/ice-cream-makers-and-data-races
http://blog.golang.org/race-detector
https://golang.org/doc/articles/race_detector.html
https://dave.cheney.net/2018/01/06/if-aligned-memory-writes-are-atomic-why-do-we-need-the-sync-atomic-package

到此这篇关于Go并发编程实现数据竞争的文章就介绍到这了,更多相关Go 数据竞争内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • Go语言中转换JSON数据简单例子

    Go语言转换JSON数据真是非常的简单. 以EasyUI的Demo为例,将/demo/datagrid/datagrid_data1.json 拷贝到$GOPATH/src目录: JSON.go: 复制代码 代码如下: package main import (         "encoding/json"         "fmt"         "io/ioutil" ) type product struct {         Pro

  • Go并发编程实现数据竞争

    目录 1.前言 2.数据竞争 2.1 示例一 2.2 循环中使用goroutine引用临时变量 2.3 引起变量共享 2.4 不受保护的全局变量 2.5 未受保护的成员变量 2.6 接口中存在的数据竞争 3. 总结 4 参考 1.前言 虽然在 go 中,并发编程十分简单, 只需要使用 go func() 就能启动一个 goroutine 去做一些事情,但是正是由于这种简单我们要十分当心,不然很容易出现一些莫名其妙的 bug 或者是你的服务由于不知名的原因就重启了. 而最常见的bug是关于线程安全

  • Java 多线程并发编程_动力节点Java学院整理

    一.多线程 1.操作系统有两个容易混淆的概念,进程和线程. 进程:一个计算机程序的运行实例,包含了需要执行的指令:有自己的独立地址空间,包含程序内容和数据:不同进程的地址空间是互相隔离的:进程拥有各种资源和状态信息,包括打开的文件.子进程和信号处理. 线程:表示程序的执行流程,是CPU调度执行的基本单位:线程有自己的程序计数器.寄存器.堆栈和帧.同一进程中的线程共用相同的地址空间,同时共享进进程锁拥有的内存和其他资源. 2.Java标准库提供了进程和线程相关的API,进程主要包括表示进程的jav

  • Python中的并发编程实例

    一.简介 我们将一个正在运行的程序称为进程.每个进程都有它自己的系统状态,包含内存状态.打开文件列表.追踪指令执行情况的程序指针以及一个保存局部变量的调用栈.通常情况下,一个进程依照一个单序列控制流顺序执行,这个控制流被称为该进程的主线程.在任何给定的时刻,一个程序只做一件事情. 一个程序可以通过Python库函数中的os或subprocess模块创建新进程(例如os.fork()或是subprocess.Popen()).然而,这些被称为子进程的进程却是独立运行的,它们有各自独立的系统状态以及

  • python并发编程之线程实例解析

    常用用法 t.is_alive() Python中线程会在一个单独的系统级别线程中执行(比如一个POSIX线程或者一个Windows线程) 这些线程将由操作系统来全权管理.线程一旦启动,将独立执行直到目标函数返回.可以通过查询 一个线程对象的状态,看它是否还在执行t.is_alive() t.join() 可以把一个线程加入到当前线程,并等待它终止 Python解释器在所有线程都终止后才继续执行代码剩余的部分 daemon 对于需要长时间运行的线程或者需要一直运行的后台任务,可以用后台线程(也称

  • Java多线程并发编程和锁原理解析

    这篇文章主要介绍了Java多线程并发编程和锁原理解析,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下 一.前言 最近项目遇到多线程并发的情景(并发抢单&恢复库存并行),代码在正常情况下运行没有什么问题,在高并发压测下会出现:库存超发/总库存与sku库存对不上等各种问题. 在运用了 限流/加锁等方案后,问题得到解决. 加锁方案见下文. 二.乐观锁 & 悲观锁 1.乐观锁 顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁

  • java并发编程专题(七)----(JUC)ReadWriteLock的用法

    前面我们已经分析过JUC包里面的Lock锁,ReentrantLock锁和semaphore信号量机制.Lock锁实现了比synchronized更灵活的锁机制,Reentrantlock是Lock的实现类,是一种可重入锁,都是每次只有一次线程对资源进行处理:semaphore实现了多个线程同时对一个资源的访问:今天我们要讲的ReadWriteLock锁将实现另外一种很重要的功能:读写分离锁. 假设你的程序中涉及到对一些共享资源的读和写操作,且写操作没有读操作那么频繁.在没有写操作的时候,两个线

  • Java并发编程之线程之间的共享和协作

    一.线程间的共享 1.1 ynchronized内置锁 用处 Java支持多个线程同时访问一个对象或者对象的成员变量 关键字synchronized可以修饰方法或者以同步块的形式来进行使用 它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中 它保证了线程对变量访问的可见性和排他性(原子性.可见性.有序性),又称为内置锁机制. 对象锁和类锁 对象锁是用于对象实例方法,或者一个对象实例上的 类锁是用于类的静态方法或者一个类的class对象上的 类的对象实例可以有很多个,但是每个类只有

  • Python并发编程实例教程之线程的玩法

    目录 一.线程基础以及守护进程 二.线程锁(互斥锁) 三.线程锁(递归锁) 四.死锁 五.队列 六.相关面试题 七.判断数据是否安全 八.进程池 & 线程池 总结 一.线程基础以及守护进程 线程是CPU调度的最小单位 全局解释器锁 全局解释器锁GIL(global interpreter lock) 全局解释器锁的出现主要是为了完成垃圾回收机制的回收机制,对不同线程的引用计数的变化记录的更加精准. 全局解释器锁导致了同一个进程中的多个线程只能有一个线程真正被CPU执行. GIL锁每执行700条指

  • Java面试题冲刺第二十五天--并发编程1

    目录 面试题1:简单说下你对线程和进程的理解? 正经回答: 深入追问: 追问1:那进程和线程有哪些区别呢? 面试题2:守护线程和用户线程的区别? 正经回答: 面试题3:什么是线程死锁? 正经回答: 深入追问: 追问1:形成死锁的四个必要条件是什么? 追问2:我们该如何避免死锁? 追问3:死锁避免和死锁预防有啥不同? 总结 面试题1:简单说下你对线程和进程的理解? 正经回答: 进程 一个在内存中运行的应用程序.每个进程都有自己独立的一块内存空间,一个进程可以有多个线程,比如在Windows系统中,

  • Java并发编程深入理解之Synchronized的使用及底层原理详解 下

    目录 一.synchronized锁优化 1.自旋锁与自适应自旋 2.锁消除 逃逸分析: 3.锁粗化 二.对象头内存布局 三.synchronized锁的膨胀升级过程 1.偏向锁 2.轻量级锁 3.重量级锁 4.各种锁的优缺点 接着上文<Java并发编程深入理解之Synchronized的使用及底层原理详解 上>继续介绍synchronized 一.synchronized锁优化 高效并发是从JDK 5升级到JDK 6后一项重要的改进项,HotSpot虚拟机开发团队在这个版本上花费了大量的资源

随机推荐