锁是什么?


锁是用于解决隔离性的一种机制
某个协程(线程)在访问某个资源时先锁住,防止其他协程的访问,等访问完毕解锁后其他协程再来加锁进行访问

锁是用来做什么的?


控制各个协程的同步,防止资源竞争导致错乱的问题
在高并发的场景下,如果选对了合适的锁,则会大大提高系统的性能,否则性能会降低。

GO中的锁有哪些?

  • 互斥锁
  • 读写锁

我们在编码中会存在多个goroutine协程同时操作一个资源(临界区),这种情况会发生竟态问题(数据竞争
比如生活中 厕所需要排队上,饮水机接水需要一个一个接
体现在代码里是下面这样

package main
import (
  "fmt"
  "sync"
)

var num int64
var wg sync.WaitGroup

func add() {
  for i :=0; i < 1000000; i++ {
    num = num + 1
  }
  wg.Done()
}

func main() {
  wg.add(2)
  
  go add()
  go add()

  wg.Wait()
  fmt.Println(num)
}
200000011191972000000

用锁可以解决这个问题,在操作num之前先确定是否拿到锁,只有拿到锁了才能进行对临界区资源的修改

互斥锁


来思考一下我们想要的加锁后上面代码的运行过程

num = 1num = 2sync包Mutex类型
package main
import (
  "fmt"
  "sync"
)

var num int64
var wg sync.WaitGroup
var lock sync.Mutex

func add() {
  for i :=0; i < 1000000; i++ {
    lock.Lock()
    num = num + 1
    lock.Unlock()
  }
  wg.Done()
}

func main() {
  wg.add(2)
  
  go add()
  go add()

  wg.Wait()
  fmt.Println(num)
}
2000000

读写锁


为什么有了互斥锁,还要读写锁?
很明显互斥锁并不能满足所有的应用场景

互斥锁是完全互斥的,不管协程是读临界区资源还是写临界区资源,都必须要拿到锁。否则就无法操作
但是我们再实际的应用场景下,大多数情况下是读多写少

比如1000个用户同时打开APP需要通过接口获取开屏广告图,这类资源短时间内基本不会发生改变,如果我们也要加锁才能获取数据,接口的响应时间会变的很长。我们不希望发生这种情况

那么,我们的需求是希望大家可以一起读,但是读的过程中不可以改变资源的值。否则大家拿到的结果就不一样了。来思考一下读写混合的场景下 G1、G3只读 G2、G4只写是怎样的执行过程吧

G1加读锁 成功
G2加写锁(资源已被读锁定,自旋)
G3加读锁 成功
G4加写锁(资源已被读锁定,自旋)
G1解读锁
G2解读锁
G3加写锁成功(G3持续尝试加写锁,现在没有读锁加锁成功)
G1加读锁(资源已被读锁定,自旋)
G3解写锁
G4加写锁成功(G4持续尝试加写锁,现在没有读锁加锁成功)
G4解写锁
G1加读锁 成功

当一个goroutine 协程获取读锁之后,其他的 goroutine 协程如果是获取读锁会继续获得锁

可如果是获取写锁就必须等待

当一个 goroutine 协程获取写锁之后,其他的goroutine 协程无论是获取读锁还是写锁都会等待

sync包RWMutex类型
package main

import (
   "fmt"
   "sync"
   "time"
)

var (
   num    int64
   wg     sync.WaitGroup
   rwlock sync.RWMutex
)

func write() {
   rwlock.Lock()

   num = num + 1
   // 模拟真实写数据消耗的时间
   time.Sleep(10 * time.Millisecond)

   rwlock.Unlock()
   wg.Done()
}

func read() {
   rwlock.RLock()

   // 模拟真实读取数据消耗的时间
   time.Sleep(time.Millisecond)

   rwlock.RUnlock()
   wg.Done()
}

func main() {
   // 用于计算时间 消耗
   start := time.Now()

   // 开5个协程用作 写
   for i := 0; i < 5; i++ {
      wg.Add(1)
      go write()
   }

   // 开500 个协程,用作读
   for i := 0; i < 1000; i++ {
      wg.Add(1)
      go read()
   }

   // 等待子协程退出
   wg.Wait()
   end := time.Now()

   // 打印程序消耗的时间
   fmt.Println(end.Sub(start))
}
53.941061ms1.7750029s

是不是结果相差很大呢,对于不同的场景应用不同的锁,对于我们的程序性能影响也是很大,当然上述结果,若读协程,和写协程的个数差距越大,结果就会越悬殊

总结一下读写锁的特征

  • 写锁是排他性的,一个读写锁同时只能有一个写或者多个读
  • 不能同时既有读又有写
  • 如果资源未被读写锁锁定,那么写者可以即刻获得写锁。否则它必须原地自旋,直到资源被所有者释放
  • 如果资源未被写者锁定,那么读者可以立刻获得读锁,否则读者必须原地自旋,直到写者释放写锁

上面提到了自旋,我们来简单解释一下什么是自旋

自旋也叫自旋锁,是专门为了防止多处理器并发而引入的一种锁,它在内核中大量应用于中断处理等部分(对于单处理器来说,防止中断处理中的并发可简单采用关闭中断的方式,即在标志寄存器中关闭/打开中断标志位,不需要自旋锁)

简单来说,在并发过程中,若其中一个协程拿不到锁,他会不停的去尝试拿锁,而不是阻塞睡眠

自旋锁和互斥锁的区别

  • 互斥锁:当拿不到锁的时候,会阻塞等待,会睡眠,等待锁释放后被唤醒
  • 自旋锁:当拿不到锁的时候,会在原地不停的看能不能拿到锁,所以叫自旋锁,不会阻塞,不会睡眠

如何选择锁?

  • 如果写多读少,那么选择互斥锁
  • 如果读多写少,那么选择读写锁

那如果读和写都很多,并且对性能要求更极致的场景怎么办?不管是睡眠还是自旋都会有损耗,我们来了解一下原子操作

啥是原子操作


"原子操作(atomic operation)是不需要synchronized",这是多线程编程的老生常谈了。所谓原子操作是指不会被线程调度机制打断的操作

这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch (切换到另一个线程)。

原子操作的特性:

sync/atomic

我们使用第一个例子来对比一下原子操作和锁的性能

package main

import (
    "fmt"
    "sync"
    "sync/atomic"
    "time"
)

var num int64
var l sync.Mutex
var wg sync.WaitGroup

// 普通版加函数
func add() {
    num = num + 1
    wg.Done()
}

// 互斥锁版加函数
func mutexAdd() {
    l.Lock()
    num = num + 1
    l.Unlock()
    wg.Done()
}

// 原子操作版加函数
func atomicAdd() {
    atomic.AddInt64(&num, 1)
    wg.Done()
}

func main() {
    // 目的是 记录程序消耗时间
    start := time.Now()
    for i := 0; i < 2000000; i++ {

        wg.Add(1)

        // go add()       // 无锁的  add函数 不是并发安全的
        // go mutexAdd()  // 互斥锁的 add函数 是并发安全的,因为拿不到互斥锁会阻塞,所以加锁性能开销大

        go atomicAdd()    // 原子操作的 add函数 是并发安全,性能优于加锁的
    }

    // 等待子协程 退出
    wg.Wait()

    end := time.Now()
    fmt.Println(num)
    // 打印程序消耗时间
    fmt.Println(end.Sub(start))
}
745.292115ms1999848
846.407091ms
806.684619ms

原子操作


加或减

AddAddInt32AddInt64

原子操作函数的第一个参数都是指针,是因为原子操作需要知道该变量的内存地址,然后以特殊的CPU指令操作,对于不能取得内存地址的变量是无法进行原子操作的。

原子操作的第二个参数类型会自动转换为与第一个参数相同的类型,原子操作会自动将的操作后的值赋值给变量,无需我们自己手动赋值。

package main

import (
    "fmt"
    "sync/atomic"
)

func main() {
    var num int64 = 99
    atomic.AddInt64(&num, 1)
    fmt.Println(num)
    
    atomic.AddInt64(&num, -1)
    fmt.Println(num)
}

执行上面代码得到的结果是

100
99
AddUint32AddUint64
package main

import (
    "fmt"
    "sync/atomic"
)

func main() {
    var num uint32 = 99
    n := 9
    atomic.AddUint32(&num, -uint32(n))
    fmt.Println(num)
    
    atomic.AddUint32(&num, ^uint32(n-1))
    fmt.Println(num)
}

执行上面的代码输出结果为

90
81

比较并交换(Compare And Swap)

sync/atomicCompareAndSwapfunc CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)func CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool)

