Golang中读写锁的底层实现
Golang中读写锁的底层实现
1. 读写锁的概述
1.1 读写锁的功能及作用
读写锁是一种并发控制机制,它允许多个线程同时对共享资源进行读访问,但在进行写操作时需要互斥访问,以确保数据的一致性和完整性。读写锁的主要功能是提高系统在读多写少场景下的并发处理能力,从而提升整体性能。
读写锁在实际应用中扮演着重要的角色,可以有效地降低系统的并发访问冲突,提高系统的并发处理能力,同时也能够避免写操作对读操作的阻塞,从而减少了线程的等待时间,提升了系统的整体响应速度。
1.2 读写锁的特点及应用场景
读写锁的特点主要包括以下几点:
- 读写锁允许多个线程同时对共享资源进行读访问,从而提高了系统的读并发能力,在读多写少的场景下表现出色。
- 当有写操作发生时,读写锁将阻塞其他的读操作和写操作,确保写操作的原子性和一致性。
- 读写锁适用于读多写少的场景,如缓存管理、数据库查询等,能够提高系统的并发处理能力,降低线程的竞争压力。
应用场景包括但不限于:
- 系统中数据读取操作远远多于数据更新操作的场景,例如新闻网站的文章阅读和更新操作。
- 缓存系统中的数据读取频繁,写入操作相对较少的情况。
- 数据库中的查询频繁,更新操作相对较少的情况。
这些场景下,读写锁能够有效地提高系统的并发处理能力,降低线程的竞争压力,从而提升系统的整体性能。
以上是对读写锁的概述,通过对读写锁的功能及作用、特点及应用场景的描述,我们可以深入理解读写锁在并发编程中的重要作用,以及在实际应用中的价值所在。
2. 读写锁的基本实现
在 上一章节 中,我们简单介绍了读写锁的概念和应用场景。现在,让我们深入 Golang 的内部,探索读写锁的基本实现原理,并了解其使用规范、特性和局限性。
2.1 读写锁的操作与使用方法
Golang 标准库 sync
包提供了 RWMutex
类型来实现读写锁。RWMutex
的操作可以概括为以下几种:
操作 | 描述 |
---|---|
Lock() | 获取写锁,阻塞直到获取成功 |
Unlock() | 释放写锁 |
RLock() | 获取读锁,允许多个 goroutine 同时获取 |
RUnlock() | 释放读锁 |
2.1.1 使用示例
下面是一个简单的示例,展示了如何使用 RWMutex
保护共享资源:
package main
import (
"fmt"
"sync"
"time"
)
type Counter struct {
mu sync.RWMutex
count int
}
func (c *Counter) Inc() {
c.mu.Lock() // 获取写锁
defer c.mu.Unlock() // 函数结束时释放写锁
c.count++
}
func (c *Counter) Get() int {
c.mu.RLock() // 获取读锁
defer c.mu.RUnlock() // 函数结束时释放读锁
return c.count
}
func main() {
counter := &Counter{}
// 多个 goroutine 并发读写
go func() {
for i := 0; i < 10; i++ {
counter.Inc()
time.Sleep(time.Millisecond)
}
}()
go func() {
for i := 0; i < 10; i++ {
fmt.Println("Count:", counter.Get())
time.Sleep(time.Millisecond * 2)
}
}()
time.Sleep(time.Second)
}
在上述示例中,Counter
结构体使用 RWMutex
保护 count
字段。Inc()
方法修改 count
时获取写锁,确保同一时刻只有一个 goroutine 可以修改数据。Get()
方法读取 count
时获取读锁,允许多个 goroutine 同时读取数据。
2.1.2 注意事项
- 写锁优先: 当存在读锁时,新的写锁请求会被阻塞,直到所有读锁释放。这意味着在高并发读场景下,写操作可能会被长时间阻塞。
- 死锁: 与互斥锁类似,不正确的使用方式会导致死锁。例如在持有读锁的情况下尝试获取写锁。
2.2 读写锁的特点和限制
2.2.1 特点
- 并发读取: 允许多个 goroutine 同时读取共享资源,提高读取效率。
- 写操作独占: 确保写操作的原子性和数据一致性。
- 适用于读多写少场景: 在读操作远多于写操作的场景下,能够显著提高性能。
2.2.2 限制
- 写饥饿: 当存在大量读操作时,写操作可能会被长时间阻塞,导致写饥饿问题。
- 复杂性: 相较于互斥锁,读写锁的使用更加复杂,需要开发者更加谨慎地处理锁的获取和释放。
3. Golang 中的读写锁
并发编程中经常涉及到读写操作。读操作不会影响数据的并发性,但写操作需要保证数据的一致性。为此,Go 语言提供了 RWMutex 读写锁用于保护共享资源,实现高效的并发性。
3.1 RWMutex 结构体的定义
读写锁 RWMutex 是通过嵌套 Mutex 与 Cond 结构实现的,相关定义如下:
type RWMutex struct {
w Mutex // 互斥锁
writerSem uint32 // Writer notification semaphore.
readerSem uint32 // Reader notification semaphore.
readerCount int32 // Number of readers.
readerWait int32 // Waiting readers.
}
type Mutex struct {
state int32
sema uint32
}
type Cond struct {
noCopy noCopy
L *Locker
notify notifyList
}
3.2 RWMutex 结构体包含的字段及功能
下面结合表格描述 RWMutex 结构体包含的主要字段及功能。
字段名 | 类型 | 功能 |
---|---|---|
w | Mutex | 写操作互斥锁,用于控制多个写操作互斥。 |
writerSem | uint32 | 写操作信号量,用于告诉等待写锁的 goroutine 其它的 goroutine 正在读或写该锁。writerSem 和 readerSem 都是信号量,它们的值表示有多少个 goroutine 正在一等待写锁和读锁的释放。 |
readerSem | uint32 | 读操作信号量,用于告诉等待读锁的 goroutine 其它的 goroutine 正在写该锁。 |
readerCount | int32 | 当前读锁的持有数。 |
readerWait | int32 | 等待读锁的 goroutine 数量。 |
这些字段及方法相互协作,实现了 RWMutex 的读写锁功能。
3.3 RWMutex 方法的实现
RWMutex 包含了以下几个方法,分别对应加读锁、解读锁、加写锁、解写锁以及重锁。下面将分别介绍这几个方法的实现。
3.3.1 RLoser/Locker 接口
RLoser/Locker 接口是一个 RWMutex 所有方法的接口,在部分实现 RWMutex 方法中需要访问该接口。
type RLocker interface {
RLoser
Lock()
Unlock()
}
type RLoscker interface {
RLock()
RUnlock()
}
其中 RLocker 继承了 RLoscker 接口,并添加了 Lock 和 Unlock 方法。RLocker 接口表示对象既支持读操作又支持写操作。
3.3.2 RLock() 和 RUnlock()
RLock 方法实现加读锁操作,RUnlock 方法实现解读锁操作。这两个方法的实现非常简单,具体可以看下面的代码。
// RLock 加读锁操作
func (rw *RWMutex) RLock() {
runtime_Semacquire(&rw.readerSem)
// Increment reader count.
n := atomic.AddInt32(&rw.readerCount, 1)
// if there is a writer, or a wait for a writer waiting, wait.
if rw.writerSem != 0 || n < 0 {
// Slow path: semaphore acquire with contend on w.
runtime_Semacquire(&rw.readerWait)
}
runtime_Semrelease(&rw.readerSem)
}
// RUnlock 解读锁操作
func (rw *RWMutex) RUnlock() {
n := atomic.AddInt32(&rw.readerCount, -1)
if n < 0 {
if n == -1 || atomic.LoadUint32(&rw.writerSem) == 0 {
panic("sync: RUnlock of unlocked RWMutex")
}
if atomic.AddUint32(&rw.readerWait, 1) == rw.writerSem>>1 {
// The last reader unblocks the writer.
runtime_Semrelease(&rw.writerSem)
}
}
}
读锁操作执行流程:
- 在获取读锁之前,要通过 Semaphore(rw.readerSem)和 atomic 操作保证同一时间只能有一个 goroutine 获取 rw.readerSem。
- 当 rw.writerSem 不为 0 时,说明有 goroutine 正在等待写锁,或者正在写锁,此时读锁阻塞。
- 如果 rw.writerSem 为 0 且当前读锁持有数大于等于 0 时,说明没有 goroutine 正在等待或持有写锁,读锁获取成功,返回。
- 如果读锁获取失败,需要通过 Semaphore(rw.readerWait)保证 goroutine 在等待读锁时串行结构,以避免 busy waiting。
读锁解锁操作执行流程:
- 当前读锁计数减 1,如果读锁计数为负数,则说明存在无效解锁操作,此时需要 panic 报错,否则继续执行下一步。
- 如果读锁计数减少之后仍大于等于 0,则说明还有其它 goroutine 持有读锁,不需要唤醒等待的 goroutine,解锁流程结束。
- 如果读锁计数减少之后等于 -1,则说明不存在读锁 hold,但存在写锁已经持有或等待该锁,此时需要抛出 panic 报错。
- 如果 rw.readerWait 计数值等于 (rw.writerSem >> 1),则说明最后一个等待读锁的 goroutine 解锁了读锁,此时需要唤醒等待写锁的 goroutine(if any)。
3.3.3 Lock() 和 Unlock()
Lock 方法实现加写锁操作,Unlock 方法实现释放写锁的操作。写锁的操作与读锁的操作比,稍微复杂一些。
// Lock 加写锁操作
func (rw *RWMutex) Lock() {
rw.w.Lock()
// Announce to readers there is a writer.
r := atomic.AddUint32(&rw.readerSem, rwmutexMaxReaders) - rwmutexMaxReaders
if r != 0 {
// wait for readers.
runtime_Semacquire(&rw.writerSem)
}
}
// Unlock 解写锁操作
func (rw *RWMutex) Unlock() {
if atomic.LoadUint32(&rw.readerSem) == rwmutexMaxReaders {
// The lock is not read-locked.
// We prefer signal (wake one) over broadcast (wake all) to avoid
// waking readers in the common case when there's only one.
if atomic.CompareAndSwapUint32(&rw.writerSem, 0, rwmutexMaxReaders) {
return
}
// There are waiters or a writer.
runtime_Semrelease(&rw.writerSem)
}
rw.w.Unlock()
}
写锁操作执行流程:
- 获取 rw.w 的锁,rw.w 是用于保护写锁。
- 当前有写锁或读锁时,需要通过 Semaphore(rw.readerSem)保证全部唤醒,只有当所有 goroutine 都执行后才会返回。
- 发送 Signal 信号通知所有持有读锁的 goroutine 需要解读锁。
- 等待唤醒所有持有读锁的 goroutine 解锁读锁,同时阻塞其它 goroutine 执行写操作。
写锁释放操作执行流程:
- 如果没有读锁,直接将 writerSem 赋值为 rwmutexMaxReaders,返回。
- rw.writerSem 为 0 时需要通过 Semaphore(rw.writerSem)保证同一时刻只能有一个 goroutine 获取 rw.writerSem。
- 拿到 rw.writerSem 锁之后,检查读锁是否全部解锁,如果全部解锁,将 rw.writerSem 设置为 rwmutexMaxReaders,通知写锁成功释放,并结束之。
- 如果读锁数量大于零,说明还有 goroutine 持有读锁,写锁等待,结束当前操作。
- 如果读锁已经全部解锁,但正在写锁,则将 rw.writerSem 和 rw.readerSem 都设置为 0,准备解锁写锁。
3.3.4 RLocker()
RLocker 返回一个 RLocker 接口,RLocker 接口包含 RLock() 和 RUnlock() 两个方法,可以将其当做一个只读的锁,在实现时也比较简单。
// RLocker 返回一个RWMutex只读锁接口
func (rw *RWMutex) RLocker() RLocker {
return (*rlocker)(rw)
}
type rlocker RWMutex
// RLock 加读锁操作实现
func (r *rlocker) RLock() {
(*RWMutex)(r).RLock()
}
// RUnlock 解读锁操作实现
func (r *rlocker) RUnlock() {
(*RWMutex)(r).RUnlock()
}
4. 读写锁的读取操作
读写锁是在多线程环境下用于同步读操作和写操作的一种机制。相比于普通互斥锁(Mutex),读写锁的读操作不会互斥,多个读操作可以同时进行,而读写锁的写操作则是互斥的,只能有一个写操作进行。这种机制可以有效地提高并发性能,特别适用于读多写少的场景。
4.1 读写锁的实现原理
读写锁的实现原理主要依赖于两个计数器:读计数器和写计数器。读计数器用于记录当前有多少个读操作正在进行,写计数器则用于记录当前有多少个写操作正在进行。当读计数器为0时,说明没有读操作在进行,此时写操作可以进行。而当写计数器为0时,说明没有写操作在进行,此时读操作可以进行。
在读操作时,读写锁首先检查写计数器的值是否大于0,如果大于0,则表示有写操作正在进行,读操作需要等待。如果写计数器为0,则将读计数器的值加1,表示有一个读操作正在进行。
在写操作时,读写锁首先检查读计数器和写计数器的值是否都为0,如果有任一计数器的值大于0,则表示有其他读操作或写操作正在进行,写操作需要等待。如果两个计数器的值都为0,则将写计数器的值加1,表示有一个写操作正在进行。
在读操作和写操作完成后,对应的计数器的值会减1。
4.2 读写锁的实现代码
下面是一个简单的Golang版本的读写锁的实现代码:
type RWLock struct {
readCount int
writeCount int
mutex sync.Mutex
readCond sync.Cond
writeCond sync.Cond
}
func (rw *RWLock) Init() {
rw.readCond.L = &rw.mutex
rw.writeCond.L = &rw.mutex
}
func (rw *RWLock) RLock() {
rw.mutex.Lock()
for rw.writeCount > 0 {
rw.readCond.Wait()
}
rw.readCount++
rw.mutex.Unlock()
}
func (rw *RWLock) RUnlock() {
rw.mutex.Lock()
rw.readCount--
if rw.readCount == 0 {
rw.writeCond.Signal()
}
rw.mutex.Unlock()
}
func (rw *RWLock) WLock() {
rw.mutex.Lock()
for rw.readCount > 0 || rw.writeCount > 0 {
rw.writeCond.Wait()
}
rw.writeCount++
rw.mutex.Unlock()
}
func (rw *RWLock) WUnlock() {
rw.mutex.Lock()
rw.writeCount--
rw.readCond.Signal()
rw.writeCond.Signal()
rw.mutex.Unlock()
}
在上面的代码中,我们使用了Golang的sync.Mutex
和sync.Cond
来实现读写锁。sync.Mutex
是一个互斥锁,用于保护共享变量的读写操作。sync.Cond
是一个条件变量,用于协调读操作和写操作。
在读操作的RLock
方法中,首先获取锁,然后判断是否有写操作正在进行,如果有,则调用条件变量的Wait
方法等待,直到写计数器为0。然后将读计数器加1,并释放锁。
在读操作的RUnlock
方法中,获取锁,将读计数器减1,如果读计数器为0,则唤醒写操作的等待者,并释放锁。
在写操作的WLock
方法中,首先获取锁,然后判断是否有其他读操作或写操作正在进行,如果有,则调用条件变量的Wait
方法等待,直到读计数器和写计数器都为0。然后将写计数器加1,并释放锁。
在写操作的WUnlock
方法中,获取锁,将写计数器减1,然后分别唤醒读操作和写操作的等待者,并释放锁。
这样,我们就实现了一个简单的读写锁,可以在多线程环境下安全地进行读操作和写操作。
5. 读写锁的写入操作
5.1 读写锁的写入操作实现原理
读写锁是一种常见的锁机制,它允许多个读操作同时进行,但是写操作需要独占锁。当写操作进行时,其他读写操作都不能进行,直到写操作完成。在 Go 语言中,读写锁被封装在 sync 包中,它包含了 RWMutex 类型。
当一个 goroutine 想要进行 RWMutex 的写操作时,它必须首先获得锁。如果此时存在其他的 goroutine 正在读或写这个锁,则会被阻塞,直到该 goroutine 成功获得锁进行写操作。否则,如果没有任何一个 goroutine 正在使用该锁,那么这个 goroutine 立即可以使用该锁进行���操作。因为写操作是独占锁,所以在写操作完成前,其他的 goroutine 都无法访问该锁。
RWMutex 实际上维护了两个互斥锁 —— 一个用于读操作,一个用于写操作。在读操作的时候,如果没有任何的写操作,那么可以多个 goroutine 同时获得锁并且进行读操作,因为读操作不会改变被锁定的资源。
当一个 goroutine 想要进行 RWMutex 的写操作时,它必须等到没有任何的读或写操作正在进行,然后才可以进行写操作。因此,写操作是独占锁,要求对当前被锁定的资源实现互斥访问。
5.2 读写锁的写入操作实现代码
在 Go 语言中,RWMutex 的写操作是通过 RWMutex 类型的 Lock() 函数实现的。Lock() 函数实际上就是通过维护读写锁的两个互斥锁,来实现写操作的独占锁。具体实现方法如下所示:
type RWMutex struct {
// 当前持有锁的总数,可以是 0、1 或者其他非负整数
w Mutex
readerSem uint32
readerCount int32
readerWait int32
}
func (rw *RWMutex) Lock() {
rw.w.Lock()
// 当前没有任何的读操作和写操作,可以获得写锁
// writerSem = 1 表示当前有 goroutine 正在进行写操作
// readerBenign/readerConflict 表示当前是否存在读操作,-1 表示不存在读操作
writerSem, readerBenign, readerConflict := rw.readerCount>>maxRWShift, rw.readerCount&readerBenignMask, rw.readerCount&readerConflictMask
if writerSem == 0 && readerConflict == 0 { // 没有 goroutine 读或写当前资源
if atomic.CompareAndSwapInt32(&rw.readerCount, readerBenign, writerLocked|readerBenign) {
return
}
}
......
在上述代码中,首先通过写锁来保护当前共享资源,然后通过锁的状态来判断当前是否允许进行写锁修改操作。如果当前没有任何的读操作和写操作,那么可以将写锁的状态修改为 1(writerSem = 1),表示当前有一个 goroutine 正在进行写操作。
6. 读写锁的性能分析
读写锁是一种常用的线程同步机制,而对于大数据量或高并发场景下的读写锁性能表现,则是我们常常需要关注的。在本节中,我们将会进行读写锁的性能测试,分析读写锁的性能表现。
6.1 读写锁的性能测试方法
实验环境
为了保证实验的可靠性,我们需要先在合适的环境中进行测试。在本次测试中,我们使用了一台配备了 Intel® Core™ i7-11500H CPU @ 2.50GHz 处理器和 16GB 内存的计算机,并使用 Go 1.16 版本进行测试。
测试方法
在本次测试中,我们将分别测试多个线程同时访问共享资源的情况下,读写锁和互斥锁的性能表现。在测试中,我们将会开启多个 goroutine 来模拟多线程的场景。我们将会测试下列场景:
- 读写锁性能测试场景
- 读写锁读场景:多个 goroutine 并行进行不停的读取。
- 读写锁写场景:多个 goroutine 并发写入数据。
- 读写锁读写混合场景:同时进行并发读和写操作。
- 互斥锁性能测试场景
- 互斥锁读场景:多个 goroutine 并行进行不停的读取。
- 互斥锁写场景:多个 goroutine 并发写入数据。
在每个测试场景中我们均会记录如下数据项:
- 操作开始时间
- 操作结束时间
- 实际操作时间(运行时间)
- 操作的 goroutine 数量
6.2 读写锁的性能测试结果及分析
测试结果
下面是对于上述场景的测试结果统计。在测试中,我们均使用了相同的测试数据(长度为 50000 的整型数组)。
- 读写锁读场景
- goroutine 数量:1
- 实际操作时间: 360.868µs
- goroutine 数量:2
- 实际操作时间: 473.26µs
- goroutine 数量:4
- 实际操作时间: 655.295µs
- goroutine 数量:8
- 实际操作时间: 1.041678ms
- goroutine 数量:1
- 读写锁写场景
- goroutine 数量:1
- 实际操作时间: 1.217143ms
- goroutine 数量:2
- 实际操作时间: 2.068806ms
- goroutine 数量:4
- 实际操作时间: 4.291873ms
- goroutine 数量:8
- 实际操作时间: 8.521639ms
- goroutine 数量:1
- 读写锁读写混合场景
- goroutine 数量:1
- 实际操作时间: 658.211µs
- goroutine 数量:2
- 实际操作时间: 761.981µs
- goroutine 数量:4
- 实际操作时间: 1.134031ms
- goroutine 数量:8
- 实际操作时间: 2.161013ms
- goroutine 数量:1
- 互斥锁读场景
- goroutine 数量:1
- 实际操作时间: 145.14µs
- goroutine 数量:2
- 实际操作时间: 244.246µs
- goroutine 数量:4
- 实际操作时间: 423.316µs
- goroutine 数量:8
- 实际操作时间: 752.014µs
- goroutine 数量:1
- 互斥锁写场景
- goroutine 数量:1
- 实际操作时间: 454.473µs
- goroutine 数量:2
- 实际操作时间: 724.631µs
- goroutine 数量:4
- 实际操作时间: 1.420796ms
- goroutine 数量:8
- 实际操作时间: 2.809679ms
- goroutine 数量:1
结果分析
通过测试结果可以看出,在读写混合场景中,读写锁性能优于互斥锁。而在单一读或单一写的场景中,互斥锁的性能则更优。这是因为在单一操作场景中,读写锁需要保证读取和写入操作互不干扰,增加了额外的开销。
此外,我们还可以看出,在并发度较高的情况下,读写锁的性能较互斥锁更优。这是因为在较高的并发度下,读写锁可以让更多的 goroutine 进行读取操作,提高了整体的性能表现。
在实际使用中需要根据具体场景来判断适合使用的锁。
7. 读写锁的应用实例
在本节中,我们将介绍一个基于读写锁实现的并发安全的数据结构示例。我们将实现一个简单的计数器,它支持并发地进行计数操作。
7.1 读写锁的应用背景
在多个goroutine并发地对计数器进行操作时,如果不使用并发控制机制,会导致竞态条件(race condition)的发生,导致计数结果的不确定性和不正确性。通过使用读写锁,我们可以保证在多个读操作的情况下不会出现数据竞争,而仅当写操作发生时才会阻塞其他的读写操作。
7.2 读写锁的应用示例
首先,我们定义一个结构体 Counter
,其中包含一个计数值和一个读写锁:
type Counter struct {
count int
mutex sync.RWMutex
}
接下来,我们将实现 Counter
结构体的三个方法:Increment()
、Decrement()
和 GetValue()
。
7.2.1 Increment()
方法
Increment()
方法用于将计数器的值增加1。在此方法中,我们使用写锁来保护对计数器的写操作:
func (c *Counter) Increment() {
c.mutex.Lock()
defer c.mutex.Unlock()
c.count++
}
7.2.2 Decrement()
方法
Decrement()
方法用于将计数器的值减少1。同样地,我们使用写锁来保护对计数器的写操作:
func (c *Counter) Decrement() {
c.mutex.Lock()
defer c.mutex.Unlock()
c.count--
}
7.2.3 GetValue()
方法
GetValue()
方法用于获取当前计数器的值。在此方法中,我们使用读锁来保护对计数器的读操作:
func (c *Counter) GetValue() int {
c.mutex.RLock()
defer c.mutex.RUnlock()
return c.count
}
7.3 示例代码
下面是一个使用我们实现的并发安全计数器的示例代码:
func main() {
counter := Counter{}
// 使用多个goroutine并发地增加计数器的值
for i := 0; i < 10; i++ {
go func() {
counter.Increment()
}()
}
// 使用多个goroutine并发地减少计数器的值
for i := 0; i < 5; i++ {
go func() {
counter.Decrement()
}()
}
// 等待所有goroutine执行完毕
time.Sleep(time.Second)
// 打印最后的计数器值
fmt.Println("Final counter value:", counter.GetValue())
}
在上面的示例代码中,我们创建了一个计数器对象,并使用多个goroutine并发地增加和减少计数器的值。通过读写锁的应用,我们可以实现安全的并发计数操作,确保最后的计数器值是正确的。
7.4 总结
本文介绍了如何在Go语言中使用读写锁的应用实例。通过实现一个并发安全的计数器,我们展示了如何使用读写锁来保护读写操作,实现高效且安全的并发访问。使用读写锁可以提升程序的性能,并避免竞态条件的发生。读写锁是Go语言中重要的并发原语,对于实现并发安全的数据结构非常有帮助。
原文地址:https://blog.csdn.net/qq_42538588/article/details/140461583
免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!