Go 语言入门很简单:读写锁

我正在参与掘金创作者训练营第4期,点击了解活动详情,一起学习吧!

前言

在上一篇文章中,我们介绍了 Go 互斥锁,这一篇文章我们来介绍 Go 语言帮我们实现的标准库的 sync.RWMutex{} 读写锁。

通过使用 sync.RWMutex,我们的程序变得更加高效。

什么是读者-写者问题

先来了解读者-写者问题(Readers–writers problem)的背景。最基本的读者-写者问题首先由 Courtois 等人提出并解决。

读者-写者问题描述了计算机并发处理读写数据遇到的问题,如何保证数据完整性、一致性。解决读者-写者问题需保证对于一份资源操作满足以下下条件:

  • 读写互斥
  • 写写互斥
  • 允许多个读者同时读取

解决读者-写者问题,可以采用读者优先(readers-preference)方案或者写者优先(writers-preference)方案。

  • 读者优先(readers-preference) :读者优先是读操作优先于写操作,即使写操作提出申请资源,但只要还有读者在读取操作,就还允许其他读者继续读取操作,直到所有读者结束读取,才开始写。读优先可以提供很高的并发处理性能,但是在频繁读取的系统中,会长时间写阻塞,导致写饥饿。
  • 写者优先(writers-preference) :写者优先是写操作优先于读操作,如果有写者提出申请资源,在申请之前已经开始读取操作的可以继续执行读取,但是如果再有读者申请读取操作,则不能够读取,只有在所有的写者写完之后才可以读取。写者优先解决了读者优先造成写饥饿的问题。但是若在频繁写入的系统中,会长时间读阻塞,导致读饥饿。

RWMutex设计采用写者优先方法,保证写操作优先处理。

回顾一下互斥锁的案例

多次单笔存款

假设你有一个银行账户,那你既可以进行存钱,也可以查询余额的操作。

package main

import "fmt"

type Account struct {
	name    string
	balance float64
}

//
func (a *Account) Deposit(amount float64) {
	a.balance += amount
}

func (a *Account) Balance() float64 {
	return a.balance
}

func main() {
	user := &Account{"xiaoW", 0}
	user.Deposit(10000)
	user.Deposit(200)
	user.Deposit(2022)

	fmt.Printf("%s's account balance has %.2f $.", user.name, user.Balance())
}
复制代码

执行该代码,进行三笔存款,我们可以看到输出的账户余额为 12222.00 $:

$ go run main.go     
xiaoW's account balance has 12222.00 $.
复制代码

同时多次存款

但如果我们进行同时存款呢?即使用 goroutine 来生成三个线程来模拟同时存款的操作。然后利用sync.WaitGroup 去等待所有 goroutine 执行完毕,打印最后的余额:

package main

import (
	"fmt"
	"sync"
)

type Account struct {
	name    string
	balance float64
}


func (a *Account) Deposit(amount float64) {
	a.balance += amount
}

func (a *Account) Balance() float64 {
	return a.balance
}

func main() {

	var wg sync.WaitGroup
	user := &Account{"xiaoW", 0}

	wg.Add(3)

	go func() {
		user.Deposit(10000)
		wg.Done()
	}()

	go func() {
		user.Deposit(200)
		wg.Done()
	}()

	go func() {
		user.Deposit(2022)
		wg.Done()
	}()

	wg.Wait()
	fmt.Printf("%s's account banlance has %.2f $.", user.name, user.Balance())
}
复制代码

同时执行 3 次是没问题的,但如果执行 1000 次呢?

package main

import (
	"fmt"
	"sync"
)

type Account struct {
	name    string
	balance float64
}

//
func (a *Account) Deposit(amount float64) {
	a.balance += amount
}

func (a *Account) Balance() float64 {
	return a.balance
}

func main() {

	var wg sync.WaitGroup
	user := &Account{"xiaoW", 0}

	n := 1000
	wg.Add(n)
	for i := 1; i <= n; i++ {
		go func() {
			user.Deposit(1000)
			wg.Done()
		}()
	}

	wg.Wait()
	fmt.Printf("%s's account banlance has %.2f $.", user.name, user.Balance())
}
复制代码

