第十四章 构建自己的同步工具

14.1 状态依赖性的管理

对于并发对象上依赖状态的方法,虽然有时候在前提条件不满足的情况下不会失败,但通常有一种更好的选择,即等待前提条件变为真。

在并发程序中,依赖状态的操作可以一直阻塞直到可以继续执行,这比使它们先失败再实现起来更为方便且更不易出错。

可阻塞的状态依赖操作的形式如14-1所示,这种加锁模式有些不同,因为锁是在操作的执行过程中被释放与重新获取的。

构成前提条件的状态变量必须由对象的锁来保护,从而使它们在测试前提条件的同时保持不变。
如果前提条件尚未满足,就必须释放锁,以便其他线程可以修改对象的状态,否则,前期条件就永远无法变成真。
在再次测试前提条件之前,必须重新获得锁。

//     14-1   可阻塞的状态依赖操作的结构
void blockingAction() throws InterruptedException {
   acquire lock on object state
	   while (前提条件尚未满足) {
	     release lock
	     等待直到前提条件满足
	     optionally fail if interrupted or timeout expires
	     reacquire lock
	    }
    perform action
    	release lock
}

14-2中的BaseBoundedBuffer,这个类中实现了一个基于数组的循环缓存,其中各个缓存状态变量(buf,head,tail和count)均由缓存的内置锁来保护。它还提供了同步的doPut和doTake方法,并在子类中通过这些方法来实现put和take操作,底层的状态将对子类隐藏。

//        14-2     有界缓存实现的基类
@ThreadSafe
public class BaseBoundedBuffer<V> {
    private final V[] buf;
    private int tail;
    private int head;
    private int count;

    protected BaseBoundedBuffer(int capacity){
        this.buf=(V[])new Object[capacity];
    }

    protected synchronized final void doPut(V v){
        buf[tail]=v;
        if(++tail==buf.length)
            tail=0;
        ++count;
    }

    protected synchronized final V doTake(){
        V v=buf[head];
        buf[head]=null;
        if(++head==buf.length)
            head=0;
        --count;
        return v;
    }

    public synchronized final boolean isFull(){
        return count==buf.length;
    }

    public synchronized final boolean isEmpty(){
        return count==0;
    }
}
14.1.1 示例:将前提条件的失败传递给调用者

put和take都进行了同步以确保实现对缓存状态的独占访问,因为这两个方法在访问缓存时都采用“先检查再运行”的逻辑策略。

//       14-3   当不满足前提条件时,有界缓存不会执行相应的操作
public class GrumpyBoundedBuffer<V> extends BaseBoundedBuffer<V> {
    public GrumpyBoundedBuffer() {
            this(100);
    }
    public GrumpyBoundedBuffer(int size){
        super(size);
    }
    public synchronized void put(V v)throws BufferFullException{
        if(isFull())
            throw new BufferFullException();
        doPut(v);
    }
    public synchronized V take()throws BufferEmptyException{
        if(isEmpty())
            throw new BufferEmptyException();
        return doTake();
    }
}
class BufferFullException extends RuntimeException {
}

class BufferEmptyException extends RuntimeException {
}

异常应该用于发生异常条件的情况中,“缓存已满”并不是有界缓存的一个异常条件。

调用者必须做好捕获异常的准备,并且在每次缓存操作时都需要重试。
14-4给出了对take的调用——并不是很漂亮。

//      14-4    调用GrumpyBoundedBuffer的代码
class ExampleUsage{
     private GrumpyBoundedBuffer<String> buffer;
     int SLEEP_GRANULARITY = 50;
     void useBuffer() throws InterruptedException{
         while(true){
             try{
                 String item=buffer.take();
                 //对item执行一些操作
                 break;
             }catch (BufferEmptyException e) {
                Thread.sleep(SLEEP_GRANULARITY);
            }
         }
     }
}

当缓存处于某个错误的状态时返回一个错误值,这是一种改进,因为并没有放弃异常机制,但这种方法并没有解决根本问题:调用者必须自行处理前提条件失败的情况

14-4中,调用者可以不进入休眠状态,而直接重新调用take,这种方法被称为忙等待或自旋等待。
忙等待可能导致CPU时钟周期浪费,休眠可能导致低响应性。

14.1.2 通过轮询与休眠来实现简单的阻塞

