我们接着之前的并发话题,继续深入。
我们可以使用go关键字创建需要的工作协程。此时我们需要看一下这些goroutine运行的过程中都会有哪些类型的数据存在?
协程创建的时候,可以从协程创建的函数的参数传递及闭包的上下文中,通过值拷贝的方式,获得一份初始的类似上下文的数据。
如果是基本类型,或者值类型,这些拷贝在每个协程上各自复制了一份数据,复制之后数据使用独立,所以也是安全的。
如果这些相关的变量是指针,这些被拷贝的只是指针的值,指针指向的数据就会是协程间一起共享的。要特别注意这些指针指向的内容。
要么是在该协程整个运行周期中不变的对象数据,这也是OK的,这样使用数据性能更高。
要么是用来通信的channel类型、context类型等,这些是并发安全的。
要么就是已经自己实现了并发安全的对象类实例(使用了锁等机制支持数据竞争),这些情况也是安全和允许的。
如果不能明确是上述的这几种情况的指针变量拷贝,就可能存在潜在的并发竞争问题,必须进一步检查相关代码。
在协程的运行过程中,当然也可能会通过使用文件系统,数据库,API接口等粗粒度方式,获取或者和其他系统/进程/线程等产生数据交互。这些粗粒度方式会让并发编程显得更简单些。
我们重点要关注的是在并发协程中,可能会产生竞争的数据。这些就是协程间共享的数据。
golang第一提供的是Mutex(互斥锁),它实现了独自占用机制,这种锁保证同一时刻只能由一个人来操作某一段代码区域。
package main
import (
"fmt"
"sync"
)
func main() {
var count int // 协程间共享的数据
var m sync.Mutex // 申明和定义锁
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
m.Lock() // 加锁
fmt.Printf("thread %d, read count:%d
", idx, count)
count++
m.Unlock() // 解锁
}(i)
}
wg.Wait()
println("main thread, count:", count)
println("done.")
}
上面的例子中,我们看到了Mutex(互斥锁)的基本使用方式,Lock/Unlock方法必须配套使用,提议锁的内容和范围要小,一方面避免锁的嵌套导致死锁,另一方面避免一个锁占用的时间过长,其他人等待太久。
由于Lock/Unlock方法必须配套使用,为了防止遗留,我们常常会使用golang的defer关键字:
m.Lock()
defer m.Unlock()
....golang中,defer是一个超级有意思的东西,golang从语法层面以超级轻柔的方式解决了我们实际写代码中的一个痛点。后面有时间单独聊聊,由于也有一些注意事项。
Lock方法,会导致其他协程阻塞,等待锁的释放。并发的多个协程只能一个一个的进入被锁定的那段代码区域。当锁释放的那一时刻,被阻塞的多个协程哪一个被唤醒,go的调度器会有必定的算法,列如饥饿协程等,但基本是随机的。
因此,这种排他锁存在性能上的代价,由于其他协程会因此等待。
降低这种代价的一个方案是RWMutex(读写锁),它对读多写少的场景给出了一个好一点的方案:
package main
import (
"fmt"
"sync"
)
func main() {
var count int // 协程间共享的数据
var m sync.RWMutex // 申明和定义锁
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
if i == 4 {
m.Lock() // 加写锁
count++
fmt.Printf("thread %d, set count to:%d
", idx, count)
m.Unlock() // 释放写锁
} else {
m.RLock() // 加读锁
fmt.Printf("thread %d, read count:%d
", idx, count)
m.RUnlock() // 释放读锁
}
}(i)
}
wg.Wait()
println("main thread, count:", count)
println("done.")
}读写锁适合读多写少场景,它同时有两个锁,一个是写锁,写锁是完全排它的,同时刻只能有一个在写。另一个是读锁,读锁不排斥他人继续读,因此同一时刻,允许有多个读存在,当然读锁排斥写操作。这样提高了读的并发数和读的效率。
Mutex和RWMutex类型的底层都是一种相对复杂的数据结构,不提议进行复制操作,和参数传递等。尽量就近使用。
Mutex和RWMutex都有一个不堵塞的尝试锁的方法,可以用于适当的场景:
ok1 := m1.TryLock()
ok2 := m2.TryRlock()锁的使用最大的问题是性能代价,和可能的循环死锁。
针对循环死锁,一个是Lock/Unlock必须配套使用,另一个是锁定的范围内不能有跟当前锁有关的内容,尽量是简单的没有锁的代码或者是与当前锁无关的代码。简单才是解决复杂隐患的最好对策。
针对锁的性能代价,只能说寻找适合使用场景的更小的代价去解决问题。每一种解决方案都只适合必定的场景。
列如:避免长时间持有锁,直接复制数据,转换成channel进行通信,使用原子操作取代锁,使用细粒度锁,使用分段锁,使用无锁数据结构等等。
减少锁竞争是一个需要综合考量具体的业务场景,这些我们明天继续聊。