1、什么是并发与并行
- 并发属于代码;
- 井行属于一个运行中的程序。
2、为什么并发那么难
竞争
数据竞争(Data Race)与条件竞争(Race Condition )大体是类似的,但是细分一下,可以理解为一个是面,一个是点,条件竞争是针对一段代码,而数据竞争针对的是访问的数据
- 数据竞争(Data Race): 当一个线程访问一个可变对象的同时,另一个线程正在写入它,就会发生数据竞争。
- 条件竞争(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++这个例子
这个可能看起来很原子,如果放到程序执行中查看执行的流程:
- 检索i的值。
- 增加i的值。
- 存储i的值。
尽管这些操作中的每一个都是原子的,但三者的结合就可能不是,这取决于程序的上下文。 原子操作的一个有趣的性质:将它们结合并不一定会产生更大的原子操作。使一个操作变为原子操作取决于你想让它在哪个上下文中(context)。
- 如果你的上下文是一个非并发进程的程序,那么该代码在该上下文中就是原子的。
- 如果你的上下文是一个goroutine,它不会将i暴露给其他goroutine,那么这个代码就是原子的。
- 如果将i暴露在公共内存中,那么操作就不是原子的
死锁,活锁,饥饿
死锁与活锁
死锁:两个进程或者协程相互竞争导致两个进程或者协程都无法执行 活锁:两个进程或者协程相互加锁解锁,实际上无法实际推进程序执行 有个形象的例子 在一个比较窄但允许两人同行的马路上,两个人相对而行,如果两个人相撞
- 死锁的处理思路是:两个人僵持不动,谁都无法往前走
- 活锁的处理思路是:两个人都很客气的让路给对方,但是两人同时移动到另一侧,又继续相撞,再移动回来又相撞,一直这样持续下去,那么就会发生活锁 相对于死锁,活锁的危害更大,因为死锁了,资源不在执行,而活锁一直在循环执行,浪费大量的资源
如何避免死锁
Coffman 条件如下:
- 相互排斥: 并发进程同时拥有资源的独占权。
- 等待条件: 并发进程必须同时拥有一个资源,并等待额外的资源。
- 没有抢占: 并发进程拥有的资源只能被该进程释放。
- 循环等待 : 一个并发进程(P1)必须等待一系列其他并发进程(P2),这些并发进程同时也在等待进程(P1)
饥饿
饥饿是在任何情况下,并发进程都无法获得执行工作所需的所有资源 。