14-5中尝试通过put和take方法来实现一种简单的“轮询与休眠”重试机制,从而使调用者无需在每次调用时都实现重试逻辑。
如果缓存为空,那么take将休眠并直到另一个线程往缓存中放入一些数据;
如果缓存为满,那么put将休眠并直到另一个线程从缓存中移除一些数据。
这种方法将前提条件的管理操作封装起来,并简化了对缓存的使用——这朝着正确的改进方法迈出了一步。

//     14-5   使用简单阻塞实现的有界缓存(并不好)
public class SleepyBoundedBuffer<V> extends BaseBoundedBuffer<V> {
    int SLEEP_GRANULARITY = 60;

    public SleepyBoundedBuffer(){
        super(100);
    }

    public SleepyBoundedBuffer(int size){
        super(size);
    }

    public void put(V v) throws InterruptedException{
        while(true){
            synchronized (this) {
                if(!isFull()){
                    doPut(v);
                    return ;
                }
            }//如果测试失败,那么当前执行的线程将首先释放锁并休眠一段时间,从而使其他线程能够访问缓存
            Thread.sleep(SLEEP_GRANULARITY);
        }
    }

    public V take() throws InterruptedException{
        while(true){
            synchronized (this) {
                if(!isEmpty())
                    return doTake();
            }//如果测试失败,那么当前执行的线程将首先释放锁并休眠一段时间,从而使其他线程能够访问缓存
            Thread.sleep(SLEEP_GRANULARITY);
        }
    }
}

缓存代码必须在持有缓存锁的时候才能测试相应的状态条件,因为表示状态条件的变量是由缓存锁保护的。

14.1.3 条件队列

条件队列名字来源于:它使得一组线程(称之为等待线程集合)能够通过某种方式来等待特定的条件变成真。传统队列的元素是一个个数据,而条件队列中的元素时一个个正在等待相关条件的线程。

每个对象可以作为一个条件队列,并且Object中的wait,notify和notifyAll方法构成了内部条件队列的API。
对象的内置锁与其内部条件队列是相互关联的,要调用对象X中条件队列中任一方法,都必须持有对象X的锁。 设施因为“等待由状态构成的条件”与“维护状态一致性”这两种机制被紧密地绑定到一起:只有能对状态进行检查时,才能在某个条件上等待,并且只有能修改状态时,才能从条件等待中释放另一个线程。

根据上述两点,在调用wait(), notify()或notifyAll()的时候,必须先获得锁,且状态变量须由该锁保护,而固有锁对象与固有条件队列对象又是同一个对象。也就是说,要在某个对象上执行wait,notify,先必须锁定该对象,而对应的状态变量也是由该对象锁保护的。

wait(),notify(),notifyAll()不属于Thread类,而是属于Object基础类,也就是说每个对像都有wait(),notify(),notifyAll()的功能。因为都个对像都有锁,锁是每个对像的基础

wait():
等待对象的同步锁,需要获得该对象的同步锁才可以调用这个方法,否则编译可以通过,但运行时会收到一个异常:IllegalMonitorStateException。
调用任意对象的 wait() 方法导致该线程阻塞,该线程不可继续执行,并且该对象上的锁被释放。

notify():
唤醒在等待该对象同步锁的线程(只唤醒一个,如果有多个在等待),注意的是在调用此方法的时候,并不能确切的唤醒某一个等待状态的线程,而是由JVM确定唤醒哪个线程,而且不是按优先级。
调用任意对象的notify()方法则导致因调用该对象的 wait()方法而阻塞的线程中随机选择的一个解除阻塞(但要等到获得锁后才真正可执行)

notifyAll():
唤醒所有等待的线程,注意唤醒的是notify之前wait的线程,对于notify之后的wait线程是没有效果的。

Object.wait会自动释放锁,并请求操作系统挂起当前线程,从而使其他线程能获得这个锁并修改对象的状态。
当被挂起的线程醒来时,它将在返回之前重新获取锁。
可以这样来理解:调用wait意味着“我要去休息了,但发生特定的事情时唤醒我“,而调用通知方法就意味着“特定的事情发生了”

14-6使用了wait和notifyAll来实现一个有界缓存。
这比使用休眠更简单,更高效,响应性更高。
如果某个功能无法通过“轮询与休眠”来实现,那么使用条件队列也无法实现,但条件队列使得在表达和管理状态依赖性时更简单高效。

//      14-6使用条件队列实现的有界缓存
public class BoundedBuffer<V> extends BaseBoundedBuffer<V>{
    //条件谓词:not-full(!isFull())
    //条件谓词:not-empty(!isEmpty())

