可重入读写锁:多个读线程

4

你好

我有一个问题与ReentrantReadWriteLocks有关。我正在尝试解决一个问题,即多个读取线程应该能够并行地在数据结构上操作,而一个写入线程只能单独操作(当没有读取线程处于活动状态时)。我正在使用Java中的ReentrantReadWriteLocks来实现这一点,但从时间测量来看,似乎读取线程也会相互锁定。我认为这不应该发生,所以我想知道是否我实现有误。我实现的方式如下:

readingMethod(){
    lock.readLock().lock();
    do reading ...
    lock.readLock().unlock();
}

writingMethod(){
    lock.writeLock().lock();
    do writing ...
    lock.writeLock().unlock();
}

当多个不同的线程调用阅读方法时,从测量时间来看,即使从未调用写入方法,阅读方法也是按顺序执行的!对此有什么想法吗?谢谢您提前致意-干杯

编辑:我尝试提供一个SSCCE,希望这很清楚:

public class Bank {
private Int[] accounts;
public ReadWriteLock lock = new ReentrantReadWriteLock();

// Multiple Threads are doing transactions.
public void transfer(int from, int to, int amount){
    lock.readLock().lock(); // Locking read.

    // Consider this the do-reading.
    synchronized(accounts[from]){
        accounts[from] -= amount;
    }
    synchronized(accounts[to]){
        accounts[to] += amount;
    }

    lock.readLock().unlock(); // Unlocking read.
}

// Only one thread does summation.
public int totalMoney(){
    lock.writeLock().lock; // Locking write.

    // Consider this the do-writing.
    int sum = 0;
    for(int i = 0; i < accounts.length; i++){
        synchronized(accounts[i]){
            sum += accounts[i];
        }
    }

    lock.writeLock().unlock; // Unlocking write.

    return sum;
}}

我知道读锁内部的部分实际上不是读取而是写入。我这样做是因为有多个线程执行写操作,而只有一个线程执行读操作,但在读取时,不能对数组进行任何更改。在我的理解中,这种方式可行。再次强调,只要不添加写方法和读锁,读锁内部的代码可以很好地处理多个线程。


你的计算机有多少个CPU可用? - Andrew Henle
2
这里提供的信息不足以提供有意义的帮助。可能有很多原因;在Java中,简单/天真的基准测试技术会产生不一致的结果(要正确地进行Java微基准测试并不容易),在“do reading”中可能存在额外同步的瓶颈,"do reading"的复杂度可能很低,线程在它们的时间片内就完成了,观察到的顺序行为实际上只是上下文切换的开销等等。请尝试提供一个清晰地展示问题的SSCCE。 - Andreas Troelsen
1
如果你说有额外的synchronized块,那么你已经回答了这个问题... - Holger
1
@Holger 经过一些测试,我现在已经弄清楚了以下内容:线程以正确的方式进入和退出读取方法(在锁定和解锁语句内部),并且不会相互锁定。然而,一个线程完成一次方法运行所需的时间明显比没有锁定/解锁的时间长得多。可能的原因是,ReadWrite Lock 也会出于某种原因阻止使用的所有其他锁定,即使它们不是同一个锁定对象?我已在进行读取时在数据结构的不同元素上进行了同步,因此不是相同的锁定。 - Kugelblitz
1
通常,使用显式锁的理由是为了获得所需的控制。同步块使用Java中所有对象都具有的隐式锁。如果所有读取器都调用readMethod(),这意味着它们首先从您的ReadWriteLock获取读取锁,然后获取同步块对象上的隐式锁。由于您尚未使用真正的SSCCE编辑问题,因此人们只能假设您正在有效地创建双重锁定,其中内部锁定防止读取器同时访问相同的数据结构。 - Andreas Troelsen
显示剩余8条评论
2个回答

4
你的代码太糟糕了,不用担心性能问题。你的代码不是线程安全的。永远不要在可变变量上同步
synchronized(accounts[from]){
    accounts[from] -= amount;
}

这段代码的作用如下:
  • 在没有同步的情况下读取数组accounts中位置from的内容,因此可能读取到已经过时的值,或者是一个仍然在其synchronized块内的线程刚刚写入的值
  • 锁定它所读取的任何对象(请记住,由自动装箱创建的Integer对象的标识未指定[除了-128至+127范围之外])
  • 再次读取数组accounts中位置from的内容
  • 从其int值中减去amount,将结果自动装箱(在大多数情况下产生一个不同的对象)
  • 将新对象存储在数组accounts的位置from
这意味着不同的线程可以同时写入相同的数组位置,同时锁定它们第一次(未同步)读取到的不同Integer实例,从而打开数据竞争的可能性。

这也意味着,如果不同线程在不同的数组位置上具有相同的值,那么它们可能会阻塞彼此,因为这些值恰好由相同的实例表示。例如,使用零值(或在-128到+127范围内都是相同值)预初始化数组是接近单线程性能的好方法,因为零(或这些其他小值)是少数保证由相同实例表示的Integer值之一。既然你没有遇到NullPointerException,显然你已经用某些东西预初始化了数组。


