锁是一种常见的并发控制技术,我们一般会将锁分成乐观锁和悲观锁,即乐观并发控制和悲观并发控制。
悲观锁
悲观锁就是我们常用的锁机制,不管它会不会发生,只要存在并发安全问题,就在操作这个资源的时候给他先加上锁。常见的锁有互斥锁,读写锁等。
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,则该进程继续执行;否则释放队列中第一个等待信号量的进程。------释放资源