我们多次运行该程序,发现每次运行结果都不一样。

$  go run main.go
xiaoW's account banlance has 0.00 $.                                                                                                         
 
$  go run main.go
xiaoW's account banlance has 886000.00 $.                                                                                                     

$  go run main.go
xiaoW's account banlance has 2000.00 $.
复制代码

正常的结果应该为 1000 * 1000 = 1000000.00 的余额才对,运行很多次的情况下才能看到一次正常的结果。

xiaoW's account banlance has 1000000.00 $.
复制代码

使用 -race 参数来查看数据竞争

我们可以利用 -race 参数来查看我们的代码是否有竞争:

$  go run -race main.go
==================
WARNING: DATA RACE
Read at 0x00c00000e040 by goroutine 7:
  main.(*Account).Deposit()
      /home/wade/GoProjects/Go RWMutex/v2/main.go:15 +0x48
  main.main.func1()
      /home/wade/GoProjects/Go RWMutex/v2/main.go:31 +0x36

Previous write at 0x00c00000e040 by goroutine 45:
  main.(*Account).Deposit()
      /home/wade/GoProjects/Go RWMutex/v2/main.go:15 +0x6e
  main.main.func1()
      /home/wade/GoProjects/Go RWMutex/v2/main.go:31 +0x36

Goroutine 7 (running) created at:
  main.main()
      /home/wade/GoProjects/Go RWMutex/v2/main.go:30 +0x144

Goroutine 45 (finished) created at:
  main.main()
      /home/wade/GoProjects/Go RWMutex/v2/main.go:30 +0x144
==================
xiaoW's account banlance has 996000.00 $.Found 1 data race(s)
exit status 66
复制代码

我们可以看到了发生了 goroutine 的线程竞争,goroutine 7 在读的时候,goroutine 45 在写,最终导致了读写不一致,所以最终的余额也都不符合我们的预期。

互斥锁:sync.Mutex

对于上述发生的线程竞争问题,我们就可以使用互斥锁来解决,即同一时间只能有一个 goroutine 能够处理该函数。代码改正如下:

package main

import (
	"fmt"
	"sync"
)

type Account struct {
	name    string
	balance float64
	mux     sync.Mutex
}

//
func (a *Account) Deposit(amount float64) {
	a.mux.Lock() // lock
	a.balance += amount
	a.mux.Unlock() // unlock
}

func (a *Account) Balance() float64 {
	return a.balance
}

func main() {

	var wg sync.WaitGroup
	user := &Account{}
	user.name = "xiaoW"

	n := 1000
	wg.Add(n)
	for i := 1; i <= n; i++ {
		go func() {
			user.Deposit(1000)
			wg.Done()
		}()
	}

	wg.Wait()
	fmt.Printf("%s's account banlance has %.2f $.", user.name, user.Balance())
}
复制代码

此时,我们再运行 3次 go run -race main.go,得到统一的结果:

$  go run -race main.go
xiaoW's account banlance has 1000000.00 $.                                                                                                  

$  go run -race main.go
xiaoW's account banlance has 1000000.00 $.                                                                                                   
 
$  go run -race main.go
xiaoW's account banlance has 1000000.00 $.
复制代码

读和写同时进行

虽然我们同一时间存款问题通过互斥锁得到了解决。但是如果同时存款与查询余额呢?

package main

import (
	"fmt"
	"sync"
)

type Account struct {
	name    string
	balance float64
	mux     sync.Mutex
}

//
func (a *Account) Deposit(amount float64) {
	a.mux.Lock() // lock
	a.balance += amount
	a.mux.Unlock() // unlock
}

func (a *Account) Balance() float64 {
	return a.balance
}

func main() {

	var wg sync.WaitGroup
	user := &Account{}
	user.name = "xiaoW"

	n := 1000
	wg.Add(n)
	for i := 1; i <= n; i++ {
		go func() {
			user.Deposit(1000)
			wg.Done()
		}()
	}

  // 查询余额
	wg.Add(n)
	for i := 0; i <= n; i++ {
		go func() {
			_ = user.Balance()
			wg.Done()
		}()
	}

	wg.Wait()
	fmt.Printf("%s's account banlance has %.2f $.", user.name, user.Balance())
}
复制代码

