锁是编程语言中比较常用到的一种技术;通常在并发的情况下,多核多个线程同时修改相同的资源时,为了保护我们的资源不会出现脏数据,会用锁来解决这个问题。
并发情况下,为什么修改相同的资源,会有问题;我们得先弄清楚数据修改的过程是怎样的。
我们一般是先从操作系统的内存中读取数据到cpu中,cpu修改数据完成后,把数据再刷回到内存中去;然而因为cpu非常快,内存虽然也快,但是完全没有cpu快,为了弥补这种差距,操作系统在cpu核内存间加了一层缓存,比如我们所知道的L1、L2、L3 cache。
我们来看下,核数不同,协程数量不同,都会有什么影响,这里我们都用累加运算来举例。
单核、单协程情况:
func main() {
runtime.GOMAXPROCS(1) //单核
sum := 0
for i:=0; i < 1000; i++ {
sum += 1
}
fmt.Println(sum) //输出 1000
}
上面这段代码,单核只主协程的情况下,累加,不管运行多少次,输出都是1000。
单核、多协程的情况:
func main() {
runtime.GOMAXPROCS(1) //单核
sum := 0
for i:=0; i < 1000; i++ {
go func() {
sum += 1
}()
}
time.Sleep(1*time.Second)
fmt.Println(sum) //输出 1000
}
单核、多协程的情况,即便是多个协程并发执行,但是不管运行多少次,结果都是1000,因为cpu按照时间片分配给不同的线程,同一个时刻只会有一个线程在执行。如果单核情况下,想出现脏数据,一般情况下,除非是发生了协程的切换。比如下面这种\
var a = 0
func main() { // 主协程
runtime.GOMAXPROCS(1) //单核
go func() { // 协程1
num := getA()
runtime.Gosched() // 手动切换协程
num++
setA(num)
}()
go func() { // 协程2
num := getA()
runtime.Gosched() // 手动切换协程
num++
setA(num)
}()
time.Sleep(2* time.Second)
fmt.Println(a) //输出1
}
func setA(n int) {
a = n
}
func getA() int {
return a
}
上面这段代码,单核情况下,如果我们不加runtime.Gosched(),那么执行的结果会一直是2,因为同一个时刻只会有一个协程执行,协程里面的代码执行非常快,快到不会发生协程的切换;但是如果我们加上runtime.Gosched()后,执行结果变成了1,为什么,因为不管协程1还是协程2先执行,先获取到a的值后切换到另一个协程执行,a的值还没有进行赋值,所以协程1、2执行的结果都是1,最后不管谁去setA都是1,runtime.Gosched()只是我们手动设置协程的切换,如果这里换出执行时间相对较长的代码也是一样的,主要协程执行完了自己分配到的cpu时间片。所以单核的情况下,出现并发导致的脏数据,一般是协程发生切换导致的。
多核单协程的情况:
这种情况,由于只有单个协程,所以不管怎么执行结果都是一样的,不会有并发的问题,和单核单协程一样。
多核、多协程的情况:
func main() {
runtime.GOMAXPROCS(4) //4核
sum := 0
for i:=0; i < 1000; i++ {
go func() {
sum += 1
}()
}
time.Sleep(1*time.Second)
fmt.Println(sum) //输出小于等于1000的各种情况的数值
}
由于我测试的电脑是4核的,这里设置4核来执行相同的累加程序代码,发现每次执行的结果并不是我们想要的1000,而是小于1000的各种数值情况,为啥呢,这里主要是多核cpu程序并行执行的缓存一致性的问题导致的。
单核cpu比较简单,如下图:
单核cpu共享cache,不会出现数据一致性的问题。
多核cpu情况,如下图:
多核cpu并行执行,如果core1对变量a的值做了修改,并且里面修改了core1 cache的数据,但这个时候由于core1 的cache没有立马同步到memory中,导致core1当前的对变量的修改对其他core是不可见的,其它core参与并行计算的时候,仍然使用变量的旧值进行运算,这就导致了数据的不一致性,出现了脏数据。
除了上面所说的线程切换和多核多线程情况下cache与memory之间同步的不一致性问题导致脏数据的情况,还要一种就是指令重排导致的,也就是操作系统执行代码的时候并没有按照我们指定的代码顺序执行,而是用了另一种顺序,这里不做详细介绍。
为了解决多核多线程编程导致的问题,操作系统引入了锁机制,锁机制能够保证在某一时间点上,只会有一个线程进入临界区代码,从而保证临界区中操作数据的一致性。
go语言中的锁主要两类,最基础的是互斥锁,基于互斥锁基础上实现了读写锁。 互斥锁和其中文意思类似,保证了互斥性,多个协程并发去执行,只有一个协程能执行,其他的都不行,满足了互斥性;
读写锁,则是,多个协程读,不会锁,因为加锁是在修改相同资源的时候需要,什么时候会修改呢,写写,写读,都会有修改的情况,但凡多个协程出现有一个写的,那么就需要读写锁。
为什么不直接用互斥锁呢,互斥锁粒度太粗,所有的协程都会有互斥性,如果我不修改资源的时候,都是在读,那么也锁的话,性能会非常低,而如果都在读的时候,是不会出现脏数据,并不需要加锁来保护数据,所以读写锁会更加适合。
以上内容纯属个人理解感悟,如有不恰当的地方欢迎指正交流!