第一个参数的值是这个变量的指针,第二个参数是这个变量的旧值,第三个参数指的是这个变量的新值。

运行过程:调用CompareAndSwapInt32 后,会先判断这个指针上的值是否跟旧值相等,若相等,就用新值覆盖掉这个值,若相等,那么后面的操作就会被忽略掉。返回一个 swapped 布尔值,表示是否已经进行了值替换操作。

与锁有不同之处:锁总是假设会有并发操作修改被操作的值,而CAS总是假设值没有被修改,因此CAS比起锁要更低的性能损耗,锁被称为悲观锁,而CAS被称为乐观锁。

CAS的使用示例

package main

import (
    "fmt"
    "sync/atomic"
)

var value int32
func AddValue(delta int32)  {
    // 类似于自旋行为,在确保交换成功后再结束循环,否则不停尝试
    for ;!atomic.CompareAndSwapInt32(&value, value, (value+delta)); {}
}

func main() {
    AddValue(10)
    fmt.Println(value)
    
    AddValue(10)
    fmt.Println(value)
}

----输出
10
20

载入与存储

sync/atomicLoadStore
package main

import (
    "fmt"
    "sync/atomic"
)

var value int32
func AddValue(delta int32)  {
    // 类似于自旋行为,在确保交换成功后再结束循环,否则不停尝试
    for ;!atomic.CompareAndSwapInt32(&value, atomic.LoadInt32(&value), (atomic.LoadInt32(&value)+delta)); {}
}

