《Java併發編程實踐》五(2):自定義同步器

這一章介紹基於狀態條件同步的一般場景,以及通過條件隊列來構建自定義同步機制的方法。最後介紹JDK同步器的公共抽象—AQS的基本原理,以及它在Java併發庫內的應用。

狀態條件

一般來說,線程之間的同步都是圍繞狀態條件展開的,以”容量有限隊列“爲例,take操作必須在隊列滿足“nonempty“狀態條件下才能執行;如果不滿足條件,take操作要麼失敗,要麼阻塞。構建基於狀態、具備併發同步功能的類,最容易的方式是使用現有的同步裝置,下使用CountDownLatch構建一個二元的同步裝置ValueLatch,用來同步一個value的初始化。

@ThreadSafe
public class ValueLatch<T> {
	@GuardedBy("this") private T value = null
	private final CountDownLatch done = new CountDownLatch(1);
	public boolean isSet() {
		return (done.getCount() == 0);
	}
	public synchronized void setValue(T newValue) {
		if (!isSet()) {
			value = newValue;
			done.countDown();
		}
	}
	
	public T getValue() throws InterruptedException {
		done.await();
		synchronized (this) {
			return value;
		}
	}
}

synchronized鎖,也可被認爲是一種特殊的0/1狀態條件的同步器,所以上面的代碼實際上用了兩個同步器,CountDownLatch用來同步“初始化”,synchronized用來同步字段讀寫。

如果現有的同步器不滿足需求,可以嘗試使用條件隊列、AbstractQueuedSynchronizer等更底層的同步裝置,本章介紹實現“基於狀態條件的同步器”的各種可選技術方案。

狀態條件阻塞

在單線程程序中,類在執行操作時如果發現條件不滿足,只能失敗返回;但在一個併發程序中,還可以選擇等待,其他線程會改變狀態並使條件滿足。”阻塞線程以等待條件滿足“通常是一個更好的選擇,否則使用者需要處理失敗並進行重試;而如何進行重試對使用者來說是一個進退兩難的問題。

使用基於狀態的阻塞機制採用以下僞碼所展示的工作模式:

1: acquire lock on object state
2:	while (precondition does not hold) {
3:		release lock
4:		wait until precondition might hold
5:		optionally fail if interrupted or timeout expires
6:		reacquire lock
7:	}
8: perform action
9: release lock

這段僞碼展示了基於狀態的阻塞的基本機制,值得仔細琢磨:

  • line 1:獲取保護狀態的鎖,因爲需要讀寫狀態,爲了線程安全,需要先持有鎖;
  • line 2:while循環的條件是:條件尚未滿足,之所有用循環而不是簡單的if,是當線程從阻塞狀態喚醒時,有可能有其他線程又修改了狀態使得條件不滿足;
  • line 3:在線程掛起前釋放鎖,如果不釋放鎖,其他線程就無法修改狀態以使條件發生變化;
  • line 4:線程掛起直到條件滿足,一般是其他線程修改狀態後喚醒該線程;
  • line 5:按選項,阻塞可能由於線程中斷或超時而失敗(直接返回失敗,跳出循環);
  • line 6:重新獲取鎖,因爲要回到第二行;
  • line 7:執行業務操作;
  • line 8:釋放鎖,結束同步代碼塊。

Bounded Buffer示例

接下來用一個類似ArrayBlockingQueue的容量有限隊列,展示各種同步實現方式。這個類有兩個主要操作:take和put,在隊列滿的情況下,put操作需要阻塞;反之,在隊列空的情況下,take操作要阻塞。

爲了方便各種同步方式的實現,先編寫了一個基類如下,由子類來編寫可阻塞的put&take方法:

@ThreadSafe
public abstract class BaseBoundedBuffer<V> {
	@GuardedBy("this") private final V[] buf;
	@GuardedBy("this") private int tail;
	@GuardedBy("this") private int head;
	@GuardedBy("this") 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;
	}
}

非阻塞版本

第一個實現版本是非阻塞的:

@ThreadSafe
public class GrumpyBoundedBuffer<V> extends BaseBoundedBuffer<V> {
	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();
	}
}

它使用與基類一致的鎖策略(synchronized),實現非常簡單,但是很難用。如果使用者需要實現等待狀態的功能,大體只能按以下方式:

while (true) {
	try {
		V item = buffer.take();
		// use item
		break;
	} catch (BufferEmptyException e) {
		Thread.sleep(SLEEP_GRANULARITY);
	}
}

在捕獲BufferEmptyException之後,線程不知道該等待多久,SLEEP_GANULARITY過小會導致忙等待,浪費CPU;SLEEP_GANULARITY過大,則損害程序響應性。