然后我们运行代码,就又出现了线程竞争的问题:

$  go run -race main.go
==================
WARNING: DATA RACE
Read at 0x00c0000ba010 by goroutine 73:
  main.(*Account).Balance()
      /home/wade/GoProjects/Go RWMutex/v2/main.go:22 +0x44
  main.main.func2()
      /home/wade/GoProjects/Go RWMutex/v2/main.go:43 +0x32

Previous write at 0x00c0000ba010 by goroutine 72:
  main.(*Account).Deposit()
      /home/wade/GoProjects/Go RWMutex/v2/main.go:17 +0x84
  main.main.func1()
      /home/wade/GoProjects/Go RWMutex/v2/main.go:35 +0x46

Goroutine 73 (running) created at:
  main.main()
      /home/wade/GoProjects/Go RWMutex/v2/main.go:42 +0x1ba

Goroutine 72 (finished) created at:
  main.main()
      /home/wade/GoProjects/Go RWMutex/v2/main.go:34 +0x15e
==================
panic: sync: negative WaitGroup counter

goroutine 2018 [running]:
sync.(*WaitGroup).Add(0xc0000b4010, 0xffffffffffffffff)
        /usr/local/go/src/sync/waitgroup.go:74 +0x2e5
sync.(*WaitGroup).Done(...)
        /usr/local/go/src/sync/waitgroup.go:99
main.main.func2(0xc0000ba000, 0xc0000b4010)
        /home/wade/GoProjects/Go RWMutex/v2/main.go:44 +0x5d
created by main.main
        /home/wade/GoProjects/Go RWMutex/v2/main.go:42 +0x1bb
exit status 2
复制代码

同理,我们需要对查询余额作同样的加锁处理:

func (a *Account) Balance() (balance float64) {
	a.mux.Lock()
	balance = a.balance
	a.mux.Unlock()
	return balance
}
复制代码

如果发生读写阻塞呢?我们利用 time.Sleep() 来模拟线程阻塞的过程:

package main

import (
	"log"
	"sync"
	"time"
)

type Account struct {
	balance float64
	mux     sync.Mutex
}

//
func (a *Account) Deposit(amount float64) {
	a.mux.Lock() // lock
	time.Sleep(time.Second * 2)
	a.balance += amount
	a.mux.Unlock() // unlock
}

func (a *Account) Balance() (balance float64) {
	a.mux.Lock()
	time.Sleep(time.Second * 2)
	balance = a.balance
	a.mux.Unlock()
	return balance
}

func main() {

	wg := &sync.WaitGroup{}
	user := &Account{}

	n := 5
	wg.Add(n)
	for i := 1; i <= n; i++ {
		go func() {
			user.Deposit(1000)
			log.Printf("写:存款: %v", 1000)
			wg.Done()
		}()
	}

	wg.Add(n)
	for i := 0; i <= n; i++ {
		go func() {
			log.Printf("读:余额: %v", user.Balance())
			wg.Done()
		}()
	}

	wg.Wait()
}
复制代码

我们在程序中,每隔两秒处理一次存款和查询操作,总共发生 5 次存款和 5 次查询,那么就需要 20 秒来执行这个程序。如果存款可以接受 2 秒的时间,但是读取应该只需要更快才对,即查询操作不应该发生阻塞。

$ go run -race main.go                                                                                                 
2022/02/28 14:31:43 写:存款: 1000
2022/02/28 14:31:45 写:存款: 1000
2022/02/28 14:31:47 写:存款: 1000
2022/02/28 14:31:49 写:存款: 1000
2022/02/28 14:31:51 写:存款: 1000
2022/02/28 14:31:53 读:余额: 5000
2022/02/28 14:31:55 读:余额: 5000
2022/02/28 14:31:57 读:余额: 5000
2022/02/28 14:31:59 读:余额: 5000
2022/02/28 14:32:01 读:余额: 5000
复制代码

读写锁:sync.RWMutex