总之,synchronized适用于对象实例,而不是变量。这就是为什么在尝试对int变量进行同步时它无法编译的原因。由于在不同对象上同步就像没有任何同步一样,您永远不应该在可变变量上进行同步。
如果您想要线程安全、并发访问不同账户,可以使用AtomicIntegers。这种解决方案将为每个账户使用一个AtomicInteger实例,该实例永远不会更改。只有其余额值将使用其线程安全方法进行更新。
public class Bank {
    private final AtomicInteger[] accounts;
    public final ReadWriteLock lock = new ReentrantReadWriteLock();
    Bank(int numAccounts) {
        // initialize, keep in mind that this array MUST NOT change
        accounts=new AtomicInteger[numAccounts];
        for(int i=0; i<numAccounts; i++) accounts[i]=new AtomicInteger();
    }

    // Multiple Threads are doing transactions.
    public void transfer(int from, int to, int amount){
        final Lock sharedLock = lock.readLock();
        sharedLock.lock();
        try {
            accounts[from].addAndGet(-amount);
            accounts[to  ].addAndGet(+amount);
        }
        finally {
            sharedLock.unlock();
        }
    }

    // Only one thread does summation.
    public int totalMoney(){
        int sum = 0;
        final Lock exclusiveLock = lock.writeLock();
        exclusiveLock.lock();
        try {
            for(AtomicInteger account: accounts)
                sum += account.get();
        }
        finally {
            exclusiveLock.unlock();
        }
        return sum;
    }
}

为了完整起见,我猜这个问题会出现,这是一个禁止取出超过可用金额的提款过程的示例:

static void safeWithdraw(AtomicInteger account, int amount) {
    for(;;) {
        int current=account.get();
        if(amount>current) throw new IllegalStateException();
        if(account.compareAndSet(current, current-amount)) return;
    }
}

可以通过将代码行accounts[from].addAndGet(-amount);替换为safeWithdraw(accounts[from], amount);来包含它。


在写完上面的示例之后,我想起了AtomicIntegerArray类,它更适合这种任务...

private final AtomicIntegerArray accounts;
public final ReadWriteLock lock = new ReentrantReadWriteLock();

Bank(int numAccounts) {
    accounts=new AtomicIntegerArray(numAccounts);
}

// Multiple Threads are doing transactions.
public void transfer(int from, int to, int amount){
    final Lock sharedLock = lock.readLock();
    sharedLock.lock();
    try {
        accounts.addAndGet(from, -amount);
        accounts.addAndGet(to,   +amount);
    }
    finally {
        sharedLock.unlock();
    }
}

// Only one thread does summation.
public int totalMoney(){
    int sum = 0;
    final Lock exclusiveLock = lock.writeLock();
    exclusiveLock.lock();
    try {
        for(int ix=0, num=accounts.length(); ix<num; ix++)
            sum += accounts.get(ix);
    }
    finally {
        exclusiveLock.unlock();
    }
    return sum;
}

非常感谢您提供如此详细的答案!当然,在尝试使示例尽可能通用和紧凑时,我也可能会搞砸。在我的代码中,每个帐户都有一个单独的类/对象,并且数组不会保存整数值,而是保存“Account.class”的实例,这在并发设置中当然是可以的。然而,我面临的问题仍未通过此实现解决(至少根据快速测试)。但是,我不确定是否应该现在编辑问题,因为您在此处编写的答案对其他人具有如此大的学习价值! - Kugelblitz
1
我认为过多的编辑会降低问题的价值。Account类可能是完全不同的问题。例如,有没有办法使更新成为原子操作?也许你想开一个新的问题,然后附上AccountBank的完整代码... - Holger
1
你需要一个类似于AtomicInteger的API,例如添加一个名为changeBalance的方法,该方法以差值作为参数。通常情况下,您需要提供所有操作的原子实现,否则需要使用getBalancesetBalance进行复合。我回答中的safeWithdraw方法展示了如何将任意的获取-处理-设置操作实现为原子操作的一般模式。 - Holger
我想我明白你的意思,但是以你的safeWithdraw实现方式呈现原子操作setBalance是否与将其实现为同步方法几乎等效? - Kugelblitz
如果setBalance只包含对int变量的单个写入,则它已经是原子性的,将int变量声明为volatile就足以使其线程安全。然而,要启用像safeWithdraw这样的原子get-process-set操作,您需要一个cas(原子比较和设置)操作;这是关键特性。您可以通过将int变量转换为AtomicInteger或将其声明为volatile并使用AtomicIntegerFieldUpdater来更新字段来提供它。 - Holger
显示剩余3条评论

1
你可以在此测试中运行2个线程。
static ReadWriteLock l = new ReentrantReadWriteLock();

static void readMehod() {
    l.readLock().lock();
    System.out.println(Thread.currentThread() + " entered");
    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    l.readLock().unlock();
    System.out.println(Thread.currentThread() + " exited");
}

并查看两个线程是否都进入了读取锁定状态。

好的,我做了这个,似乎所有线程(使用2和12都试过)都以正确的方式进入和退出,而不会阻塞彼此。看起来问题出在别的地方。 - Kugelblitz
我在上面的评论中回答了“Holger”的问题,并提到了这一点。只是让你知道一下...也许你对此有所了解。 - Kugelblitz

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接