條件隊列(Condition Queue)

條件隊列爲”狀態條件的等待、通知“建立了一個標準的模式,它之所以稱之爲條件隊列,是因爲它將等待某個條件的線程組織成一個隊列。條件隊列總是與一個鎖關聯,因爲條件狀態需要鎖的保護;每個java對象可被視爲一個條件隊列,叫內置條件隊列,它與監視器鎖相關聯。相關的接口是Object類的wait,notify,notifyAll等方法。

下面是使用內置條件隊列實現的BoundedBuffer版本:

@ThreadSafe
public class BoundedBuffer<V> extends BaseBoundedBuffer<V> {

	// CONDITION PREDICATE: not-full (!isFull())
	// CONDITION PREDICATE: not-empty (!isEmpty())
	public BoundedBuffer(int size) { super(size); }
	
	// BLOCKS-UNTIL: not-full
	public synchronized void put(V v) throws InterruptedException {
		while (isFull())
			wait();
		doPut(v);
		notifyAll();
	}
	// BLOCKS-UNTIL: not-empty
	public synchronized V take() throws InterruptedException {
		while (isEmpty())
			wait();
		V v = doTake();
		notifyAll();
		return v;
	}
}

上面的代碼是一個生產環境可用的阻塞隊列,put方法進入方法體之後已經持有內置鎖,wait操作內部會暫時釋放鎖;doPut成功之後,ifEmpty謂詞可能發生變化,因此調用notifyAll方法來通知狀態隊列內的所有等待線程恢復執行(重新計算條件謂詞)。

條件謂詞

示例代碼中的isEmpty、isFull是”條件謂詞“,用於判斷某個狀態條件是否滿足。對開發者來說,清晰的條件謂詞,是正確使用條件隊列的關鍵,這也是內置條件隊列經常令人迷惑的原因,因爲諸如Object.wait、Object.notify這樣的方法,完全沒有表達出“狀態”,“”條件謂詞“”到底是什麼。

使用條件隊列,一定要通過文檔或註釋,講清楚謂詞邏輯,就像上面的BoundedBuffer一樣。

waiting

Object.wait方法返回,並不代表條件謂詞一定成立。首先,多個條件謂詞可能共享一個條件隊列(BoundedBuffer的isEmpty和isFull共享內置條件隊列),等待條件A的線程可能由於條件B的狀態改變而被喚醒,;其次,由於併發,在線程被喚醒到重新獲得鎖之間的間隙,其他線程可能已經修改了條件狀態。

因此在使用條件隊列的等待操作,要注意遵循以下規則:

  • 有明確的條件謂詞(判斷對象狀態是否滿足條件);
  • 在wait之前執行條件謂詞,wait之後也立即執行條件謂詞;
  • 將上面兩個操作組成一個循環體;
  • 條件謂詞涉及的狀態變量,應當被對應的鎖保護;
  • 調用wait,notify,notifyAll的時候,必須持有鎖;(Java編譯器已經保證了這一點)
  • 在條件謂詞檢查成功之後,不要立即釋放鎖,要等狀態變量使用完之後再釋放。

上面僞碼和BoundedBuffer遵循了上面的規則,用while循環來檢查條件謂詞和wait

notification

任何操作,只要修改狀態使得條件謂詞成立,必須調用條件隊列的通知方法,內置條件隊列是Object.notify或Object.notifyAll,來喚醒等待線程。BoundedBuffer的put和take都可能導致條件謂詞發生變化,因此調用了Object.notifyAll。

notify和notifyAll方法區別在於,前者讓JVM自動選擇一個等待線程喚醒,後者喚醒所有等待線程。執行notify和notifyAll需要持有鎖,而被喚醒的線程需要重新獲得鎖來執行,因此方法在調用notify和notifyAll之後要儘快釋放鎖。

BoundedBuffer不能使用notify,必須使用notifyAll,因爲條件隊列有兩個條件謂詞,如果條件謂詞C1成立,卻喚醒了等待條件謂詞C2的線程,可能導致死鎖。要使用notify必須滿足兩個條件:

  • 一致的等待者:所有的wait線程都在等待同一個條件謂詞,並且在條件滿足後,執行相同的邏輯;
  • 一進一出:一個通知只能滿足一個等待線程。

絕大多數類都不滿足以上這兩個條件,因此流行的建議是一律使用notifyAll。不過使用notifyAll確實可能導致更多的上下文切換、鎖競爭,這是安全性和可伸縮性發生矛盾的一個案例。

