前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >深入理解Golang 读写锁(RWMutex)执行机制

深入理解Golang 读写锁(RWMutex)执行机制

原创
作者头像
路之遥
发布2023-09-14 15:31:38
4830
发布2023-09-14 15:31:38
举报
文章被收录于专栏:luzhiyaoluzhiyao

在使用golang开发程序过程中,应用的内部状态常常面临读写并发访问的情况,此时需要使用同步原语对状态进行保护,但golang提供了三种同步原语 Mutex, RWMutex, sync.Map,如何选择合适的同步原语就显得至关重要,选择不当可能会造成程序性能下降,降低应用体验。本文将深入探讨Golang RWMutex的执行机制,为选择提供理论依据。

一、 构造读写场景

首先,我们将构造一个读写并发的场景,推测某一时刻读写任务的执行顺序。

场景:

  1. 初始5个读锁,在t1->t2阶段执行
  2. 在t1->t2读锁执行期间,收到一个写锁调用【W1】和3个读锁调用【R6、R7、R8】
  3. 在t2时间段后,交互收到2个写调用【W2、W3】、2个读调用【R9、R10】

猜测:

  1. t1->t2阶段,收到的R6~R8读调用是否与R1~R5读调用一起执行?W1写调用执行发生在R6~R8之前还是之后?
  2. t2->t3阶段,是读任务在执行、还是写任务在执行?
  3. t3时间执行结束后,剩余任务是如何执行?

读写锁包含了两种锁:读锁、写锁,因此设计中两种锁的权重可能有下列三种场景:

* 读优先:读任务占有锁时,后续的读任务可以立即获得锁;这种设计可以提高并发性能(后来的读任务不需要等待),但如果读任务太多,会造成写任务一直处于等待中,造成写饥饿现象

* 写优先:指如果有写任务在等待锁,会阻塞后来的读任务获取锁。保证了写任务不会被持续的读进程阻塞,但如果写任务过多,又会导致读任务一直被阻塞,造成读任务饿死。

* 读写权重一致:读写锁的优先级一样,即普通的Mutex.

但Golang的读写实现中,采用了读优先、写优先交替策略。即:在读任务执行过程中,对于接收到的写任务、读任务,采取写优先策略,阻塞接收到的读任务,让写任务在读过程结束后优先执行;在写任务执行过程中,对于接收到的写任务、读任务,采取读优先策略,阻塞接收到的写任务,让读任务在写过程结束后优先执行。使用交替机制,确保不会因为读写任何一方任务过多,造成另一方的任务无法执行。

上述构造的场景任务执行顺序为:

接下来通过分析代码,从实现层面来加深读写锁的实现机制,并为自己的开发提供借鉴。

二、 代码实现解析

Golang的读写锁代码实现相当精炼、100行左右的代码,知识点相当密集。从代码结构上,它采用的是装饰器,在普通Mutex结构上增加了一层处理读写并发业务的逻辑;当出现读写并发情况时,由装饰器层进行处理,调度对应的读任务或写任务;当出现写并发时,有普通的Mutex负责,使同一时间仅处理一个写任务。

类结构解析

下面代码先上结构,解释各个字段的含义:

代码语言:javascript
复制
type RWMutex struct {
    w Mutex // held if there are pending writers

    writerSem uint32 // semaphore for writers to wait for completing readers
    readerSem uint32 // semaphore for readers to wait for completing writers
    readerCount int32 // number of pending readers
    readerWait int32 // number of departing readers
}

w: 对写任务进行并发控制,获取写锁时首先需要获取该锁,如果它已被某个写任务占据,则后来获取的写任务会阻塞在该锁上.

writerSem:写操作等待的信号量,当写任务可以执行时释放该信号量.

readerSem:读操作等待的信号量,当读任务可以执行时释放该信号量.

readerCount:1. 记录读任务的数量 2. 当其值为负时,表示有写任务在等待或正在处理

readerWait:用于实现**写优先的关键逻辑**,写任务需要等待多少个读任务结束,才可以执行;

重要的常量:

* const rwmutexMaxReaders = 1 << 30,设定读锁的最大数量,同时用于反转readerCount值为负,标识存在写任务

获取读锁

  1. 获取读锁时,先将读计数器 readerCount增1,表示增加一个读任务
  2. 当readerCount值为负时,表示前面存在等待处理写任务或有写任务正在处理,此时阻塞新接收到的读任务,等待信号量通知
