锁是一种常见的并发控制技术,我们一般会将锁分成乐观锁和悲观锁,即乐观并发控制和悲观并发控制。

悲观锁

悲观锁就是我们常用的锁机制,不管它会不会发生,只要存在并发安全问题,就在操作这个资源的时候给他先加上锁。常见的锁有互斥锁,读写锁等。

golang中除了atomic其他都是悲观锁。

互斥锁Mutex

实例

var l sync.Mutex
func foo() {
     l.Lock()
     defer l.Unlock()
     //...
}

其中Mutex为互斥锁,Lock()加锁,Unlock()解锁,如果在使用Unlock()前未加锁,就会引起一个运行错误,使用Lock()加锁后,便不能再次对其进行加锁,直到利用Unlock()解锁对其解锁后,才能再次加锁.适用于读写不确定场景,即读写次数没有明显的区别,并且只允许只有一个读或者写的场景,所以该锁也叫做全局锁。

读写锁RWMutex

func (rw *RWMutex) Lock()  

写锁,如果在添加写锁之前已经有其他的读锁和写锁,则lock就会阻塞直到该锁可用,为确保该锁最终可用,已阻塞的 Lock 调用会从获得的锁中排除新的读取器,即写锁权限高于读锁,有写锁时优先进行写锁定

func (rw *RWMutex) Unlock() 

写锁解锁,如果没有进行写锁定,则就会引起一个运行时错误

func (rw *RWMutex) RLock()

读锁,当有写锁时,无法加载读锁,当只有读锁或者没有锁时,可以加载读锁,读锁可以加载多个,所以适用于"读多写少"的场景

func (rw *RWMutex)RUnlock() 

读锁解锁,RUnlock 撤销单次RLock 调用,它对于其它同时存在的读取器则没有效果。若 rw 并没有为读取而锁定,调用 RUnlock 就会引发一个运行时错误(注:这种说法在go1.3版本中是不对的,例如下面这个例子)。

读写锁是针对于读写操作的互斥锁。

基本遵循两大原则:

  • 加读锁后就不能写,允许存在多个读锁,但只能有一把写锁;读锁的时候不能写,可以随便读。多个goroutin同时读。
  • 加写锁的时候,当写锁未被释放时或此时有正被等待的写锁(只有当全部读锁结束,写锁才可用),读锁不可用;

下面再用一个实例来简单介绍一下 RWMutex 的几条规则:

var rw sync.RWMutex

func reader(readerID int) {
    fmt.Printf("[reader-%d] try to get read lock\n", readerID)
    rw.RLock()
    fmt.Printf("[reader-%d] get read lock and sleep\n", readerID)
    time.Sleep(1 * time.Second)
    fmt.Printf("[reader-%d] release read lock\n", readerID)
    rw.RUnlock()
}

func writer(writerID int) {
    fmt.Printf("[writer-%d] try to get write lock\n", writerID)
    rw.Lock()
    fmt.Printf("[writer-%d] get write lock and sleep\n", writerID)
    time.Sleep(3 * time.Second)
    fmt.Printf("[writer-%d] release write lock\n", writerID)
    rw.Unlock()
}

func main() {
    // 启动多个 goroutine 获取 read lock 后 sleep 一段时间
    // 由于此时没有写者,所以两个 reader 都可以同时获取到读锁
    go reader(1)
    go reader(2)

    time.Sleep(500 * time.Millisecond)

    // 写者获取写锁,由于读锁未被释放,所以一开始写者获取不到写锁
    go writer(1)

    time.Sleep(1 * time.Second)

    // 由于写锁还未释放,新的读者获取不到读锁
    go reader(3)
    go reader(4)

    // 主 goroutine 等待足够长时间让所有 goroutine 执行完
    time.Sleep(10 * time.Second)
}

执行后输出为:

[reader-2] try to get read lock
[reader-1] try to get read lock
[reader-2] get read lock and sleep
[reader-1] get read lock and sleep
[writer-1] try to get write lock      --> 尝试获取写锁失败,因为读锁未释放
[reader-2] release read lock          --> 读锁释放
[reader-1] release read lock
[writer-1] get write lock and sleep   --> 读锁释放后,获取写锁成功
[reader-4] try to get read lock       --> 获取读锁失败因为写锁未释放
[reader-3] try to get read lock
[writer-1] release write lock         --> 写锁释放
[reader-3] get read lock and sleep    --> 写锁释放后,获取读锁成功
[reader-4] get read lock and sleep
[reader-4] release read lock
[reader-3] release read lock

乐观锁

乐观锁并不是一把真正的锁,不像上面的锁一样有api,而是一种并发控制的思想:总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。

版本号机制

一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。

CAS算法

即compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数

  • 需要读写的内存值 V
  • 进行比较的值 A
  • 拟写入的新值 B

当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。

CAS在golang的库sync/atomic中得到了实现。atomic基本使用了乐观锁的原理,但是只是支持int32/int64/uint32/uint64/uintptr这几种数据类型的一些基础操作,操作共五种:增减, 比较并交换, 载入, 存储,交换。

一般无锁的操作都是使用乐观并发控制思想来实现的。

实现方式

1、基于数据库的version字段进行实现,在表中新增一个字段version就可以,执行sql的时候加上这个字段的比较

2、基于缓存数据库实现,比如redis的watch,和memcached的gets和cas

锁的原理

锁的实现一般会依赖于信号量,信号量则是一个非负的整数计数器。

信号量:多线程同步使用的;一个线程完成某个动作后通过信号告诉别的线程,别的线程才可以执行某些动作;信号量可以是多值的,当信号量在0和1之间操作时候就是互斥量

互斥量:多线程互斥使用的;一个线程占用某个资源,那么别的线程就无法访问,直到该线程离开,其他线程才可以访问该资源;0或1

具体互斥锁的实现原理可以参考这篇文章:https://www.cnblogs.com/sylz/p/6030201.html

简单来说,就是加锁时,就把信号量减一,如果是零说明加锁成功。释放锁时把信号量加一,如果是一说明释放成功。

但是在实际应用中大家都使用信号量,因为信号量是多值得,可以通过信号量加等待队列,减少唤醒的次数。

pv原语

P(S):将信号量S的值减1,即S=S-1;
        如果S>=0,则该进程继续执行;否则该进程置为等待状态,排入等待队列。-----申请资源
V(S):将信号量S的值加1,即S=S+1;
        如果S>0,则该进程继续执行;否则释放队列中第一个等待信号量的进程。------释放资源