可以從另一個角度對通知方式進行優化:僅在條件謂詞從false變爲true的時候才發出通知,這叫做"conditional notification"。BoundedBuffer.put方法可以按這個思想進行優化如下。

public synchronized void put(V v) throws InterruptedException {
	while (isFull())
		wait()
	boolean wasEmpty = isEmpty();
	doPut(v);
	if (wasEmpty)
		notifyAll();
}

無論是使用notify,還是"conditional notification",都增加了編碼、維護的難度,僅在測試證明確實需要優化時,纔有必要採取這樣的手段。

Condition對象

就像Lock類型是內置鎖的一般化,Condition類是內置條件隊列的一般化,提供了更豐富的功能。對於內置條件隊列,每個鎖只能有一個隊列,如果有多個條件謂詞,只能共享這個隊列,影響性能。

Lock.newCondition方法創建與該鎖關聯的Condition對象,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();
}

一個Lock可以創建多個關聯的Condition對象,Condition隊列的公平性由鎖的公平性來決定,Condition的await,signal,signalAll方法分別相當於內置條件隊列的wait,notify,notifyAll方法。

Condition也是Object,也支持wait,notify,notifyAll方法,使用時要注意。

下面用Condition來改進BoundedBuffer如下(省略了take方法):

@ThreadSafe
public class ConditionBoundedBuffer<T> {
	protected final Lock lock = new ReentrantLock();
	
	// CONDITION PREDICATE: notFull (count < items.length)
	private final Condition notFull = lock.newCondition();
	
	// CONDITION PREDICATE: notEmpty (count > 0)
	private final Condition notEmpty = lock.newCondition();
	
	@GuardedBy("lock") private final T[] items = (T[]) new Object[BUFFER_SIZE];
	@GuardedBy("lock") private int tail, head, count;
	
	// BLOCKS-UNTIL: 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();
		} finally {
			lock.unlock();
		}
	}
}

ConditionBoundedBuffer與BoundedBuffer的功能完全一致,它通過兩個Condition對象分別對應兩個條件謂詞,滿足了用signal取代signalAll的條件。此外,通過變量命名(notFull&notEmpty),表達出Condition對象代表的條件謂詞,比使用內置條件隊列有更好的代碼可讀性、可維護性。

與Java監視器鎖被推薦使用不同,內置條件隊列由於其模糊性,不被推薦使用,建議用Condition來取代。

解密同步器(AQS)

ReentrantLock和Semaphore有很多的共同點,它們都起到類似”門禁“的作用,在同一時刻只允許有限數量的線程通過;而且二者都提供不可中斷、可中斷、限時的加鎖(獲取)操作,甚至都可以指定公平或不公平策略。這樣一來,你或許認爲Semaphore是基於ReentrantLock實現的,或者反過來ReentrantLock是基於二元Semaphore實現的;實際上,這都是完全可行的,下面的SemaphoreOnLock展示瞭如何通過ReentrantLock實現Semaphore。

@ThreadSafe
public class SemaphoreOnLock {
	private final Lock lock = new ReentrantLock();
	// CONDITION PREDICATE: permitsAvailable (permits > 0)
	private final Condition permitsAvailable = lock.newCondition();
	@GuardedBy("lock") private int permits;
	
	SemaphoreOnLock(int initialPermits) {
		lock.lock();
		try {
			permits = initialPermits;
		} finally {
			lock.unlock();
		}
	}
	// BLOCKS-UNTIL: permitsAvailable
	public void acquire() throws InterruptedException {
		lock.lock();
		try {
			while (permits <= 0)
				permitsAvailable.await();
			--permits;
		} finally {
			lock.unlock();
		}
	}
	public void release() {
		lock.lock();
		try {
			++permits;
			permitsAvailable.signal();
		} finally {
			lock.unlock();
		}
	}
}

不過真實的情況是:ReentrantLock和Semaphore都有一個叫做AbstractQueuedSynchronizer(AQS)公共基類。AQS是一個用來構建鎖和同步器的框架,它有廣泛的應用,CountDownLatch、ReentrantReadWriteLock也是基於AQS實現的;很多第三方庫也基於AQS來實現滿足特定需求的同步器。

AQS概述

AQS實現了同步器的通用功能,比如它內含一個FIFO阻塞線程隊列,處理了線程阻塞和恢復的細節等;具體同步器實現只需要關注什麼情況下線程通過、什麼情況下阻塞、什麼情況下喚醒阻塞的線程。AQS是一個高度抽象的類,直接理解它比較困難,我們可以通過一些具體的同步器來剖析它的工作方式。

