说明:Java & Go 并发编程序列的文章,根据每篇文章的主题或细分标题,分别演示 Java 和 Go 语言当中的相关实现。更多该系列文章请查看:Java & Go 并发编程系列
读写锁,顾名思义,就是把读和写区分对待了。读写锁具有以下特点:
- 写操作和写操作是互斥的
- 写操作和读操作也是互斥的
- 读操作和读操作不互斥
也就是说,读写锁与互斥锁一个重要区别是,读写锁允许多个线程同时读共享资源。读写锁可以基于此,实施更加细致的访问控制。
以上提到的线程,在 Go 语言指 Goroutine。
代码场景:假设一场足球比赛中,有一个程序负责更新比赛分数,另外有三个程序会频繁的读取这个比分,如果读取到的比分发生变化,则打印出来。
「Java」ReadWriteLock
在 Java 中 ReadWriteLock 是一个接口,以下代码基于 ReadWriteLock 的实现类 ReentrantReadWriteLock 来进行演示。
// 代表足球比赛的记分板
static class ScoreBoard {
private int homeTeam;// 主队的分数
private int guestTeam;// 客队的分数
private final ReadWriteLock lock;
public ScoreBoard() {
this.lock = new ReentrantReadWriteLock();
}
// 设置两队的分数
public void set(int homeTeam, int guestTeam) {
lock.writeLock().lock();// 基于写锁的 lock
try {
this.homeTeam = homeTeam;
this.guestTeam = guestTeam;
} finally {
lock.writeLock().unlock();// 基于写锁的 unlock
}
}
// 获取分数
public String get() {
lock.readLock().lock();// 基于读锁的 lock
try {
return String.format("%d:%d", homeTeam, guestTeam);
} finally {
lock.readLock().unlock();// 基于读锁的 unlock
}
}
}
// 表示写比分的线程
static class Writer implements Runnable {
private final ScoreBoard scoreBoard;
Writer(ScoreBoard scoreBoard) {
this.scoreBoard = scoreBoard;
}
@Override
public void run() {
// 初始比分0:0
int homeTeam = 0, guestTeam = 0;
// 模拟主队和客队随机进球,一共进了5个球
for (int i = 0; i < 5; i++) {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (ThreadLocalRandom.current().nextBoolean()) {
homeTeam++;
} else {
guestTeam++;
}
System.out.printf("[Writer] 开始刷新比分\n");
scoreBoard.set(homeTeam, guestTeam);
System.out.printf("[Writer] 刷新比分结束,比分为: %d:%d\n", homeTeam, guestTeam);
}
}
}
// 表示读比分的线程
static class Reader implements Runnable {
private final ScoreBoard scoreBoard;
public Reader(ScoreBoard scoreBoard) {
this.scoreBoard = scoreBoard;
}
@Override
public void run() {
String local = null;// 读线程自个儿保存一份比分,如果比分没变,不打印出来
// 每个读线程循环读600次
for (int i = 0; i < 600; i++) {
String newest = scoreBoard.get();
if (!newest.equals(local)) {
System.out.printf("[%s] 获取比分: %s\n", Thread.currentThread().getName(), newest);
local = newest;
}
try {
TimeUnit.MILLISECONDS.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) throws InterruptedException {
// 创建一个记分板
ScoreBoard scoreBoard = new ScoreBoard();
// 表示3个读线程
List<Thread> readers = IntStream.range(0, 3)
.boxed()
.map(e -> new Thread(new Reader(scoreBoard), String.format("Reader-%d", e)))
.collect(Collectors.toList());
// 启动3个读线程
for (Thread t : readers) {
t.start();
}
// 创建并启动一个写线程
Writer writer = new Writer(scoreBoard);
Thread writeThread = new Thread(writer);
writeThread.start();
// 等待所有线程退出
for (Thread t : readers) {
t.join();
}
writeThread.join();
}
运行结果:
[Reader-0] 获取比分: 0:0
[Reader-1] 获取比分: 0:0
[Reader-2] 获取比分: 0:0
[Writer] 开始刷新比分
[Writer] 刷新比分结束,比分为: 0:1
[Reader-1] 获取比分: 0:1
[Reader-2] 获取比分: 0:1
[Reader-0] 获取比分: 0:1
[Writer] 开始刷新比分
[Writer] 刷新比分结束,比分为: 1:1
[Reader-2] 获取比分: 1:1
[Reader-1] 获取比分: 1:1
[Reader-0] 获取比分: 1:1
[Writer] 开始刷新比分
[Writer] 刷新比分结束,比分为: 1:2
[Reader-2] 获取比分: 1:2
[Reader-0] 获取比分: 1:2
[Reader-1] 获取比分: 1:2
[Writer] 开始刷新比分
[Writer] 刷新比分结束,比分为: 1:3
[Reader-0] 获取比分: 1:3
[Reader-1] 获取比分: 1:3
[Reader-2] 获取比分: 1:3
[Writer] 开始刷新比分
[Writer] 刷新比分结束,比分为: 1:4
[Reader-1] 获取比分: 1:4
[Reader-0] 获取比分: 1:4
[Reader-2] 获取比分: 1:4
从运行结果可以看出,每次写线程完整更新比分之后,读线程才会读取到更新之后的比分。另外一点,每次读线程几乎都是同一时间打印新的比分,由于 scoreBoard.get()
几乎不耗时间,不能很好的说明读线程之间没有互斥的情况,我们可以在该方法内部适当的休眠一些时间,并调整每个读线程循环读取的次数为60次:
// 修改处一:休眠100毫秒
public String get() {
lock.readLock().lock();// 基于读锁的 lock
try {
// 以下为新增的代码
try {
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 以上为新增的代码
return String.format("%d:%d", homeTeam, guestTeam);
} finally {
lock.readLock().unlock();// 基于读锁的 unlock
}
}
// ...
// 修改处二:从600改成60次
// 每个读线程循环读60次
for (int i = 0; i < 60; i++) {
String newest = scoreBoard.get();
// ...
}
再次运行,我们还是可以看到每次读线程几乎都是同一时间打印新的比分,说明读操作之间并不互斥。
「Go」sync.RWMutex
Go 语言可以实现读写锁的工具为 sync.RWMutex,也是一个开箱即用的类型。
> 以下代码使用了 sync.WaitGroup,并非本文的重点,关于 sync.WaitGroup 的用法请回顾前文:等待一组并发任务完成——CountDownLatch VS sync.WaitGroup
// 代表足球比赛的记分板
type scoreBoard struct {
homeTeam int
guestTeam int
mu sync.RWMutex
}
// 设置两队的分数
func (s *scoreBoard) set(homeTeam int, guestTeam int) {
s.mu.Lock()
defer s.mu.Unlock()
s.homeTeam = homeTeam
s.guestTeam = guestTeam
}
// 获取分数
func (s *scoreBoard) get() string {
s.mu.RLock() // 注意区别与写操作的 Lock()
defer s.mu.RUnlock() // 注意区别与写操作的 Unlock()
return fmt.Sprintf("%d:%d", s.homeTeam, s.guestTeam)
}
// var number int
func main() {
// 创建一个记分板
score := scoreBoard{}
// 仅仅用来保证所有新启用的 Goroutine 执行结束后再退出主 Goroutine
var wg sync.WaitGroup
wg.Add(4) // 1个写和3个读
// 启用一个 Goroutine 执行写操作
go func() {
defer wg.Done()
var homeTeam, guestTeam int
rand.Seed(time.Now().UnixNano())
// 模拟主队和客队随机进球,一共进了5个球
for i := 0; i < 5; i++ {
time.Sleep(time.Second * 1)
if b := rand.Float32(); b >= 0.5 {
homeTeam++
} else {
guestTeam++
}
fmt.Printf("[Writer] 开始刷新比分\n")
score.set(homeTeam, guestTeam)
fmt.Printf("[Writer] 刷新比分结束,比分为: %d:%d\n", homeTeam, guestTeam)
}
}()
// 启动3个 Goroutine 执行读操作
for i := 0; i < 3; i++ {
go func(number int) {
defer wg.Done()
var local string
for i := 0; i < 600; i++ {
if newest := score.get(); newest != local {
fmt.Printf("[Reader-%d] 获取比分: %s\n", number, newest)
local = newest
}
time.Sleep(time.Millisecond * 10)
}
}(i)
}
wg.Wait()
fmt.Printf("done!")
}
运行结果:
[Reader-2] 获取比分: 0:0
[Reader-1] 获取比分: 0:0
[Reader-0] 获取比分: 0:0
[Writer] 开始刷新比分
[Writer] 刷新比分结束,比分为: 1:0
[Reader-2] 获取比分: 1:0
[Reader-0] 获取比分: 1:0
[Reader-1] 获取比分: 1:0
[Writer] 开始刷新比分
[Writer] 刷新比分结束,比分为: 1:1
[Reader-0] 获取比分: 1:1
[Reader-1] 获取比分: 1:1
[Reader-2] 获取比分: 1:1
[Writer] 开始刷新比分
[Writer] 刷新比分结束,比分为: 2:1
[Reader-0] 获取比分: 2:1
[Reader-2] 获取比分: 2:1
[Reader-1] 获取比分: 2:1
[Writer] 开始刷新比分
[Writer] 刷新比分结束,比分为: 2:2
[Reader-1] 获取比分: 2:2
[Reader-2] 获取比分: 2:2
[Reader-0] 获取比分: 2:2
[Writer] 开始刷新比分
[Writer] 刷新比分结束,比分为: 2:3
[Reader-0] 获取比分: 2:3
[Reader-2] 获取比分: 2:3
[Reader-1] 获取比分: 2:3
更多该系列文章请查看:Java & Go 并发编程系列