Skip to content

乐观锁和悲观锁

乐观锁

顾名思义,乐观锁是一种处于乐观思想的锁,它假设数据都不会发生冲突,所以不会显式的给数据上锁. 取而代之的是在每次提交更新前去检查别人是否有更新这个数据,如果有就会重试更新

乐观锁的实现方式

版本号机制

给数据添加一个版本号的字段,在提交更新前检查版本号是否改变:

  • 若没有改变则更新版本号,提交更新
  • 若发生改变则说明数据在此之前已被更新,则需要重试更新
go语言的简单实现
  • 数据定义
go
// GetVal 获取值
func (d *Data) GetVal() int {
	return d.Val
}

// GetVersion 获取版本号
func (d *Data) GetVersion() int {
	return d.Version
}
// WriteVal 写入值,如果写入失败则返回false
func (d *Data) WriteVal(val, version int) bool {
	// 判断版本号是否改变
	if d.Version == version {
		d.Val = val
		d.Version++
		return true
	}
	return false
}
  • 读写操作
go
// Reader 读取值
func Reader(data *Data, id int, msg chan string) {
	time.Sleep(time.Second)
	msg <- fmt.Sprintf("Reader %d, val: %d, ver: %d", id, data.GetVal(), data.GetVersion())
}

// Writer 写入值
func Writer(data *Data, id int, msg chan string) {
	attempts := 0
	// 最多尝试5次
	for attempts < 5 {
		ver := data.GetVersion()
		v := data.GetVal()
		// 随机休眠
		time.Sleep(time.Duration(rand.Intn(10) * 100))
		if data.WriteVal(v+1, ver) {
			msg <- fmt.Sprintf("Writer %d update success; val: %d, ver: %d", id, data.GetVal(), data.GetVersion())
			break
		} else {
			attempts++
			msg <- fmt.Sprintf("Writer %d update No%d failed; val: %d, ver: %d", id, attempts, data.GetVal(), data.GetVersion())
		}
	}
}

Reader 函数模拟读取操作,读取数据并输出信息。读取操作前会休眠一秒钟。 Writer 函数模拟写入操作,尝试更新数据值。每次尝试前会随机休眠一段时间,最多尝试5次。如果更新成功,则输出成功信息,否则输出失败信息。

  • 主函数
go
func main() {
	data := &Data{Val: 0, Version: 0}
	msg := make(chan string)
	defer close(msg)
	for i := 0; i < 10; i++ {
		go Reader(data, i, msg)
		go Writer(data, i, msg)
	}
	for {
		select {
		case m := <-msg:
			fmt.Println(m)
		default:
		}
	}
}
  • 输出
text
Writer 3 update No1 failed; val: 1, ver: 1
Writer 6 update No1 failed; val: 1, ver: 1
Writer 1 update No1 failed; val: 1, ver: 1
Writer 7 update No1 failed; val: 1, ver: 1
Writer 5 update No1 failed; val: 1, ver: 1
Writer 4 update success; val: 1, ver: 1
Writer 0 update No1 failed; val: 1, ver: 1
Writer 2 update No1 failed; val: 1, ver: 1
Writer 9 update No1 failed; val: 1, ver: 1
Writer 8 update No1 failed; val: 1, ver: 1
Writer 7 update success; val: 2, ver: 2
Writer 3 update No2 failed; val: 2, ver: 2
Writer 5 update No2 failed; val: 2, ver: 2
Writer 1 update No2 failed; val: 2, ver: 2
Writer 9 update success; val: 3, ver: 3
Writer 6 update No2 failed; val: 3, ver: 3
Writer 0 update No2 failed; val: 3, ver: 3
Writer 8 update No2 failed; val: 3, ver: 3
Writer 2 update No2 failed; val: 3, ver: 3
Writer 8 update success; val: 4, ver: 4
Writer 0 update No3 failed; val: 4, ver: 4
Writer 2 update success; val: 5, ver: 5
Writer 1 update No3 failed; val: 5, ver: 5
Writer 6 update No3 failed; val: 5, ver: 5
Writer 3 update No3 failed; val: 5, ver: 5
Writer 5 update No3 failed; val: 5, ver: 5
Writer 1 update success; val: 6, ver: 6
Writer 0 update No4 failed; val: 6, ver: 6
Writer 6 update success; val: 7, ver: 7
Writer 3 update No4 failed; val: 7, ver: 7
Writer 5 update No4 failed; val: 7, ver: 7
Writer 5 update success; val: 8, ver: 8
Writer 0 update No5 failed; val: 8, ver: 8
Writer 3 update No5 failed; val: 8, ver: 8
Reader 1, val: 8, ver: 8
Reader 0, val: 8, ver: 8
Reader 9, val: 8, ver: 8
Reader 6, val: 8, ver: 8
Reader 7, val: 8, ver: 8
Reader 3, val: 8, ver: 8
Reader 5, val: 8, ver: 8
Reader 2, val: 8, ver: 8
Reader 8, val: 8, ver: 8
Reader 4, val: 8, ver: 8