基於AQS同步器的基本操作是”acquire“和”release“,線程調用acquire試圖佔據(或者通過)這個同步器,它是一個基於條件狀態的操作,在條件不滿足時可能會阻塞。 不同的同步器,acquire含義有所不同,對CountDownLatch來說,acquire成功的條件是”latch到達它的終結狀態”;而對與FutureTask,acquire意味着“任務到達完成狀態”。release是一個非阻塞操作,它修改狀態使的阻塞於acquire操作之上的線程得以恢復運行。

AQS內部管理一個int類型的狀態值,所有阻塞和同步都是圍繞這個狀態進行的。AQS提供了操作狀態的方法:getState,setState和compareAndSetState。狀態所代表的含義由具體同步器來決定,比如ReentrantLock的狀態值代表當前線程加鎖的次數,而Semaphore的狀態值代表剩餘的許可數量,FutureTask的狀態則是任務的狀態(未開始、運行中、完成、取消)。 當然同步器也可以管理額外的狀態,比如ReentrantLock會維護當前持有鎖的線程,以區分重入的加鎖請求。

AQS也是基於狀態條件的同步機制,只不它使用更底層的技術(CAS指令、volatile、線程操作)來實現的。本質上,我們只要擁有一個狀態同步器,我們就可以用它實現各式各樣的狀態同步器。而AQS就可以被看做一個狀態同步器。

acquire操作

AQS的acquire操作的基本形式如下:

boolean acquire() throws InterruptedException {
	while (state does not permit acquire) {
		if (blocking acquisition requested) {
			enqueue current thread if not already queued
			block current thread
		}
		else
			return failure
	}
	possibly update synchronization state
	dequeue thread if it was queued
	return success
}

上面的代碼在形式上與條件隊列的wait操作有點類似,包含的步驟如下:

  • 判斷當前的狀態是否允許acquire成功,由於需要反覆計算該判斷,所以用while包裹
  • 如果不符合條件
    • 再看本次acquire是否可阻塞,可阻塞的話將當前線程加入AQS的阻塞隊列,並掛起;當其他線程修改狀態後,會喚醒該線程,回到while循環,重新競爭該同步器;
    • 如果是非阻塞操作,失敗返回
  • 如果符合條件
    • 可能需要修改狀態,獨佔該同步器;
    • 如果當前線程在阻塞隊列裏,刪除它;
    • 返回成功

acquire成功的條件狀態是什麼?是否某些狀態下允許多個線程同時acquire成功?都由具體的同步器來決定,但它們的工作模式大體如此,我們應當記住這個模式並在學習具體同步器時不斷對照這個模式。

release操作

AQS的release操作,表示線程要釋放同步器,它不會導致阻塞:

void release() {
	update synchronization state
	if (new state may permit a blocked thread to acquire)
		unblock one or more queued threads
}

release操作首先修改AQS的狀態,如果狀態變化使得某些阻塞在acquire操作上的線程有機會成功,那麼喚醒這些線程。

繼承AQS

AQS爲子類提供了getState, setState和compareAndSetState方法來訪問狀態值,並定義了一些可重寫的方法。

如果要實現一個完全互斥的(類似ReentrantLock)的同步器,需要覆蓋以下幾個方法:

  • protected boolean tryAcquire(int arg):嘗試佔據AQS,成功返回true,arg參數代表修改狀態的量,它的含義由子類解釋;
  • protected boolean tryRelease(int arg):嘗試釋放AQS;

如果該是一個支持Condition的Lock,還需要實現:

  • protected boolean isHeldExclusively():該同步器是否被當前線程獨佔?

如果實現可共享的同步器,覆蓋以下方法:

  • protected int tryAcquireShared(int arg):嘗試以共享方式佔據AQS,返回值負數代表失敗,0代表成功且後續tryAcquireShared不會成功,>0代表成功且後續tryAcquireShared仍可能成功;這個特性使得AQS可支持有限共享的同步器(Semaphore);
  • protected int tryReleaseShared(int arg):嘗試以共享方式釋放AQS;

線程調用AQS的acquireXXX和releaseXXX方法時,後者會調用上面這些方法,依據返回值來決定來執行同步邏輯,如阻塞、喚醒等。

基於AQS的SimpleLatch

現在基於AQS實現一個簡單的同步器OneShotLatch(二元狀態的CountdownLatch),它的初始狀態是關閉,調用await方法會阻塞,直到其他線程調用signal打開它。

@ThreadSafe
public class OneShotLatch {
	private final Sync sync = new Sync();
	
