一、并发问题的产生
若多个cpu需要对同一内存地址中的数据,进行增删改查,必须按先来后到的顺序进行。保证不了顺序,程序就会出现问题。这就是所谓的竞争条件 race condition
二、并发问题的场景
先跑个程序看看:
func main() {
runtime.GOMAXPROCS(1) // P的值
var data = 0
access := func(i int) {
data++
fmt.Printf("goroutine:%d data: %v\n", i, data)
}
for i := 0; i < 5; i++ {
go access(i)
}
<-time.After(2 * time.Second)
}
P设置为1,所有goroutine都在一个P的队列中,依次执行。
结果:
goroutine:0 data: 1
goroutine:1 data: 2
goroutine:2 data: 3
goroutine:3 data: 4
goroutine:4 data: 5
P设置为5,5个M与之对应,最多5个goroutine可并发的执行,data的值成不可确定的了。
结果:
goroutine:1 data: 2 //第一次
goroutine:0 data: 1
goroutine:3 data: 4
goroutine:4 data: 5
goroutine:2 data: 5
goroutine:1 data: 1 // 第二次
goroutine:0 data: 2
goroutine:3 data: 4
goroutine:4 data: 5
goroutine:2 data: 5
程序中像这种,需要独占的访问共享资源的代码,有一个专门的名词:临界区(critical section)。
为了使共享数据按人规定好的逻辑来进行正常变化,因此需要在程序操作数据时,使其顺序化,先到先得。这个过程也有个说法,叫保护临界区。
修改代码:
func main() {
runtime.GOMAXPROCS(5)
var data = 0
var memAccess sync.Mutex
access := func(i int) {
memAccess.Lock()
data++
fmt.Printf("goroutine:%d data: %v\n", i, data)
memAccess.Unlock()
}
for i := 0; i < 5; i++ {
go access(i)
}
<-time.After(2 * time.Second)
}
结果:
goroutine:1 data: 1 //第一次
goroutine:0 data: 2
goroutine:3 data: 3
goroutine:4 data: 4
goroutine:2 data: 5
goroutine:0 data: 1 //第二次
goroutine:1 data: 2
goroutine:3 data: 3
goroutine:4 data: 4
goroutine:2 data: 5
通过对data加互斥锁,保护了临界区,使逻辑有序。
没加锁,程序存在正确性问题。加了锁没加对,程序可能会出现下列三种情况:
- 死锁 :
type value struct {
memAccess sync.Mutex
value int
}
func main() {
runtime.GOMAXPROCS(3)
var wg sync.WaitGroup
sum := func(v1, v2 *value) {
defer wg.Done()
v1.memAccess.Lock()
time.Sleep(2 * time.Second)
v2.memAccess.Lock()
fmt.Printf("sum = %d\n", v1.value+v2.value)
v2.memAccess.Unlock()
v1.memAccess.Unlock()
}
product := func(v1, v2 *value) {
defer wg.Done()
v2.memAccess.Lock()
time.Sleep(2 * time.Second)
v1.memAccess.Lock()
fmt.Printf("product = %d\n", v1.value*v2.value)
v1.memAccess.Unlock()
v2.memAccess.Unlock()
}
var v1, v2 value
v1.value = 1
v2.value = 1
wg.Add(2)
go sum(&v1, &v2)
go product(&v1, &v2)
wg.Wait()
}
结果:
fatal error: all goroutines are asleep - deadlock!
- 活锁:举个例子,路上一个人向你走来,你想移到一边让他先过,结果他也做了同样的动作。然后这种情况一直持续下去,这就是活锁。
func main() {
runtime.GOMAXPROCS(3)
cv := sync.NewCond(&sync.Mutex{})
go func() {
for range time.Tick(1 * time.Second) { // 通过tick控制两个人的步调
cv.Broadcast()
}
}()
takeStep := func() {
cv.L.Lock()
cv.Wait()
cv.L.Unlock()
}
tryDir := func(dirName string, dir *int32, out *bytes.Buffer) bool {
fmt.Fprintf(out, " %+v", dirName)
atomic.AddInt32(dir, 1)
takeStep() //走上一步
if atomic.LoadInt32(dir) == 1 { //走成功就返回
fmt.Fprint(out, ". Success!")
return true
}
takeStep() // 没走成功,再走回来
atomic.AddInt32(dir, -1)
return false
}
var left, right int32
tryLeft := func(out *bytes.Buffer) bool {
return tryDir("向左走", &left, out)
}
tryRight := func(out *bytes.Buffer) bool {
return tryDir("向右走", &right, out)
}
walk := func(walking *sync.WaitGroup, name string) {
var out bytes.Buffer
defer walking.Done()
defer func() { fmt.Println(out.String()) }()
fmt.Fprintf(&out, "%v is trying to scoot:", name)
for i := 0; i < 5; i++ {
if tryLeft(&out) || tryRight(&out) {
return
}
}
fmt.Fprintf(&out, "\n%v is tried!", name)
}
var trail sync.WaitGroup
trail.Add(2)
go walk(&trail, "男人") // 男人在路上走
go walk(&trail, "女人") // 女人在路上走
trail.Wait()
}
结果:
女人 is trying to scoot: 向左走 向右走 向左走 向右走 向左走 向右走 向左走 向右走 向左走 向右走
女人 is tried!
男人 is trying to scoot: 向左走 向右走 向左走 向右走 向左走 向右走 向左走 向右走 向左走 向右走
男人 is tried!
- 饥饿:贪婪的进程一直占有资源,阻止其他进程甚至全部进程执行任务
func main() {
runtime.GOMAXPROCS(3)
var wg sync.WaitGroup
const runtime = 1 * time.Second
var sharedLock sync.Mutex
greedyWorker := func() {
defer wg.Done()
var count int
for begin := time.Now(); time.Since(begin) <= runtime; {
sharedLock.Lock()
time.Sleep(3 * time.Nanosecond)
sharedLock.Unlock()
count++
}
fmt.Printf("Greedy worker was able to execute %v work loops\n", count)
}
politeWorker := func() {
defer wg.Done()
var count int
for begin := time.Now(); time.Since(begin) <= runtime; {
sharedLock.Lock()
time.Sleep(1 * time.Nanosecond)
sharedLock.Unlock()
sharedLock.Lock()
time.Sleep(1 * time.Nanosecond)
sharedLock.Unlock()
sharedLock.Lock()
time.Sleep(1 * time.Nanosecond)
sharedLock.Unlock()
count++
}
fmt.Printf("Polite worker was able to execute %v work loops\n", count)
}
wg.Add(2)
go greedyWorker()
go politeWorker()
wg.Wait()
}
结果:
Greedy worker was able to execute 273 work loops
Polite worker was able to execute 92 work loops
三、并发问题的总结
锁不用肯定会出问题。用了,解了前面的问题,又出现了更多的新问题。
死锁,是因为错误的使用了锁,导致异常。
活锁,是饥饿的一种特殊情况,逻辑上感觉对,程序也一直在正常的跑,但就是效率低,逻辑上进行不下去。
饥饿,与锁使用的粒度有关,通过计数取样,可以判断进程的工作效率
只要有共享资源的访问,必定要使其逻辑上进行顺序化和原子化,确保访问一致。这绕不开锁这个概念。用锁增加了程序正确性的风险和开发人员脑力上的负担,特别是在一些大型复杂的程序中。
因此,当前解并发问题主流的思路有两种:actor模型与csp模型。他们都是从编程语言的角度去抽象并发模型:不要共享内存,各逻辑块之间,通过通讯解共享内存的问题。
actor模型,强调每个actor有自己的状态和行为。要改变状态,只有通过事件消息,由每个actor进行改变。actor通过邮箱接受外部事件。代表语言有erlang,内部使用了大量的无锁队列,且其语言层面的变量不可重复赋值等特性。每个actor都是一个最基本的计算单元,进入邮箱的事件是保持顺序的,在并发场景下,需要访问共享资源时,进入邮箱时,就保证了其顺序性,actor的调度交给语言层面去做,即这个计算单元什么时候执行,什么时候不执行。
csp模型,本质上来说和actor模型是异曲同工,通过通信达到内存共享的目标,其代表语言golang,通过channel实现不同计算单元(goroutine)间的通讯顺序问题。对共享资源的访问,使用channel和goroutine保证资源的顺序访问,goroutine的调度交给语言层面去做。golang也提供了锁的原语。
参考:《Concurrency in Go》