AQS初體驗

NinWoo發表於2019-07-25

AQS初體驗

AQS是AbstractQueuedSynchronizer的簡稱。AQS提供了一種實現阻塞鎖和一系列依賴FIFO等待佇列的同步器的框架。所謂框架,AQS使用了模板方法的設計模式,為我們遮蔽了諸如內部佇列等一系列複雜的操作,讓我們專注於對鎖相關功能的實現。

獲取鎖

既然涉及到鎖競爭的問題,必然需要一個標誌位來表示鎖的狀態,AQS中提供了state這樣一個成員變數,為了安全的操作state,我們需要使用原子操作。將state從0修改為1就代表這個執行緒已經持有了這把鎖。
但競爭鎖的執行緒絕對不會只是一個,其他未競爭到鎖的執行緒該如何進行處理?
第一個答案可能是重試,重試雖好,但是可不能貪杯,如果競爭很嚴重,無數的執行緒在不斷的重新嘗試獲取鎖,我們的CPU早晚會吃不消。
第二個比較好的方式就是排隊,持有鎖的執行緒釋放鎖之後,通知下一個執行緒去獲取鎖,避免了不必要的CPU損失。但是值得注意的是,即使是從佇列中被喚醒的執行緒去獲取鎖也依舊可能獲取不到的,因為無時無刻都有新加入的執行緒來競爭鎖。
AQS實際上就是使用了雙端佇列來解決了這個問題的。

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

tryAcquire()如果失敗將執行acquireQueued()中的addWaiter()方法,即嘗試加入等待佇列。這個等待佇列使用了雙端佇列進行實現,在AQS中定義了一個Node的資料結構,AQS中維護著head和tail兩個成員變數。
在單執行緒中插入佇列尾部很簡單,只需要將原來的tail的next指向新插入的節點,並且將tail重新設定為新插入的節點。但是在多執行緒環境中,很有可能發生多個執行緒同時插入尾部的現象,而上述的插入過程不具有原子性,同時插入的過程必將出現多個操作順序的混亂,最終導致等待佇列的tail節點
AQS在插入tail節點時使用原子操作來保證了插入的可靠。

private Node addWaiter(Node mode) {
    Node node = new Node(Thread.currentThread(), mode);
    // Try the fast path of enq; backup to full enq on failure
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    enq(node);
    return node;
}

插入成功的直接返回了node,而沒有插入成功的則執行了enq()函式,在enq()中使用了CAS進行插入。

private Node enq(final Node node) {
    for (;;) {
        Node t = tail;
        if (t == null) { // Must initialize
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}

經歷這個CAS插入,最後全部的節點都將被插入到佇列尾部。

現在,沒有獲取到鎖的執行緒已經被放進佇列了,但是放入佇列也代表著我們可以忘了初心。我們的目標是獲取鎖,而不是進入佇列。acquireQueued()就在嘗試為我們獲取鎖。

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            final Node p = node.predecessor();
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

簡單說就是,檢查自己是不是head節點的下一個節點,如果是的話,嘗試獲取獲取鎖;如果不是的話,將使用LockSupport的park方法阻塞當前執行緒,避免造成CPU的浪費。

釋放鎖

釋放鎖的過程可以分成兩大部分:

  1. 恢復AQS的狀態為無鎖狀態
  2. 喚醒等待佇列中下一個等待的節點

在第一個過程中,沒有排在隊頭的節點都已經被阻塞了,而喚醒的時機就是前一個節點已經釋放鎖,所以可以說這個等待佇列,實際上是一個喚醒鏈。

public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            // 使用unpark喚醒下一個執行緒
            unparkSuccessor(h);
        return true;
    }
    return false;
}

總結

AQS為我們提供了:

  • status 狀態同步標示
  • Node雙端佇列 儲存競爭鎖執行緒
  • 基於Node雙端佇列的執行緒喚醒機制

我覺得AQS精華在於,將原來N個執行緒併發競爭鎖降低為1+M(新加入)個。在我們自己實現類似的資源競爭演算法中,也可以通過加入佇列來降低競爭的併發度,降低CPU的負載壓力。