代码语言:javascript
复制
func (rw *RWMutex) RLock() {
    ....
    // readerCount计数器++
    // 小于0,表示存在等待处理/正在处理的写操作
    // 新加入的读操作阻塞,等待readerSem信号量通知
    // > 0: 表示只有读锁,读读不冲突,上锁成功,函数退出    
    //  ======== 此处就是写优先的逻辑逻辑===========
	if atomic.AddInt32(&rw.readerCount, 1) < 0 {
		// A writer is pending, wait for it.
		runtime_SemacquireMutex(&rw.readerSem, false, 0)
	}
    .....
}

释放读锁

  1. 释放读锁时,先将读计数器减一,表示完成一个读任务
  2. 如果readerCount为负,则存在需要优先处理的写任务,进入慢路径
  3. 首先检测读计数器的临界区,防止RUnlock调用出错(上锁一次、解锁多次)
  4. 因为此时存在写任务,readerWait已被写任务赋值,将该值减一,表示写任务执行前要处理的读任务完成一个
  5. 如果readerWait为0,则表示写任务执行之前的所有读任务都已完成,释放写信号量,执行等待处理的写任务.
代码语言:javascript
复制
func (rw *RWMutex) RUnlock() {
    ....

    // 读计数器--
    // < 0,表示存在等待处理的写操作
    // > 0, 表示还有剩余的读锁,读读之间不需要阻塞
    // ==0,表示没有任何锁
	if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 {
		// Outlined slow-path to allow the fast-path to be inlined
		rw.rUnlockSlow(r)
	}
	....
}

func (rw *RWMutex) rUnlockSlow(r int32) {
    // 临界区检测 readerCount  [-rwmutexMaxReaders, -1] && (0, rwmutexMaxReaders)
	if r+1 == 0 || r+1 == -rwmutexMaxReaders {
		race.Enable()
		throw("sync: RUnlock of unlocked RWMutex")
	}

	// 获取写锁之前,需要处理的读锁数量
    // readerWait ==0:表示写锁之前的读锁都处理完毕,后续将锁分配给写事务,
    // 释放信号量,通知等待的写锁
    // readerWait > 0: 表示还有读锁在处理,退出调用.
	if atomic.AddInt32(&rw.readerWait, -1) == 0 {
		// The last reader unblocks the writer.
		runtime_Semrelease(&rw.writerSem, false, 1)
	}
}

获取写锁

  1. 获取写锁时,先抢占互斥锁;因为当存在多个写任务时,同一时间仅会处理一个。
  2. 反转readerCount的值为负,同时计算收到写任务时的读任务数量
  3. 当读任务数量>0时,表示存在正在处理的读任务,将该值累加给readerWait,表示执行接收到的写任务时需要执行多少任务
  4. 当readWait > 0,表示有任务要执行,因为通过信号量将写任务阻塞
代码语言:javascript
复制
func (rw *RWMutex) Lock() {
    ....
    先上锁,排除其它写锁竞争
	rw.w.Lock()
	// Announce to readers there is a pending writer.
	r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders
	// Wait for active readers.
	if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {
		runtime_SemacquireMutex(&rw.writerSem, false, 0)
	}
    ....
}

释放写锁

  1. 释放写锁时,先将readerCount反转为正值表示写任务执行完成,并计算读任务的数量;在释放写锁期间如果有新到的并发读任务,因为readerCount>=0,可以立即获取读锁执行
  2. 释放r次读信号量,将在写任务期间被阻塞的读任务唤醒执行
  3. 释放Mutex互斥锁
代码语言:javascript
复制
func (rw *RWMutex) Unlock() {
    ....
	// Announce to readers there is no active writer.
	r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders)
	if r >= rwmutexMaxReaders {
		race.Enable()
		throw("sync: Unlock of unlocked RWMutex")
	}
	// Unblock blocked readers, if any.
	for i := 0; i < int(r); i++ {
		runtime_Semrelease(&rw.readerSem, false, 0)
	}
	// Allow other writers to proceed.
	rw.w.Unlock()
	....
}

状态数值变化

section1构造场景下读写锁内部的状态数值变化,加深代码理解.

三、总结

重要的事情再说一遍,Golang的读写锁采用交替执行策略,即在读任务执行过程中,收到写任务和读任务时,采用写优先策略,后续先执行写任务;在写任务执行过程收到读任务和写任务时,采用读优先策略,后续先执行读任务。交替策略保证读写任何一种任务不会因为对方任务数量过多,而无法获得执行的机会。但当读数量过多时,在一个时间段内(T)会大幅压缩写任务执行时间,导致写任务无法获得足够的执行机会,从而造成部分写饥饿。

因此Golang的读写锁适用于写多读少、或读写相当的场景。对于读多写少的场景,更适合使用sync.Map。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、 构造读写场景
  • 二、 代码实现解析
    • 类结构解析
      • 获取读锁
        • 释放读锁
          • 状态数值变化
          • 三、总结
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档