    public BoundedBuffer(){
        super(100);
    }

    public BoundedBuffer(int size){
        super(size);
    }
    //阻塞直到:not-full
    public synchronized void put(V v) throws InterruptedException{
        while(isFull())
            wait();
        doPut(v);
        //唤醒所有正在等待该对象的线程 ,这样take操作才能获得锁
        notifyAll();
    }
    //阻塞直到:not-empty
    public synchronized V take() throws InterruptedException{
        while(isEmpty())
            wait();
        V v=doTake();
        //唤醒所有正在等待该对象的线程,这样put才能获得锁
        notifyAll();
        return v;
    }
}

14.2 使用条件队列

条件队列使构建高效以及高可响应性的状态依赖类变得更容易,但同时也容易被不正确地使用。

14.2.1 条件谓词

要想正确地使用条件队列,关键是要找出在哪个条件谓词上等待。如果没有条件谓词,条件等待机制将无法发挥作用。

条件谓词是指使某个操作成为状态依赖操作的前提条件。
条件谓词是有类中各个状态变量构成的表达式。

在条件等待中存在一种重要的三元关系,包括加锁,wait方法和一个条件谓词。
在条件谓词中包含多个状态变量,而状态变量由一个锁来保护,因此在测试条件之前必须先持有这个锁。
锁对象和条件队列对象(即调用wait和notify等方法所在的对象)必须是同一个对象。

在14-6中,缓存的状态是由缓存锁保护的,并且缓存对象被用作条件队列。take方法将获取请求缓存锁,然后对条件谓词(即缓存为非空)进行测试。如果缓存非空,那么它会移除第一个元素。之所以能这么做,是因为take此时仍然只有保护缓存状态的锁。

如果条件谓词不为真(缓存为空),take将在缓存的内置条件队列上调用wait方法。wait方法将释放锁,阻塞当前线程,并等待直到超时,然后线程被中断或通过一个通知被唤醒。在唤醒进程后,wait在返回前还要重新获取锁,当线程从wait方法中被唤醒时,它在重新请求锁时不具有任何特殊的优先级,而要与其他尝试进入同步代码块的线程一起正常地在锁上进行竞争。

14.2.2 过早唤醒

内置条件队列可以与多个条件谓词一起使用。当一个线程由于调用notifyAll而醒来时,并不意味者正在等待的条件谓词已变成真了(像烤面包机和咖啡机共用一个铃声,你必须查看是哪个设备发出的)。

当执行控制重新进入调用wait的代码时,它已经重新获取了与条件队列相关的锁。
现在条件谓词不一定为真,因为在发出通知的线程调用notifyAll时,条件谓词可能已经变成真,但在重新获得锁时再次变成假。在线程被唤醒到wait重新获取锁的这段时间内,可能有其他线程已经获取了这个锁,并修改了对象的状态。
或者条件谓词从调用wait起就根本没变成真,也许因为与同一条件队列相关的另一个谓词变成了真。“一个条件队列与多个条件谓词相关”时一种常见的情况—在14-6中使用的条件队列与“非满”和“非空”两个条件谓词相关。

每当线程从wait中唤醒时,必须再次测试条件谓词,如果条件谓词不为真,就继续等待(或者失败)。
由于线程在条件谓词不为真的情况下也可反复醒来,因此必须在一个循环中调用wait,并在每次迭代中测试条件谓词。

14-7给出了条件等待的标准形式

//       14-7  状态依赖方法的标准形式
void stateDependentMethod() throws InterruptedException {
   // 必须通过一个锁来保护条件谓词
   synchronized(lock) {
      while (!conditionPredicate())
         lock.wait();
      // 现在对象处于合适的状态
	}
}

当使用条件等待时(例如Object.wait和Condition.wait):
①通常有一个条件谓词—包括一些对象状态的测试,线程在执行前必须通过这些测试。
②在调用wait之前测试条件谓词,并且从wait中返回时再次进行测试。
③在一个循环中调用wait
④确保使用与条件队列相关的锁来保护构成条件谓词的各个状态变量
⑤当调用wait,notify,notifyAll等方法时,一定要持有与条件队列相关的锁。
⑥在检查条件谓词之后以及开始执行相应的操作之前,不要释放锁

14.2.3 丢失的信号

丢失的信号是指:线程必须等待一个已经成真的条件,但在开始等待之前没有检查条件谓词。(好比你等烤面包机的过程中去拿报纸,而途中铃声响了,你并不知道,还会继续等待)