可以发现当有writer写入成功后,在这之前尝试更新的writer都更新失败了 但这也让我们发现乐观锁的缺点:在写入比较频繁的数据中容易造成大量的更新失败和重试,从而浪费资源

CAS算法

算法CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。 CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。 CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。原子操作 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。 CAS 涉及到三个操作数:

  • V:要更新的变量值(Var)
  • E:预期值(Expected)
  • N:拟写入的新值(New) 当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。 举一个简单的例子:线程 A 要修改变量 i 的值为 6,i 原值为 1(V = 1,E=1,N=6,假设不存在 ABA 问题)。 i 与 1 进行比较,如果相等, 则说明没被其他线程修改,可以被设置为 6 。 i 与 1 进行比较,如果不相等,则说明被其他线程修改,当前线程放弃更新,CAS 操作失败。 当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。

悲观锁

顾名思义,悲观锁是一种处于悲观思想的锁,它假设数据在更新时总会发生冲突,所以在每次获取资源时都会给资源上锁,其他线程想要访问该资源必须等待解锁,从而实现资源独享.

互斥锁

共享资源的使用是互斥的,即一个线程获得资源的使用权后就会将该资源加锁,使用完后会将其解锁, 如果在使用过程中有其他线程想要获取该资源的锁,那么它就会被阻塞陷入睡眠状态-空等待(sleep-waiting),直到该资源被解锁才会被唤醒, 如果被阻塞的资源不止一个,那么它们都会被唤醒,但是获得资源使用权的是第一个被唤醒的线程,其它线程又陷入沉睡.

go语言的简单实现

  • 数据定义
go
// Data 带互斥锁的数据
type Data struct {
	Val int
	sync.Mutex
}
  • 读写操作
go
// Reader 读取数据
func Reader(data *Data, id int, msg chan string) {
	// 加锁
	data.Mutex.Lock()
	msg <- fmt.Sprintf("Reader %d locked", id)
	// 读取完成后解锁
	defer func() {
		data.Mutex.Unlock()
		msg <- fmt.Sprintf("Reader %d unlocked", id)

	}()
	msg <- fmt.Sprintf("Reader %d read %d", id, data.Val)

}

// Writer 写入数据
func Writer(data *Data, id int, msg chan string) {
	// 加锁
	data.Mutex.Lock()
	msg <- fmt.Sprintf("Writer %d locked", id)
	// 写入完成后解锁
	defer func() {
		data.Mutex.Unlock()
		msg <- fmt.Sprintf("Writer %d unlocked", id)

	}()
	data.Val++
	msg <- fmt.Sprintf("Writer %d wrote %d", id, data.Val)
}
  • 主函数
go
func main() {
	var wg sync.WaitGroup
	data := &Data{Val: 0}
	msg := make(chan string)
	defer close(msg)
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			Reader(data, id, msg)
		}(i)

		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			Writer(data, id, msg)
		}(i)
	}

	for i := 0; i < 60; i++ {
		fmt.Println(<-msg)
	}
}
  • 输出
text
Reader 0 locked
Reader 0 read 0
Reader 0 unlocked
Writer 1 locked
Writer 1 wrote 1
Writer 1 unlocked
Reader 3 locked
Reader 3 read 1
Reader 1 locked
Reader 1 read 1
Reader 3 unlocked
Reader 1 unlocked
Writer 5 locked
Writer 5 wrote 2
Writer 5 unlocked
Writer 3 locked
Writer 3 wrote 3
Writer 3 unlocked
Reader 4 locked
Reader 4 read 3
Reader 4 unlocked
Writer 4 locked
Writer 4 wrote 4
Writer 4 unlocked
Reader 5 locked
Reader 5 read 4
Reader 5 unlocked
Writer 6 locked
Writer 6 wrote 5
Writer 6 unlocked
Reader 6 locked
Reader 6 read 5
Reader 6 unlocked
Reader 7 locked
Reader 7 read 5
Reader 7 unlocked
Reader 2 locked
Reader 2 read 5
Reader 2 unlocked
Writer 2 locked
Writer 2 wrote 6
Writer 2 unlocked
Writer 7 locked
Writer 7 wrote 7
Writer 7 unlocked
Reader 8 locked
Reader 8 read 7
Reader 8 unlocked
Writer 8 locked
Writer 8 wrote 8
Writer 8 unlocked
Reader 9 locked
Reader 9 read 8
Reader 9 unlocked
Writer 9 locked
Writer 9 wrote 9
Writer 9 unlocked
Writer 0 locked
Writer 0 wrote 10
Writer 0 unlocked