Mutex 将所有的 goroutine 视为平等的,并且只允许一个 goroutine 获取锁。针对这种情况,读写锁就该被派上用场了。

RWMutex 是 Go 语言中内置的一个 reader/writer 锁,用来解决读者-写者问题(Readers–writers problem)。任意数量的读取器可以同时获取锁,或者单个写入器可以获取锁。 这个想法是读者只关心与写者的冲突,并且可以毫无困难地与其他读者并发执行。

Go 的读写锁的特点:多读单写。 RWMutex 结构更灵活,支持两类 goroutine:readers 和 writers。 在任意一时刻,一个 RWMutex 只能由任意数量的 readers 持有,或者只能由一个 writers 持有。

读写锁的四个方法

  • RLock():此方法尝试获取读锁,并会阻塞直到被获取
  • RUnlock():解锁读锁
  • Lock():获取写锁,阻塞直到被获取
  • UnLock():释放写锁
  • RLocker():该方法返回一个指向 Locker 的指针,用于获取和释放读锁

读写锁演示

把互斥锁改为读写锁也很简单,只需要把 sync.Mutex 换成 sync.RWMutex ,然后在读操作的地方改为 RLock(),释放读锁改为 RUnlock()

package main

import (
	"log"
	"sync"
	"time"
)

type Account struct {
	balance float64
	mux     sync.RWMutex // 读写锁
}

//
func (a *Account) Deposit(amount float64) {
	a.mux.Lock() // 写锁
	time.Sleep(time.Second * 2)
	a.balance += amount
	a.mux.Unlock() // 释放写锁
}

func (a *Account) Balance() (balance float64) {
	a.mux.RLock() // 读锁
	time.Sleep(time.Second * 2)
	balance = a.balance
	a.mux.RUnlock() // 释放读锁
	return balance
}

func main() {

	wg := &sync.WaitGroup{}
	user := &Account{}

	n := 5
	wg.Add(n)
	for i := 1; i <= n; i++ {
		go func() {
			user.Deposit(1000)
			log.Printf("写:存款: %v", 1000)
			wg.Done()
		}()
	}

	wg.Add(n)
	for i := 0; i <= n; i++ {
		go func() {
			log.Printf("读:余额: %v", user.Balance())
			wg.Done()
		}()
	}

	wg.Wait()
}
复制代码

明显能感觉到读操作变快了,发生一次写之后,直接发生 6 次读操作,说明读操作是同时进行的,存款1000 一次后,6 次读操作都是 1000 元,说明结果是正确的。

2022/02/28 14:42:50 写:存款: 1000
2022/02/28 14:42:52 读:余额: 1000
2022/02/28 14:42:52 读:余额: 1000
2022/02/28 14:42:52 读:余额: 1000
2022/02/28 14:42:52 读:余额: 1000
2022/02/28 14:42:52 读:余额: 1000
2022/02/28 14:42:52 读:余额: 1000
2022/02/28 14:42:54 写:存款: 1000
2022/02/28 14:42:56 写:存款: 1000
2022/02/28 14:42:58 写:存款: 1000
复制代码

总结

本文从读者-写者问题出发,回顾了互斥锁的案例:一个银行账户存款和查询的竞争问题的出现以及解决方法。最后引出 Go 自带的读写锁 sync.RWMutex

读写锁的特点是多读单写,一个 RWMutex 只能由任意数量的 readers 持有,或者只能由一个 writers 持有。我们可以利用读写锁来锁定某个操作以防止其他例程/线程在处理它时更改值,防止程序出现不可预测的错误。最后,可以利用读写锁弥补互斥锁的缺陷,用来加快程序的读操作,减少程序的运行时间。

灵感来源:

  1. Go 程序设计语言--sync.RWMutex读写锁
  2. stackoverflow -- How to use RWMutex
  3. 官方文档 -- RWMutex
  4. Golang RWMutex示例
  5. Using Mutexes in Golang - A Comprehensive Tutorial With Examples
  6. sync.RWMutex
  7. Go语言实战笔记(十七)| Go 读写锁

Guess you like

Origin juejin.im/post/7069655986336137230