读《go语言并发之道》-1

53 阅读4分钟

1、什么是并发与并行

  • 并发属于代码;
  • 井行属于一个运行中的程序。

2、为什么并发那么难

竞争

数据竞争(Data Race)与条件竞争(Race Condition )大体是类似的,但是细分一下,可以理解为一个是面,一个是点,条件竞争是针对一段代码,而数据竞争针对的是访问的数据

  1. 数据竞争(Data Race): 当一个线程访问一个可变对象的同时,另一个线程正在写入它,就会发生数据竞争。
  2. 条件竞争(Race Condition ): 当事件的时序影响一段代码的正确性时,就会发生竞态条件。

如何检查

1、go语言本身提供了检查,

go run -race main.go 或者 go build -race main.go

它只能检测到运行时的竞争条件;并不能证明之后不会发生数据竞争,所以更重要的对可能产生竞争的情况做好提前的规划,并写好单测

如何避免这类情况呢:

可以使用一下集中方案

  • 锁:互斥锁:Mutex 或者 RWMutex,通过对共享资源加锁来保证同一时间只有一个 goroutine 访问。读写锁:RWMutex,通过读写锁的机制来允许多个 goroutine 同时读取共享资源,但是只允许一个 goroutine 写入共享资源。
  • 原子操作:使用 sync/atomic 包中提供的原子操作,可以对共享变量进行原子操作,从而保证不会出现竞态条件和数据竞争。
  • Channel:使用 Go 语言中的通道机制,可以将数据通过通道传递,从而避免直接对共享资源的访问。 我们举个最简单的例子吧:

1、加锁(讲究一点的可以使用读写锁)

map里面写数据: map 读写竞争,直接赋值会有竞争
var M map[int]int

func run() {
    lock := &sync.Mutex{}
    M = make(map[int]int)
    for i := 0; i < 10; i++ {
       go UpdateMap(i, lock)
    }
    time.Sleep(time.Second * 1)
    fmt.Println(M)
}
func UpdateMap(i int, lock *sync.Mutex) {
    lock.Lock()
    M[i] = i
    defer lock.Unlock()
}
输出:
map[0:0 1:1 2:2 3:3 4:4 5:5 6:6 7:7 8:8 9:9]

2、原子操作 因为原子操作是在cpu的寄存器中进行的,这个和加锁也类似,加锁也是在寄存器中进行一系列加减操作

var data int
var dataAtomic int64
for i := 0; i < 100; i++ {
    go func() {
       data++
       atomic.AddInt64(&dataAtomic, 1)
    }()
}
time.Sleep(1 * time.Second)
fmt.Println("直接加=>", data, "原子操作=》", dataAtomic)
输出结果
直接加=> 99 原子操作=> 100

3、Channel:竞争的关系存在共享内存的并发方案里面,使用基于通信的方式去并发则没有

var data int
var dataChannelRes int
dataChannel := make(chan int, 100)
for i := 0; i < 100; i++ {
    go func() {
       data++
    }()
    go func() {
       dataChannel <- 1
    }()
}
go func() {
    for {
       select {
       case <-dataChannel:
          dataChannelRes++
       }
    }
}()
time.Sleep(1 * time.Second)

fmt.Println("直接加=>", data, "管道=>", dataChannelRes)
输出:
直接加=> 99 管道=> 100

原子性

还是用i++这个例子

这个可能看起来很原子,如果放到程序执行中查看执行的流程:

  1. 检索i的值。
  2. 增加i的值。
  3. 存储i的值。

尽管这些操作中的每一个都是原子的,但三者的结合就可能不是,这取决于程序的上下文。 原子操作的一个有趣的性质:将它们结合并不一定会产生更大的原子操作。使一个操作变为原子操作取决于你想让它在哪个上下文中(context)。

  • 如果你的上下文是一个非并发进程的程序,那么该代码在该上下文中就是原子的。
  • 如果你的上下文是一个goroutine,它不会将i暴露给其他goroutine,那么这个代码就是原子的。
  • 如果将i暴露在公共内存中,那么操作就不是原子的

死锁,活锁,饥饿

死锁与活锁

死锁:两个进程或者协程相互竞争导致两个进程或者协程都无法执行 活锁:两个进程或者协程相互加锁解锁,实际上无法实际推进程序执行 有个形象的例子 在一个比较窄但允许两人同行的马路上,两个人相对而行,如果两个人相撞

  • 死锁的处理思路是:两个人僵持不动,谁都无法往前走
  • 活锁的处理思路是:两个人都很客气的让路给对方,但是两人同时移动到另一侧,又继续相撞,再移动回来又相撞,一直这样持续下去,那么就会发生活锁 相对于死锁,活锁的危害更大,因为死锁了,资源不在执行,而活锁一直在循环执行,浪费大量的资源

如何避免死锁

Coffman 条件如下:

  • 相互排斥: 并发进程同时拥有资源的独占权。
  • 等待条件: 并发进程必须同时拥有一个资源,并等待额外的资源。
  • 没有抢占: 并发进程拥有的资源只能被该进程释放。
  • 循环等待 : 一个并发进程(P1)必须等待一系列其他并发进程(P2),这些并发进程同时也在等待进程(P1)

饥饿

饥饿是在任何情况下,并发进程都无法获得执行工作所需的所有资源 。