通知并不像你涂在面包上的果酱,它没有“黏附性”。
如果线程A通知了一个条件队列,而线程B随后在这个条件队列上等待,那么线程B将不会立即醒来,而是需要另一个通知来唤醒它。(例如,没有在调用wait之前检查条件信号就会导致信号的丢失)

14.2.4 通知

每当在等待一个条件时,一定要确保在条件谓词变成真时通过某种方式发出通知。

在条件队列API中有两种发出通知的方法:notify和notifyAll。
这两种方法都必须获得与条件队列对象相关联的锁。
发出通知的线程应尽快地释放锁,从而确保正在等待的线程尽快地解除阻塞。

只有同时满足一下两个条件时,才能使用单一的notify而不是notifyAll:
①所有等待线程的类型都相同
只有一个条件谓词与条件队列相关,并且每个线程在从wait返回后将执行相同的操作。
②单进单出
在条件变量上的每次通知,最多只能唤醒一个线程来执行。

14-6中满足“单进单出”,但不满足第一点,因为它的条件队列有两个不同的条件谓词:“非空”和“非满”。

在14-6中的put和take方法中采用的通知机制时保守的:每当将一个对象放入缓存或者从缓存中移走一个对象时,就执行一次通知。
我们可以进行优化:首先,仅当缓存从空变为非空,或者从满转为非满时,才需要释放一个线程(等到状态转换了才唤醒线程,其他新进来线程正常执行)。并且,仅当put或take影响到这些状态转换时,才发出通知,这被称为“条件通知(Conditional Notification)”。

虽然“条件通知”可以提升性能,但却很难实现(还会使子类的实现变得复杂),因此在使用时应该谨慎。

//         14-8   在BoundedBuffer中使用条件通知
public synchronized void put(V v) throws InterruptedException{
    while (isFull())
       wait();
    boolean wasEmpty = isEmpty();
    doPut(v);
    if (wasEmpty)
      notifyAll();
}
14.2.5 示例:阀门类

第5章使用的“开始阀门闭锁”在初始化指定的参数为1,从而创建一个二元闭锁:它只有两种状态:即初始状态(1)和结束状态(0)。

闭锁能阻止线程通过开始阀门,并直到阀门被打开,此时所有的线程都可以通过该阀门。

闭锁机制在某些情况下存在一个缺陷:按照这种方式构造的阀门在打开后无法重新关闭。

通过使用条件等待,创建一个可重新关闭的ThreadGate类。它可以打开和关闭阀门,并提供一个await方法,该方法能一直阻塞直到阀门被打开。在open方法中使用了notifyAll,因为这个类的语义不满足“单进单出”

//     14-9   适应wait和notifyAll来实现可重新关闭的阀门
@ThreadSafe
public class ThreadGate {
    //条件谓词:opened-since(n)(isopen||generation>n)
    @GuardedBy("this") private boolean isOpen;
    @GuardedBy("this") private int generation;

    public synchronized void close() {
        isOpen = false;
    }

    public synchronized void open() {
        ++generation;
        isOpen = true;
        notifyAll();
    }

//  阻塞直到:opened-since{generation on entry}
    public synchronized void await() throws InterruptedException {
        int arrivalGeneration = generation;
        //当有线程调用了open,isOpen为true,generation增加,此时才不阻塞
        while (!isOpen && arrivalGeneration == generation)
            wait();
    }
}

在await中使用的条件比测试isOpen复杂得多,这种条件谓词是必须的,因为如果当阀门打开时有N个线程正在等待它,那么这些线程都应该被允许执行。
然而,如果阀门在打开后又非常快速地关闭了,并且await方法只检查isOpen,那么所有线程都可能无法释放:当所有线程收到通知时,将重新请求锁并退出wait,而此时的阀门可能已经再次关闭了。因此,在ThreadGate中使用了一个更复杂的条件谓词:每次阀门关闭后,递增一个“Generation”计数器,如果阀门现在是打开的,或者阀门自从该线程达到后一直是打开的,那么线程就可以通过await。

由于ThreadGate只支持等待打开阀门,因此它直到open中执行通知。要想支持“等待打开”又支持“等待关闭”,必须在open和close中都进行通知。这就说明了在维护状态依赖的类时时非常困难的——当增加一个新的状态依赖操作时,可能需要对多条修改对象的代码路径进行改动。

14.2.6 子类的安全问题

对于状态依赖的类,要么将其等待和通知等协议完全向子类公开(并且写入正式文档),要门完全阻止子类参与到等待和通知等过程中。