可以发现每一次读写操作都是原子性的既每次读写完成前都不会发生另外的读写操作

读写锁

Mutex在大量并发的情况下,会造成锁等待,对性能的影响比较大。 如果某个读操作的协程加了锁,其他的协程没必要处于等待状态,可以并发地访问共享变量,这样能让读操作并行,提高读性能。 RWLock就是用来干这个的,这种锁在某一时刻能由多个reader持有,或者被一个writer持有

主要遵循以下规则 :

  • 读写锁的读锁可以重入,在已经有读锁的情况下,可以任意加读锁。
  • 在读锁没有全部解锁的情况下,写操作会阻塞直到所有读锁解锁。
  • 写锁定的情况下,其他协程的读写都会被阻塞,直到写锁解锁。

go语言的简单实现

  • 数据定义
go
// Data 带读写锁的数据
type Data struct {
	Val int
	mu  sync.RWMutex
}
  • 读写操作
go
// Reader 读取数据
func Reader(data *Data, id int, msg chan string) {
	// 加读锁
	data.mu.RLock()
	msg <- fmt.Sprintf("Reader %d locked", id)

	// 读取完成后解锁
	defer func() {
		data.mu.RUnlock()
		msg <- fmt.Sprintf("Reader %d unlocked", id)

	}()
	msg <- fmt.Sprintf("Reader %d read %d", id, data.Val)

}

// Writer 写入数据
func Writer(data *Data, id int, msg chan string) {
	// 加写锁
	data.mu.Lock()
	msg <- fmt.Sprintf("Writer %d locked", id)
	// 写入完成后解锁
	defer func() {
		data.mu.Unlock()
		msg <- fmt.Sprintf("Writer %d unlocked", id)

	}()
	data.Val++
	time.Sleep(time.Second * 2)
	msg <- fmt.Sprintf("Writer %d wrote %d", id, data.Val)
}
  • 主函数
go
func main() {
	var wg sync.WaitGroup
	data := &Data{Val: 0}
	msg := make(chan string)
	defer close(msg)
	for i := 0; i < 5; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			Reader(data, id, msg)
		}(i)

		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			Writer(data, id, msg)
		}(i)
	}

	for i := 0; i < 30; i++ {
		fmt.Println(<-msg)
	}
}
  • 输出
text
Reader 0 locked
Reader 0 read 0
Reader 0 unlocked
Writer 0 locked
Writer 0 wrote 1
Writer 0 unlocked
Reader 1 locked
Reader 3 locked
Reader 4 locked
Reader 2 locked
Reader 2 read 1
Reader 2 unlocked
Reader 4 read 1
Reader 4 unlocked
Reader 1 read 1
Reader 1 unlocked
Reader 3 read 1
Reader 3 unlocked
Writer 2 locked
Writer 2 wrote 2
Writer 2 unlocked
Writer 3 locked
Writer 3 wrote 3
Writer 3 unlocked
Writer 4 locked
Writer 4 wrote 4
Writer 4 unlocked
Writer 1 locked
Writer 1 wrote 5
Writer 1 unlocked

可以发现数据可以由多个Reader读取,但是同时只能由1个Writer写入

乐观锁和悲观锁的应用场景

悲观锁的特点:

  • 1.悲观锁适用于并发写操作较多的场景,因为写操作涉及到数据的修改,需要保证数据的一致性。
  • 2.悲观锁在加锁期间,其他线程无法访问被锁定的资源,从而保证了数据的完整性。
  • 3.悲观锁需要频繁地进行加锁和解锁操作,开销较大。

悲观锁的应用场景:

  • 1.银行账户转账:在进行转账操作时,需要保证同时只有一个线程能够修改账户余额,避免出现数据不一致的情况。
  • 2.数据库行锁:在数据库中,使用悲观锁可以在读取数据之前对数据进行加锁,避免其他事务对数据的并发修改。

乐观锁的特点:

  • 1.乐观锁适用于并发读操作较多的场景,因为读操作不涉及到数据的修改,不需要加锁。
  • 2.乐观锁在更新数据时,只有在提交更新操作时才对数据进行版本检查,减少了加锁和解锁的开销。
  • 3.乐观锁可能需要进行重试,以处理并发修改引起的冲突。

乐观锁的应用场景:

  • 1.数据库乐观锁:在数据库中,可以使用版本号或时间戳来实现乐观锁,用于避免并发修改引起的数据冲突。
  • 2.缓存更新:在缓存中,可以使用版本号或时间戳来实现乐观锁,用于保证缓存数据的一致性。

悲观锁适用于并发写操作较多的场景,需要频繁地进行加锁和解锁操作,保证数据的一致性; 而乐观锁适用于并发读操作较多的场景,通过版本检查来处理并发修改引起的冲突,减少了加锁和解锁的开销。