问小白 wenxiaobai
资讯
历史
科技
环境与自然
成长
游戏
财经
文学与艺术
美食
健康
家居
文化
情感
汽车
三农
军事
旅行
运动
教育
生活
星座命理

锁(Lock):不同锁类型分析

创作时间:
作者:
@小白创作中心

锁(Lock):不同锁类型分析

引用
CSDN
1.
https://blog.csdn.net/qq_40653180/article/details/146030543

在并发编程中,锁机制是确保数据一致性和线程安全的重要工具。本文将深入探讨不同类型的锁,包括乐观锁、自旋锁和悲观锁,分析它们的工作原理和应用场景,帮助读者更好地理解并发编程中的锁机制。

为什么要使用锁?

在并发编程中,操作之间的happen-before关系是否确定是一个关键问题。假设有如下操作:

两个操作都要修改同一块内存区域的内容。从常规逻辑来看,这些操作必然存在先后顺序,无论哪个先执行,最终应该得到一个从 0 到 2 的确定值,而不是一个不确定的值(类似薛定谔的状态)。

然而,在并发场景下情况变得复杂。i++操作并非原子操作,在单线程环境中,这不会产生问题。但当出现并发情况时(例如,两个线程分别运行在两个物理核心上,或者两个线程运行在同一物理核心但运行过程中发生了上下文切换),就会导致操作顺序不一致,进而产生不一致的结果。

所以,我们需要借助锁的机制来维护操作之间的happen-before关系,以此确保数据的正确性(一致性)。

锁的类型

根据锁的实现方式和效果,大致可以分为两类:乐观锁和悲观锁等。

乐观锁

乐观锁基于一种乐观的假设,即认为在大多数情况下不会出现冲突。当冲突确实发生时,它确保临界资源只会有一次成功的操作,其余操作都会失败。乐观锁提供了一种低成本的并发安全方案,其本质是通过CAS(compare and swap,比较并交换)原子操作,来保证数据修改的原子性和顺序性。

以我们常用的version锁为例,它的使用流程如下:

  1. 从数据库中取出记录时,同时获取当前记录的version号。
  2. 在执行更新操作时,使用update ... set version = new_version where version = old_version语句。
  3. 如果在步骤 1 到步骤 2 之间,数据已经被其他操作更新过,那么where条件中的version值将无法匹配,数据也就不会被再次修改。

version锁之所以能够实现并发安全,是因为这里的update操作本质上是一次CAS操作。在update语句中,where条件相当于compare操作,而set version=version+1则相当于swap操作。因此,version锁中的version字段,也可以替换为其他符合CAS操作条件的字段,同样能达到乐观锁的效果。

在Go语言中,atomic包包含了基本的原子操作,文档地址。

自旋锁

当线程无法获取锁时,自旋锁会让线程在临界区外不断循环尝试获取临界资源。在持有锁资源期间,如果发生了中断,其他线程能够从内存中读取到最新的值,从而继续推进任务。可以简单地理解为,自旋锁是通过乐观锁的方式来达到悲观锁的效果。

以下是一个简单的自旋锁代码示例:

// 假设这是自旋锁的简单实现
type SpinLock struct {
    locked bool
}
func (sl *SpinLock) Lock() {
    for {
        if !sl.locked {
            sl.locked = true
            return
        }
    }
}
func (sl *SpinLock) Unlock() {
    sl.locked = false
}

获取不到临界资源的线程会在Lock()函数处不断循环。

自旋锁的优点在于不会阻塞线程,线程只会在运行态就绪态之间切换,不会陷入内核,从而减少了上下文切换的开销。然而,这也可能导致CPU出现一些空转。因此,在并发代码执行速度越快的场景中,自旋锁的收益越高(即浪费的CPU时间越少)。在一些对性能要求极高的场景和中间件中,如nginx等会使用自旋锁。

例如,如果临界区的并发量为5,并且在一个时间片内就可以完成工作,那么使用自旋锁完成并发任务的时长为5个时间片;而如果使用悲观锁,所需时长则为5个时间片+多次上下文切换时间,显然自旋锁的效率更高。但如果代码执行时间较长,CPU空转浪费的时间就会增多,此时悲观锁可能更为合适。

自旋锁通过cas loop实现,和乐观锁一样,尽管名为“锁”,但实际上并没有真正使用锁,属于一种lock-free编程方式。

悲观锁

当线程无法获取锁时,悲观锁会使线程主动休眠,让出CPU,进入阻塞状态。在解锁时,会释放锁资源,并唤醒其他处于阻塞状态的线程。因此,如果在持有锁资源期间发生中断,其他线程仍会处于阻塞状态,任务无法继续推进。

常见的悲观锁包括互斥锁读写锁等。由于会导致线程阻塞,线程会在运行态就绪态阻塞态之间切换。

以下是Go语言早期实现的mutex互斥锁代码:

// CAS操作,当时还没有抽象出atomic包
func cas(val *int32, old, new int32) bool
func semacquire(*int32)
func semrelease(*int32)
// 互斥锁的结构,包含两个字段
type Mutex struct {
    key  int32 // 锁是否被持有的标识
    sema int32 // 信号量专用,用以阻塞/唤醒
    goroutine
}
// 保证成功在val上增加delta的值
func xadd(val *int32, delta int32) (new int32) {
    for {
        v := *val
        if cas(val, v, v+delta) {
            return v + delta
        }
    }
    panic("unreached")
}
// 请求锁
func (m *Mutex) Lock() {
    if xadd(&m.key, 1) == 1 { //标识加1,如果等于1,成功获取到锁
        return
    }
    semacquire(&m.sema) // 否则阻塞等待
}
func (m *Mutex) Unlock() {
    if xadd(&m.key, -1) == 0 { // 将标识减去1,如果等于0,则没有其它等待者
        return
    }
    semrelease(&m.sema) // 唤醒其它阻塞的goroutine
}

在这种实现中,当线程或协程无法获取临界资源时,会主动进入休眠状态。

更详细的mutext解析,点击前往查看

总结

总体而言,无论是乐观锁、悲观锁还是自旋锁,大致可以分为两类:一类会使线程阻塞(如悲观锁),另一类不会使线程阻塞(如乐观锁和自旋锁),这是它们最本质的区别。

参考文献

  1. https://time.geekbang.org/column/article/295850?utm_source=u_nav_web&utm_medium=u_nav_web&utm_term=u_nav_web
  2. https://www.cnblogs.com/kismetv/p/10787228.html
  3. https://www.bilibili.com/video/BV1ff4y1q7we/?spm_id_from=333.788.recommend_more_video.0
  4. https://www.bilibili.com/video/BV1Sp4y1h7bu
  5. https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-sync-primitives/
© 2023 北京元石科技有限公司 ◎ 京公网安备 11010802042949号