	public void signal() { 
		sync.releaseShared(0); 
	}
	public void await() throws InterruptedException {
		sync.acquireSharedInterruptibly(0);
	}
	private class Sync extends AbstractQueuedSynchronizer {
		protected int tryAcquireShared(int ignored) {
			// Succeed if latch is open (state == 1), else fail
			return (getState() == 1) ? 1 : -1;
		}
		protected boolean tryReleaseShared(int ignored) {
			setState(1); // Latch is now open
			return true; // Other threads may now be able to acquire
		}
	}
}

OneShotLatch沒有繼承自AQS,而是通過一個內部類來繼承AQS,這樣做是值得推薦的:首先AQS提供的功能並不是所有的同步器都需要,將AQS封閉在同步器內,能夠屏蔽不需要的功能,避免被誤用;其次AQS的方法語義過於抽象,具體同步器可以重新定義更加契合其功能的操作方法給使用者。實際上,java所有的同步器都是用這樣的模式來使用AQS的。

OneShotLatch用state1代表打開,state0代表關閉,默認就是關閉;OneShotLatch是可共享的,所以實現了tryAcquireShared和tryReleaseShared;tryAcquireShared只要state==1即成功,它不獨佔同步器,所以該操作不修改state;tryReleaseShared將state改爲1,該方法總會成功,因爲Latch無論打開多少次,都保持打開狀態。

OneShotLatch的await調用了sync.acquireSharedInterruptibly(參數無意義),後者會調用tryAcquireShared,;signal調用sync.releaseShared(參數無意義),後者會調用tryReleaseShared。

Sync.tryAcquireShared的實現,並不需要擔心併發場景導致程序錯誤,即使tryAcquireShared返回失敗的一刻,另外一個線程修改了state(),同步器也不會出錯。如果tryAcquireShared返回false,AQS會嘗試將當前線程加入等待隊列,創建一個阻塞線程Node;在插入隊列之前,會再調用一次tryAcquireShared,此後,如果有其他線程調用了tryReleaseShared,AQS保證此線程Node會得到喚醒機會。有興趣的同學可以看看AQS源碼,它真正的複雜之處其實是內部的阻塞線程隊列。

AQS in java.util.concurrent

這一部分我們來介紹一下,java.util.concurrent包裏面的同步器是如何使用AQS來實現其功能的;不過我們不會深入它的源碼細節,僅限於原理。

ReentrantLock

ReentrantLock僅支持互斥的工作方式,所以它內部實現了tryAcquire, tryRelease, 和isHeldExclusively(支持Condition);tryAcquire的工作方式類似以下僞代碼:

protected boolean tryAcquire(int ignored) {
	final Thread current = Thread.currentThread();
	int c = getState();
	if (c == 0) {
		if (compareAndSetState(0, 1)) {
			owner = current;
			return true;
		}
	} else if (current == owner) {
		setState(c+1);
		return true;
	}
	return false;
}

AQS的狀態爲0時,表示沒有線程佔據這個鎖,ReentrantLock通過compareAndSetState來加鎖(compareAndSetState是一個原子操作,下一章介紹),加鎖成功記住鎖的擁有者(owner);如果狀態爲非0,有兩種情況,一種是owner就是當前線程,表明這是一次重入加鎖,將state+1即可(此處無併發風險,不需要使用原子操作);否則返回false表示加鎖失敗。

Semaphore

Semaphore是用可共享的方式工作,它用AQS的狀態來表示當前剩餘的許可數量,它內部實現tryAcquireShared類似以下僞代碼:

protected int tryAcquireShared(int acquires) {
	while (true) {
		int available = getState();
		int remaining = available - acquires;
		if (remaining < 0
			|| compareAndSetState(available, remaining))
			return remaining;
	}
}

tryAcquireShared計算剩餘許可數量與需求的差值,若不足(remaining<0)返回失敗;否則使用原子compareAndSetState來更新狀態值;如果compareAndSetState由於併發失敗,那就重試(while)。

tryReleaseShared的實現類似以下僞代碼,使用compareAndSetState增加狀態值,直至成功爲止:

protected boolean tryReleaseShared(int releases) {
	while (true) {
		int p = getState();
		if (compareAndSetState(p, p + releases))
			return true;
	}
}

CountDownLatch

CountDownLatch的tryReleaseShared類似Semaphore,tryAcquireShared類似OneShotLatch,不再贅述。

總結

如果你想構建一個基於狀態的同步機制,最好的方式是直接使用現有的類,如Semaphore、BlockingQueue或CountDownLatch;如果這些類不滿足需求,那麼可以考慮使用條件隊列(Condition Queue);如果還不滿足需求,可基於AbstractQueuedSynchronizer(AQS)構建自定義的同步器,AQS實現了一個高度抽象的基於狀態的線程同步機制(阻塞、喚醒),java併發庫內的同步器基本都是基於AQS實現的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章