另一选择就是完全禁止子类化,例如将类型声明为final,或者将条件队列,锁和状态变量等隐藏起来,使子类看不见它们。

14.2.7 封装条件队列

通常,我们应该把条件队列封装起来,因而除了使用条件队列的类,就不能在其他地方访问它。否则,调用者会采用一种违背设计的方式来使用条件队列。

然而,将条件队列对象封装起来,与线程安全类的最常见设计模式并不一致,在这种模式中建议使用对象的内置锁来保护对象自身的状态。在14-6中给出了这种常见的模式,即缓存对象自身既是锁,又是条件队列。

可以容易地将14-6中重新设计为使用私有的锁对象和条件队列,新的14-6中不再支持任何形式的客户端加锁。

14.3 显式的Condition对象

13章中介绍,在某些情况下,当内置锁过于灵活时,可以使用显式锁。 正如Lock时一种广义的内置锁,Condition时一种广义的内置条件队列。

//       14-10     Condition接口
public interface Condition {
   void await() throws InterruptedException;
   boolean await(long time, TimeUnit unit) throws InterruptedException;
   long awaitNanos(long nanosTimeout) throws InterruptedException;                               
   void awaitUninterruptibly();
   boolean awaitUntil(Date deadline) throws InterruptedException;
   void signal();
   void signalAll();
}

内置条件队列存在一些缺陷,每个内置锁都只能有一个相关联的条件队列,因而在像BoundedBuffer这种类中,多个线程可能在同一个条件队列上等待不同的条件谓词,并在最常见的加锁模式下公开条件队列对象。

如果想编写一个带有多个条件谓词的并发对象,或者想获得除了条件队列可见性的更多控制权,就可以使用显式的Lock和Condition而不是内置锁和条件队列。

一个Condition和一个Lock关联在一起,就像一个条件队列和一个内置锁相关联一样。要创建一个Condition,可以在相关联的Lock上调用Lock.newCondition方法。
Condition比内置条件队列提供了更丰富的功能:在每个锁上可存在多个等待,条件等待可以时可中断的或不可中断的,基于时限的等待,以及公平的非公平的队列操作。

与内置条件队列不同的时,对于每个Lock,可以有任意数量的Condition对象。Condition对象继承了相关的Lock对象的公平性,对于公平的锁,线程会依照FIFO顺序从Condition.await中释放。

在Condition对象,与wait,notify,notifyAll方法分别对应的时await,signal,signalAll。但是Condition对Object进行了扩展,因而它也包含wait和notify方法,一定要确保使用正确的版本——await和signal。

14-11使用两个Condition,分别为notFull和notEmpty,用于表示”非空“和”非满“两个条件谓词。当缓存为空时,take将阻塞并等待notEmpty,此时put想notEmpty发送信号,可以解除任何在take中阻塞的线程。

//     14-11  使用显式条件变量的有界缓存
@ThreadSafe
public class ConditionBoundedBuffer <T> {
    protected final Lock lock = new ReentrantLock();
    //条件谓词:notFull(count<items.length)
    private final Condition notFull = lock.newCondition();
  //条件谓词:noEmpty(count>0)
    private final Condition notEmpty = lock.newCondition();
    private static final int BUFFER_SIZE = 100;
    @GuardedBy("lock") private final T[] items = (T[]) new Object[BUFFER_SIZE];
    @GuardedBy("lock") private int tail, head, count;

    //阻塞直到notFull
    public void put(T x) throws InterruptedException {
        lock.lock();
        try {
            while (count == items.length)
                notFull.await();
            items[tail] = x;
            if (++tail == items.length)
                tail = 0;
            ++count;
            notEmpty.signal();//put想notEmpty发送信号,可以解除任何在take中阻塞的线程。
        } finally {
            lock.unlock();
        }
    }

  //阻塞直到notEmpty
    public T take() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0)
                notEmpty.await();
            T x = items[head];
            items[head] = null;
            if (++head == items.length)
                head = 0;
            --count;
            notFull.signal();
            return x;
        } finally {
            lock.unlock();
        }
    }
}

当使用显式的Lock和Condition时,也必须满足锁,条件谓词和条件变量之间的三元关系。

如果需要一些高级功能,例如使用公平的队列操作或者在每个锁上对应多个等待线程集,那么应该优先使用Condition而不是内置条件队列。(如果需要ReentrantLock的高级功能,并且已经使用了它,那么就已经做出了选择。)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值