func main() {
    AddValue(10)
    fmt.Println(value)
    
    AddValue(10)
    fmt.Println(value)
}
func StoreInt32(addr *int32, val int32)

交换

func SwapInt32(addr *int32, new int32) (old int32)

原子值

sync/atomicValue
type Value struct {
   v interface{}
}
v interface{}Value

使用方式如下:

var Atomicvalue  atomic.Value

该类型有两个公开的指针方法

//原子的读取原子值实例中存储的值,返回一个 interface{} 类型的值,且不接受任何参数。
//若未曾通过store方法存储值之前,会返回nil
func (v *Value) Load() (x interface{})

//原子的在原子实例中存储一个值,接收一个 interface{} 类型(不能为nil)的参数,且不会返回任何值
func (v *Value) Store(x interface{})

一旦原子值实例存储了某个类型的值,那么之后Store存储的值就必须是与该类型一致,否则就会引发panic。

atomic.Value
atomic.Value

看下面这两种情况对比的示例

package main

import (
    "fmt"
    "sync/atomic"
)

func main() {
    
    var Atomicvalue  atomic.Value
    Atomicvalue.Store([]int{1,2,3,4,5})
    fmt.Println("main before testA: ",Atomicvalue)
    testA(Atomicvalue)
    fmt.Println("main after testA: ",Atomicvalue)
    
    // 复位
    Atomicvalue.Store([]int{1,2,3,4,5})
    fmt.Println("\n")
    
    fmt.Println("main before testB: ",Atomicvalue)
    testB(&Atomicvalue)
    fmt.Println("main after testB: ",Atomicvalue)
}

func testA(Atomicvalue atomic.Value) {
    Atomicvalue.Store([]int{6,7,8,9,10})
    fmt.Println("testA: ",Atomicvalue)
}

func testB(Atomicvalue *atomic.Value) {
    Atomicvalue.Store([]int{6,7,8,9,10})
    fmt.Println("testB: ",Atomicvalue)
}

执行后输出结果:

main before testA:  {[1 2 3 4 5]}
testA:  {[6 7 8 9 10]}
main after testA:  {[1 2 3 4 5]}


main before testB:  {[1 2 3 4 5]}
testB:  &{[6 7 8 9 10]}
main after testB:  {[6 7 8 9 10]}

TODO 锁的实现