動態

詳情 返回 返回

小小的引用計數,大大的性能考究 - 動態 詳情

本文基於 Netty 4.1.56.Final 版本進行討論

在上篇文章《聊一聊 Netty 數據搬運工 ByteBuf 體系的設計與實現》 中,筆者詳細地為大家介紹了 ByteBuf 整個體系的設計,其中筆者覺得 Netty 對於引用計數的設計非常精彩,因此將這部分設計內容專門獨立出來。


Netty 為 ByteBuf 引入了引用計數的機制,在 ByteBuf 的整個設計體系中,所有的 ByteBuf 都會繼承一個抽象類 AbstractReferenceCountedByteBuf , 它是對接口 ReferenceCounted 的實現。

image

public interface ReferenceCounted {
     int refCnt();
     ReferenceCounted retain();
     ReferenceCounted retain(int increment);
     boolean release();
     boolean release(int decrement);
}

每個 ByteBuf 的內部都維護了一個叫做 refCnt 的引用計數,我們可以通過 refCnt() 方法來獲取 ByteBuf 當前的引用計數 refCnt。當 ByteBuf 在其他上下文中被引用的時候,我們需要通過 retain() 方法將 ByteBuf 的引用計數加 1。另外我們也可以通過 retain(int increment) 方法來指定 refCnt 增加的大小(increment)。

有對 ByteBuf 的引用那麼就有對 ByteBuf 的釋放,每當我們使用完 ByteBuf 的時候就需要手動調用 release() 方法將 ByteBuf 的引用計數減 1 。當引用計數 refCnt 變成 0 的時候,Netty 就會通過 deallocate 方法來釋放 ByteBuf 所引用的內存資源。這時 release() 方法會返回 true , 如果 refCnt 還不為 0 ,那麼就返回 false 。同樣我們也可以通過 release(int decrement) 方法來指定 refCnt 減少多少(decrement)。

1. 為什麼要引入引用計數

”在其他上下文中引用 ByteBuf “ 是什麼意思呢 ? 比如我們在線程 1 中創建了一個 ByteBuf,然後將這個 ByteBuf 丟給線程 2 進行處理,線程 2 又可能丟給線程 3, 而每個線程都有自己的上下文處理邏輯,比如對 ByteBuf 的處理,釋放等操作。這樣就使得 ByteBuf 在事實上形成了在多個線程上下文中被共享的情況。

面對這種情況我們就很難在一個單獨的線程上下文中判斷一個 ByteBuf 該不該被釋放,比如線程 1 準備釋放 ByteBuf 了,但是它可能正在被其他線程使用。所以這也是 Netty 為 ByteBuf 引入引用計數的重要原因,每當引用一次 ByteBuf 的時候就需要通過 retain() 方法將引用計數加 1, release() 釋放的時候將引用計數減 1 ,當引用計數為 0 了,説明已經沒有其他上下文引用 ByteBuf 了,這時 Netty 就可以釋放它了。

另外相比於 JDK DirectByteBuffer 需要依賴 GC 機制來釋放其背後引用的 Native Memory , Netty 更傾向於手動及時釋放 DirectByteBuf 。因為 JDK DirectByteBuffer 的釋放需要等到 GC 發生,由於 DirectByteBuffer 的對象實例所佔的 JVM 堆內存太小了,所以一時很難觸發 GC , 這就導致被引用的 Native Memory 的釋放有了一定的延遲,嚴重的情況會越積越多,導致 OOM 。而且也會導致進程中對 DirectByteBuffer 的申請操作有非常大的延遲。

而 Netty 為了避免這些情況的出現,選擇在每次使用完畢之後手動釋放 Native Memory ,但是不依賴 JVM 的話,總會有內存泄露的情況,比如在使用完了 ByteBuf 卻忘記調用 release() 方法來釋放。

所以為了檢測內存泄露的發生,這也是 Netty 為 ByteBuf 引入了引用計數的另一個原因,當 ByteBuf 不再被引用的時候,也就是沒有任何強引用或者軟引用的時候,如果此時發生 GC , 那麼這個 ByteBuf 實例(位於 JVM 堆中)就需要被回收了,這時 Netty 就會檢查這個 ByteBuf 的引用計數是否為 0 , 如果不為 0 ,説明我們忘記調用 release() 釋放了,近而判斷出這個 ByteBuf 發生了內存泄露。

在探測到內存泄露發生之後,後續 Netty 就會通過 reportLeak() 將內存泄露的相關信息以 error 的日誌級別輸出到日誌中。

看到這裏,大家可能不禁要問,不就是引入了一個小小的引用計數嘛,這有何難 ? 值得這裏大書特書嗎 ? 不就是在創建 ByteBuf 的時候將引用計數 refCnt 初始化為 1 , 每次在其他上下文引用的時候將 refCnt 加 1, 每次釋放的時候再將 refCnt 減 1 嗎 ?減到 0 的時候就釋放 Native Memory ,太簡單了吧~~

事實上 Netty 對引用計數的設計非常講究,絕非如此簡單,甚至有些複雜,其背後隱藏着大大的性能考究以及對複雜併發問題的全面考慮,在性能與線程安全問題之間的反覆權衡。

2. 引用計數的最初設計

所以為了理清關於引用計數的整個設計脈絡,我們需要將版本回退到最初的起點 —— 4.1.16.Final 版本,來看一下原始的設計。

public abstract class AbstractReferenceCountedByteBuf extends AbstractByteBuf {
    // 原子更新 refCnt 的 Updater
    private static final AtomicIntegerFieldUpdater<AbstractReferenceCountedByteBuf> refCntUpdater =
            AtomicIntegerFieldUpdater.newUpdater(AbstractReferenceCountedByteBuf.class, "refCnt");
    // 引用計數,初始化為 1
    private volatile int refCnt;

    protected AbstractReferenceCountedByteBuf(int maxCapacity) {
        super(maxCapacity);
        // 引用計數初始化為 1
        refCntUpdater.set(this, 1);
    }

    // 引用計數增加 increment
    private ByteBuf retain0(int increment) {
        for (;;) {
            int refCnt = this.refCnt;
            // 每次 retain 的時候對引用計數加 1
            final int nextCnt = refCnt + increment;

            // Ensure we not resurrect (which means the refCnt was 0) and also that we encountered an overflow.
            if (nextCnt <= increment) {
                // 如果 refCnt 已經為 0 或者發生溢出,則拋異常
                throw new IllegalReferenceCountException(refCnt, increment);
            }
            // CAS 更新 refCnt
            if (refCntUpdater.compareAndSet(this, refCnt, nextCnt)) {
                break;
            }
        }
        return this;
    }

    // 引用計數減少 decrement
    private boolean release0(int decrement) {
        for (;;) {
            int refCnt = this.refCnt;
            if (refCnt < decrement) {
                // 引用的次數必須和釋放的次數相等對應
                throw new IllegalReferenceCountException(refCnt, -decrement);
            }
            // 每次 release 引用計數減 1 
            // CAS 更新 refCnt
            if (refCntUpdater.compareAndSet(this, refCnt, refCnt - decrement)) {
                if (refCnt == decrement) {
                    // 如果引用計數為 0 ,則釋放 Native Memory,並返回 true
                    deallocate();
                    return true;
                }
                // 引用計數不為 0 ,返回 false
                return false;
            }
        }
    }
}

在 4.1.16.Final 之前的版本設計中,確實和我們當初想象的一樣,非常簡單,創建 ByteBuf 的時候將 refCnt 初始化為 1。 每次引用 retain 的時候將引用計數加 1 ,每次釋放 release 的時候將引用計數減 1,在一個 for 循環中通過 CAS 替換。當引用計數為 0 的時候,通過 deallocate() 釋放 Native Memory。

3. 引入指令級別上的優化

4.1.16.Final 的設計簡潔清晰,在我們看來完全沒有任何問題,但 Netty 對性能的考究完全沒有因此止步,由於在 x86 架構下 XADD 指令的性能要高於 CMPXCHG 指令, compareAndSet 方法底層是通過 CMPXCHG 指令實現的,而 getAndAdd 方法底層是 XADD 指令。

所以在對性能極致的追求下,Netty 在 4.1.17.Final 版本中用 getAndAdd 方法來替換 compareAndSet 方法。

public abstract class AbstractReferenceCountedByteBuf extends AbstractByteBuf {

    private volatile int refCnt;

    protected AbstractReferenceCountedByteBuf(int maxCapacity) {
        super(maxCapacity);
        // 引用計數在初始的時候還是為 1 
        refCntUpdater.set(this, 1);
    }

    private ByteBuf retain0(final int increment) {
        // 相比於 compareAndSet 的實現,這裏將 for 循環去掉
        // 並且每次是先對 refCnt 增加計數 increment
        int oldRef = refCntUpdater.getAndAdd(this, increment);
        // 增加完 refCnt 計數之後才去判斷異常情況
        if (oldRef <= 0 || oldRef + increment < oldRef) {
            // Ensure we don't resurrect (which means the refCnt was 0) and also that we encountered an overflow.
            // 如果原來的 refCnt 已經為 0 或者 refCnt 溢出,則對 refCnt 進行回退,並拋出異常
            refCntUpdater.getAndAdd(this, -increment);
            throw new IllegalReferenceCountException(oldRef, increment);
        }
        return this;
    }

    private boolean release0(int decrement) {
        // 先對 refCnt 減少計數 decrement
        int oldRef = refCntUpdater.getAndAdd(this, -decrement);
        // 如果 refCnt 已經為 0 則進行 Native Memory 的釋放
        if (oldRef == decrement) {
            deallocate();
            return true;
        } else if (oldRef < decrement || oldRef - decrement > oldRef) {
            // 如果釋放次數大於 retain 次數 或者 refCnt 出現下溢
            // 則對 refCnt 進行回退,並拋出異常
            refCntUpdater.getAndAdd(this, decrement);
            throw new IllegalReferenceCountException(oldRef, decrement);
        }
        return false;
    }
}

在 4.1.16.Final 版本的實現中,Netty 是在一個 for 循環中,先對 retain 和 release 的異常情況進行校驗,之後再通過 CAS 更新 refCnt。否則直接拋出 IllegalReferenceCountException。採用的是一種悲觀更新引用計數的策略。

而在 4.1.17.Final 版本的實現中 , Netty 去掉了 for 循環,正好和 compareAndSet 的實現相反,而是先通過 getAndAdd 更新 refCnt,更新之後再來判斷相關的異常情況,如果發現有異常,則進行回退,並拋出 IllegalReferenceCountException。採用的是一種樂觀更新引用計數的策略。

比如在 retain 增加引用計數的時候,先對 refCnt 增加計數 increment,然後判斷原來的引用計數 oldRef 是否已經為 0 或者 refCnt 是否發生溢出,如果是,則需要對 refCnt 的值進行回退,並拋異常。

在 release 減少引用計數的時候,先對 refCnt 減少計數 decrement,然後判斷 release 的次數是否大於 retain 的次數防止 over-release ,以及 refCnt 是否發生下溢,如果是,則對 refCnt 的值進行回退,並拋異常。

4. 併發安全問題的引入

在 4.1.17.Final 版本的設計中,我們對引用計數的 retain 以及 release 操作都要比 4.1.16.Final 版本的性能要高,雖然現在性能是高了,但是同時引入了新的併發問題。

讓我們先假設一個這樣的場景,現在有一個 ByteBuf,它當前的 refCnt = 1 ,線程 1 對這個 ByteBuf 執行 release() 操作。

image

在 4.1.17.Final 的實現中,Netty 會首先通過 getAndAdd 將 refCnt 更新為 0 ,然後接着調用 deallocate() 方法釋放 Native Memory ,很簡單也很清晰是吧,讓我們再加點併發複雜度上去。

現在我們在上圖步驟一與步驟二之間插入一個線程 2 , 線程 2 對這個 ByteBuf 併發執行 retain() 方法。

image

在 4.1.17.Final 的實現中,線程 2 首先通過 getAndAdd 將 refCnt 從 0 更新為 1,緊接着線程 2 就會發現 refCnt 原來的值 oldRef 是等於 0 的,也就是説線程 2 在調用 retain() 的時候,ByteBuf 的引用計數已經為 0 了,並且線程 1 已經開始準備釋放 Native Memory 了。

所以線程 2 需要再次調用 getAndAdd 方法將 refCnt 的值進行回退,從 1 再次回退到 0 ,最後拋出 IllegalReferenceCountException。這樣的結果顯然是正確的,也是符合語義的。畢竟不能對一個引用計數為 0 的 ByteBuf 調用 retain()

現在看來一切風平浪靜,都是按照我們的設想有條不紊的進行,我們不妨再加點併發複雜度上去。在上圖步驟 1.1 與步驟 1.2 之間在插入一個線程 3 , 線程 3 對這個 ByteBuf 再次併發執行 retain() 方法。

image

由於引用計數的更新(步驟 1.1)與引用計數的回退(步驟 1.2)這兩個操作並不是一個原子操作,如果在這兩個操作之間不巧插入了一個線程 3 ,線程 3 在併發執行 retain() 方法的時候,首先會通過 getAndAdd 將引用計數 refCnt 從 1 增加到 2 。

注意,此時線程 2 還沒來得及回退 refCnt , 所以線程 3 此時看到的 refCnt 是 1 而不是 0

由於此時線程 3 看到的 oldRef 是 1 ,所以線程 3 成功調用 retain() 方法將 ByteBuf 的引用計數增加到了 2 ,並且不會回退也不會拋出異常。在線程 3 看來此時的 ByteBuf 完完全全是一個正常可以被使用的 ByteBuf。

緊接着線程 1 開始執行步驟 2 —— deallocate() 方法釋放 Native Memory,此後線程 3 在訪問這個 ByteBuf 的時候就有問題了,因為 Native Memory 已經被線程1 釋放了。

5. 在性能與併發安全之間的權衡

接下來 Netty 就需要在性能與併發安全之間進行權衡了,現在有兩個選擇,第一個選擇是直接回滾到 4.1.16.Final 版本,放棄 XADD 指令帶來的性能提升,之前的設計中採用的 CMPXCHG 指令雖然性能相對差一些,但是不會出現上述的併發安全問題。

因為 Netty 是在一個 for 循環中採用悲觀的策略來更新引用計數,先是判斷異常情況,然後在通過 CAS 來更新 refCnt。即使多個線程看到了 refCnt 的中間狀態也沒關係,因為接下來進行的 CAS 也會跟着失敗。

比如上邊例子中的線程 1 對 ByteBuf 進行 release 的時候,在線程 1 執行 CAS 將 refCnt 替換為 0 之前的這個間隙中,refCnt 是 1 ,如果在這個間隙中,線程 2 併發執行 retain 方法,此時線程 2 看到的 refCnt 確實為 1 ,它是一箇中間狀態,線程 2 執行 CAS 將 refCnt 替換為 2。

此時線程 1 執行 CAS 就會失敗,但會在下一輪 for 循環中將 refCnt 替換為 1,這是完全符合引用計數語義的。

另外一種情況是線程 1 已經執行完 CAS 將 refCnt 替換為 0 ,這時候線程 2 去 retain ,由於 4.1.16.Final 版本中的設計是先檢查異常後 CAS 替換,所以線程 2 首先會在 retain 方法中檢查到 ByteBuf 的 refCnt 已經為 0 ,直接拋出 IllegalReferenceCountException,並不會執行 CAS 。這同樣符合引用計數的語義,畢竟不能對一個引用計數已經為 0 的 ByteBuf 執行任何訪問操作。

第二個選擇是既要保留 XADD 指令帶來的性能提升,也要解決 4.1.17.Final 版本中引入的併發安全問題。毫無疑問,Netty 最終選擇的是這種方案。

在介紹 Netty 的精彩設計之前,我想我們還是應該在回顧下這個併發安全問題出現的根本原因是什麼 ?

在 4.1.17.Final 版本的設計中,Netty 首先是通過 getAndAdd 方法先對 refCnt 的值進行更新,如果出現異常情況,在進行回滾。而更新,回滾的這兩個操作並不是原子的,之間的中間狀態會被其他線程看到。

比如,線程 2 看到了線程 1 的中間狀態(refCnt = 0),於是將引用計數加到 1
, 在線程 2 進行回滾之前,這期間的中間狀態(refCnt = 1,oldRef = 0)又被線程 3 看到了,於是線程 3 將引用計數增加到了 2 (refCnt = 2,oldRef = 1)。 此時線程 3 覺得這是一種正常的狀態,但在線程 1 看來 refCnt 的值已經是 0 了,後續線程 1 就會釋放 Native Memory ,這就出問題了。

問題的根本原因其實是這裏的 refCnt 不同的值均代表不同的語義,比如對於線程 1 來説,通過 release 將 refCnt 減到了 0 ,這裏的語義是 ByteBuf 已經不在被引用了,可以釋放 Native Memory 。

隨後線程 2 通過 retain 將 refCnt 加到了 1 ,這就把 ByteBuf 語義改變了,表示該 ByteBuf 在線程 2 中被引用了一次。最後線程 3 又通過 retain 將 refCnt 加到了 2 ,再一次改變了 ByteBuf 的語義。

只要用到 XADD 指令來實現引用計數的更新,那麼就不可避免的出現上述併發更新 refCnt 的情況,關鍵是 refCnt 的值每一次被其他線程併發修改之後,ByteBuf 的語義就變了。這才是 4.1.17.Final 版本中的關鍵問題所在。

如果 Netty 想在同時享受 XADD 指令帶來的性能提升之外,又要解決上述提到的併發安全問題,就要重新對引用計數進行設計。首先我們的要求是繼續採用 XADD 指令來實現引用計數的更新,但這就會帶來多線程併發修改所引起的 ByteBuf 語義改變。

既然多線程併發修改無法避免,那麼我們能不能重新設計一下引用計數,讓 ByteBuf 語義無論多線程怎麼修改,它的語義始終保持不變。也就是説只要線程 1 將 refCnt 減到了 0 ,那麼無論線程 2 和線程 3 怎麼併發修改 refCnt,怎麼增加 refCnt 的值,refCnt 等於 0 的這個語義始終保持不變呢 ?

6. 奇偶設計的引入

這裏 Netty 有一個極奇巧妙精彩的設計,引用計數的設計不再是邏輯意義上的 0 , 1 , 2 , 3 .....,而是分為了兩大類,要麼是偶數,要麼是奇數。

  • 偶數代表的語義是 ByteBuf 的 refCnt 不為 0 ,也就是説只要一個 ByteBuf 還在被引用,那麼它的 refCnt 就是一個偶數,具體被引用多少次,可以通過 refCnt >>> 1 來獲取。
  • 奇數代表的語義是 ByteBuf 的 refCnt 等於 0 ,只要一個 ByteBuf 已經沒有任何地方引用它了,那麼它的 refCnt 就是一個奇數,其背後引用的 Native Memory 隨後就會被釋放。

ByteBuf 在初始化的時候,refCnt 不在是 1 而是被初始化為 2 (偶數),每次 retain 的時候不在是對 refCnt 加 1 而是加 2 (偶數步長),每次 release 的時候不再是對 refCnt 減 1 而是減 2 (同樣是偶數步長)。這樣一來,只要一個 ByteBuf 的引用計數為偶數,那麼多線程無論怎麼併發調用 retain 方法,引用計數還是一個偶數,語義仍然保持不變。

   public final int initialValue() {
        return 2;
    }

當一個 ByteBuf 被 release 到沒有任何引用計數的時候,Netty 不在將 refCnt 設置為 0 而是設置為 1 (奇數),對於一個值為奇數的 refCnt,無論多線程怎麼併發調用 retain 方法和 release 方法,引用計數還是一個奇數,ByteBuf 引用計數為 0 的這層語義一直會保持不變。

我們還是以上圖中所展示的併發安全問題為例,在新的引用計數設計方案中,首先線程 1 對 ByteBuf 執行 release 方法,Netty 會將 refCnt 設置為 1 (奇數)。

線程 2 併發調用 retain 方法,通過 getAndAdd 將 refCnt 從 1 加到了 3 ,refCnt 仍然是一個奇數,按照奇數所表示的語義 —— ByteBuf 引用計數已經是 0 了,那麼線程 2 就會在 retain 方法中拋出 IllegalReferenceCountException。

線程 3 併發調用 retain 方法,通過 getAndAdd 將 refCnt 從 3 加到了 5,看到了沒 ,在新方案的設計中,無論多線程怎麼併發執行 retain 方法,refCnt 的值一直都只會是一個奇數,隨後線程 3 在 retain 方法中拋出 IllegalReferenceCountException。這完全符合引用計數的併發語義。

這個新的引用計數設計方案是在 4.1.32.Final 版本引入進來的,僅僅通過一個奇偶設計,就非常巧妙的解決了 4.1.17.Final 版本中存在的併發安全問題。現在新方案的核心設計要素我們已經清楚了,那麼接下來筆者將以 4.1.56.Final 版本來為大家繼續介紹下新方案的實現細節。

Netty 中的 ByteBuf 全部繼承於 AbstractReferenceCountedByteBuf,在這個類中實現了所有對 ByteBuf 引用計數的操作,對於 ReferenceCounted 接口的實現就在這裏。

public abstract class AbstractReferenceCountedByteBuf extends AbstractByteBuf {
    // 獲取 refCnt 字段在 ByteBuf 對象內存中的偏移
    // 後續通過 Unsafe 對 refCnt 進行操作
    private static final long REFCNT_FIELD_OFFSET =
            ReferenceCountUpdater.getUnsafeOffset(AbstractReferenceCountedByteBuf.class, "refCnt");

    // 獲取 refCnt 字段 的 AtomicFieldUpdater
    // 後續通過 AtomicFieldUpdater 來操作 refCnt 字段
    private static final AtomicIntegerFieldUpdater<AbstractReferenceCountedByteBuf> AIF_UPDATER =
            AtomicIntegerFieldUpdater.newUpdater(AbstractReferenceCountedByteBuf.class, "refCnt");

    // 創建 ReferenceCountUpdater,對於引用計數的所有操作最終都會代理到這個類中
    private static final ReferenceCountUpdater<AbstractReferenceCountedByteBuf> updater =
            new ReferenceCountUpdater<AbstractReferenceCountedByteBuf>() {
        @Override
        protected AtomicIntegerFieldUpdater<AbstractReferenceCountedByteBuf> updater() {
            // 通過 AtomicIntegerFieldUpdater 操作 refCnt 字段
            return AIF_UPDATER;
        }
        @Override
        protected long unsafeOffset() {
            // 通過 Unsafe 操作 refCnt 字段
            return REFCNT_FIELD_OFFSET;
        }
    };
    // ByteBuf 中的引用計數,初始為 2 (偶數)
    private volatile int refCnt = updater.initialValue();
}

其中定義了一個 refCnt 字段用於記錄 ByteBuf 被引用的次數,由於採用了奇偶設計,在創建 ByteBuf 的時候,Netty 會將 refCnt 初始化為 2 (偶數),它的邏輯語義是該 ByteBuf 被引用一次。後續對 ByteBuf 執行 retain 就會對 refCnt 進行加 2 ,執行 release 就會對 refCnt 進行減 2 ,對於引用計數的單次操作都是以 2 為步長進行。

由於在 Netty 中除了 AbstractReferenceCountedByteBuf 這個專門用於實現 ByteBuf 的引用計數功能之外,還有一個更加通用的引用計數抽象類 AbstractReferenceCounted,它用於實現所有系統資源類的引用計數功能(ByteBuf 只是其中的一種內存資源)。

由於都是對引用計數的實現,所以在之前的版本中,這兩個類中包含了很多重複的引用計數相關操作邏輯,所以 Netty 在 4.1.35.Final 版本中專門引入了一個 ReferenceCountUpdater 類,將所有引用計數的相關實現聚合在這裏。

ReferenceCountUpdater 對於引用計數 refCnt 的操作有兩種方式,一種是通過 AtomicFieldUpdater 來對 refCnt 進行操作,我們可以通過 updater() 獲取到 refCnt 字段對應的 AtomicFieldUpdater。

另一種則是通過 Unsafe 來對 refCnt 進行操作,我們可以通過 unsafeOffset() 來獲取到 refCnt 字段在 ByteBuf 實例對象內存中的偏移。

按理來説,我們採用一種方式就可以對 refCnt 進行訪問或者更新了,那為什麼 Netty 提供了兩種方式呢 ?會顯得有點多餘嗎 ?這個點大家可以先思考下為什麼 ,後續在我們剖析到源碼細節的時候筆者在為大家解答。

好了,下面我們正式開始介紹新版引用計數設計方案的具體實現細節,第一個問題,在新的設計方案中,我們如何獲取 ByteBuf 的邏輯引用計數 ?

public abstract class ReferenceCountUpdater<T extends ReferenceCounted> {
    public final int initialValue() {
        // ByteBuf 引用計數初始化為 2
        return 2;
    }

    public final int refCnt(T instance) {
        // 通過 updater 獲取 refCnt
        // 根據 refCnt 在  realRefCnt 中獲取真實的引用計數
        return realRefCnt(updater().get(instance));
    }
    // 獲取 ByteBuf 的邏輯引用計數
    private static int realRefCnt(int rawCnt) {
        // 奇偶判斷
        return rawCnt != 2 && rawCnt != 4 && (rawCnt & 1) != 0 ? 0 : rawCnt >>> 1;
    }
}

由於採用了奇偶引用計數的設計,所以我們在獲取邏輯引用計數的時候需要判斷當前 rawCnt(refCnt)是奇數還是偶數,它們分別代表了不同的語義。

  • 如果 rawCnt 是奇數,則表示當前 ByteBuf 已經沒有任何地方引用了,邏輯引用計數返回 0.
  • 如果 rawCnt 是偶數,則表示當前 ByteBuf 還有地方在引用,邏輯引用計數則為 rawCnt >>> 1

realRefCnt 函數其實就是簡單的一個奇偶判斷邏輯,但在它的實現中卻體現出了 Netty 對性能的極致追求。比如,我們判斷一個數是奇數還是偶數其實很簡單,直接通過 rawCnt & 1 就可以判斷,如果返回 0 表示 rawCnt 是一個偶數,如果返回 1 表示 rawCnt 是一個奇數。

但是我們看到 Netty 在奇偶判斷條件的前面又加上了 rawCnt != 2 && rawCnt != 4 語句,這是幹嘛的呢 ?

其實 Netty 這裏是為了儘量用性能更高的 == 運算來代替 & 運算,但又不可能用 == 運算來枚舉出所有的偶數值(也沒這必要),所以只用 == 運算來判斷在實際場景中經常出現的引用計數,一般經常出現的引用計數值為 2 或者 4 , 也就是説 ByteBuf 在大部分場景下只會被引用 1 次或者 2 次,對於這種高頻出現的場景,Netty 用 == 運算來針對性優化,低頻出現的場景就回退到 & 運算。

大部分性能優化的套路都是相同的,我們通常不能一上來就奢求一個大而全的針對全局的優化方案,這是不可能的,也是十分低效的。往往最有效的,可以立竿見影的優化方案都是針對局部熱點進行專門優化。

對引用計數的設置也是一樣,都需要考慮奇偶的轉換,我們在 setRefCnt 方法中指定的參數 refCnt 表示邏輯上的引用計數 —— 0, 1 , 2 , 3 ....,但要設置到 ByteBuf 時,就需要對邏輯引用計數在乘以 2 ,讓它始終是一個偶數。

    public final void setRefCnt(T instance, int refCnt) {
        updater().set(instance, refCnt > 0 ? refCnt << 1 : 1); // overflow OK here
    }

有了這些基礎之後,我們下面就來看一下在新版本的 retain 方法設計中,Netty 是如何解決 4.1.17.Final 版本存在的併發安全問題。首先 Netty 對引用計數的奇偶設計對於用户來説是透明的。引用計數對於用户來説仍然是普通的自然數 —— 0, 1 , 2 , 3 ....

所以每當用户調用 retain 方法試圖增加 ByteBuf 的引用計數時,通常是指定邏輯增加步長 —— increment(用户視角),而在具體的實現角度,Netty 會增加兩倍的 increment (rawIncrement)到 refCnt 字段中。

    public final T retain(T instance) {
        // 引用計數邏輯上是加 1 ,但實際上是加 2 (實現角度)
        return retain0(instance, 1, 2);
    }

    public final T retain(T instance, int increment) {
        // all changes to the raw count are 2x the "real" change - overflow is OK
        // rawIncrement 始終是邏輯計數 increment 的兩倍
        int rawIncrement = checkPositive(increment, "increment") << 1;
        // 將 rawIncrement 設置到 ByteBuf 的 refCnt 字段中
        return retain0(instance, increment, rawIncrement);
    }

    // rawIncrement = increment << 1
    // increment 表示引用計數的邏輯增長步長
    // rawIncrement 表示引用計數的實際增長步長
    private T retain0(T instance, final int increment, final int rawIncrement) {
        // 先通過 XADD 指令將  refCnt 的值加起來
        int oldRef = updater().getAndAdd(instance, rawIncrement);
        // 如果 oldRef 是一個奇數,也就是 ByteBuf 已經沒有引用了,拋出異常
        if (oldRef != 2 && oldRef != 4 && (oldRef & 1) != 0) {
            // 如果 oldRef 已經是一個奇數了,無論多線程在這裏怎麼併發 retain ,都是一個奇數,這裏都會拋出異常
            throw new IllegalReferenceCountException(0, increment);
        }
        // don't pass 0! 
        // refCnt 不可能為 0 ,只能是 1
        if ((oldRef <= 0 && oldRef + rawIncrement >= 0)
                || (oldRef >= 0 && oldRef + rawIncrement < oldRef)) {
            // 如果 refCnt 字段已經溢出,則進行回退,並拋異常
            updater().getAndAdd(instance, -rawIncrement);
            throw new IllegalReferenceCountException(realRefCnt(oldRef), increment);
        }
        return instance;
    }

首先新版本的 retain0 方法仍然保留了 4.1.17.Final 版本引入的 XADD 指令帶來的性能優勢,大致的處理邏輯也是類似的,一上來先通過 getAndAdd 方法將 refCnt 增加 rawIncrement,對於 retain(T instance) 來説這裏直接加 2 。

然後判斷原來的引用計數 oldRef 是否是一個奇數,如果是一個奇數,那麼就表示 ByteBuf 已經沒有任何引用了,邏輯引用計數早已經為 0 了,那麼就拋出 IllegalReferenceCountException。

在引用計數為奇數的情況下,無論多線程怎麼對 refCnt 併發加 2 ,refCnt 始終是一個奇數,最終都會拋出異常。解決併發安全問題的要點就在這裏,一定要保證 retain 方法的併發執行不能改變原來的語義。

最後會判斷一下 refCnt 字段是否發生溢出,如果溢出,則進行回退,並拋出異常。下面我們仍然以之前的併發場景為例,用一個具體的例子,來回味一下奇偶設計的精妙之處。

image

現在線程 1 對一個 refCnt 為 2 的 ByteBuf 執行 release 方法,這時 ByteBuf 的邏輯引用計數就為 0 了,對於一個沒有任何引用的 ByteBuf 來説,新版的設計中它的 refCnt 只能是一個奇數,不能為 0 ,所以這裏 Netty 會將 refCnt 設置為 1 。然後在步驟 2 中調用 deallocate 方法釋放 Native Memory。

線程 2 在步驟 1 和步驟 2 之間插入進來對 ByteBuf 併發執行 retain 方法,這時線程 2 看到的 refCnt 是 1,然後通過 getAndAdd 將 refCnt 加到了 3 ,仍然是一個奇數,隨後拋出 IllegalReferenceCountException 異常。

線程 3 在步驟 1.1 和步驟 1.2 之間插入進來再次對 ByteBuf 併發執行 retain 方法,這時線程 3 看到的 refCnt 是 3,然後通過 getAndAdd 將 refCnt 加到了 5 ,還是一個奇數,隨後拋出 IllegalReferenceCountException 異常。

這樣一來就保證了引用計數的併發語義 —— 只要一個 ByteBuf 沒有任何引用的時候(refCnt = 1),其他線程無論怎麼併發執行 retain 方法都會得到一個異常。

但是引用計數併發語義的保證不能單單隻靠 retain 方法,它還需要與 release 方法相互配合協作才可以,所以為了併發語義的保證 , release 方法的設計就不能使用性能更高的 XADD 指令,而是要回退到 CMPXCHG 指令來實現。

為什麼這麼説呢 ?因為新版引用計數的設計採用的是奇偶實現,refCnt 為偶數表示 ByteBuf 還有引用,refCnt 為奇數表示 ByteBuf 已經沒有任何引用了,可以安全釋放 Native Memory 。對於一個 refCnt 已經為奇數的 ByteBuf 來説,無論多線程怎麼併發執行 retain 方法,得到的 refCnt 仍然是一個奇數,最終都會拋出 IllegalReferenceCountException,這就是引用計數的併發語義 。

為了保證這一點,就需要在每次調用 retain ,release 方法的時候,以偶數步長來更新 refCnt,比如每一次調用 retain 方法就對 refCnt 加 2 ,每一次調用 release 方法就對 refCnt 減 2 。

但總有一個時刻,refCnt 會被減到 0 的對吧,在新版的奇偶設計中,refCnt 是不允許為 0 的,因為一旦 refCnt 被減到了 0 ,多線程併發執行 retain 之後,就會將 refCnt 再次加成了偶數,這又會出現併發問題。

而每一次調用 release 方法是對 refCnt 減 2 ,如果我們採用 XADD 指令實現 release 的話,回想一下 4.1.17.Final 版本中的設計,它首先進來是通過 getAndAdd 方法對 refCnt 減 2 ,這樣一來,refCnt 就變成 0 了,就有併發安全問題了。所以我們需要通過 CMPXCHG 指令將 refCnt 更新為 1。

這裏有的同學可能要問了,那可不可以先進行一下 if 判斷,如果 refCnt 減 2 之後變為 0 了,我們在通過 getAndAdd 方法將 refCnt 更新為 1 (減一個奇數),這樣一來不也可以利用上 XADD 指令的性能優勢嗎 ?

答案是不行的,因為 if 判斷與 getAndAdd 更新這兩個操作之間仍然不是原子的,多線程可以在這個間隙仍然有併發執行 retain 方法的可能,如下圖所示:

image

在線程 1 執行 if 判斷和 getAndAdd 更新這兩個操作之間,線程 2 看到的 refCnt 其實 2 ,然後線程 2 會將 refCnt 加到 4 ,線程 3 緊接着會將 refCnt 增加到 6 ,在線程 2 和線程 3 看來這個 ByteBuf 完全是正常的,但是線程 1 馬上就會釋放 Native Memory 了。

而且採用這種設計的話,一會通過 getAndAdd 對 refCnt 減一個奇數,一會通過 getAndAdd 對 refCnt 加一個偶數,這樣就把原本的奇偶設計搞亂掉了。

所以我們的設計目標是一定要保證在 ByteBuf 沒有任何引用計數的時候,release 方法需要原子性的將 refCnt 更新為 1 。 因此必須採用 CMPXCHG 指令來實現而不能使用 XADD 指令。

再者説, CMPXCHG 指令是可以原子性的判斷當前是否有併發情況的,如果有併發情況出現,CAS 就會失敗,我們可以繼續重試。但 XADD 指令卻無法原子性的判斷是否有併發情況,因為它每次都是先更新,後判斷併發,這就不是原子的了。這一點,在下面的源碼實現中會體現的特別明顯

7. 儘量避免內存屏障的開銷

    public final boolean release(T instance) {
        // 第一次嘗試採用 unSafe nonVolatile 的方式讀取 refCnf 的值
        int rawCnt = nonVolatileRawCnt(instance);
        // 如果邏輯引用計數被減到 0 了,那麼就通過 tryFinalRelease0 使用 CAS 將 refCnf 更新為 1
        // CAS 失敗的話,則通過 retryRelease0 進行重試
        // 如果邏輯引用計數不為 0 ,則通過 nonFinalRelease0 將 refCnf 減 2
        return rawCnt == 2 ? tryFinalRelease0(instance, 2) || retryRelease0(instance, 1)
                : nonFinalRelease0(instance, 1, rawCnt, toLiveRealRefCnt(rawCnt, 1));
    }

這裏有一個小的細節再次體現出 Netty 對於性能的極致追求,refCnt 字段在 ByteBuf 中被 Netty 申明為一個 volatile 字段。

private volatile int refCnt = updater.initialValue();

我們對 refCnt 的普通讀寫都是要走內存屏障的,但 Netty 在 release 方法中首次讀取 refCnt 的值是採用 nonVolatile 的方式,不走內存屏障,直接讀取 cache line,避免了屏障開銷。

    private int nonVolatileRawCnt(T instance) {
        // 獲取 REFCNT_FIELD_OFFSET
        final long offset = unsafeOffset();
        // 通過 UnSafe 的方式來訪問 refCnt , 避免內存屏障的開銷
        return offset != -1 ? PlatformDependent.getInt(instance, offset) : updater().get(instance);
    }

那有的同學可能要問了,如果讀取 refCnt 的時候不走內存屏障的話,讀取到的 refCnt 不就可能是一個錯誤的值嗎 ?

事實上確實是這樣的,但 Netty 不 care , 讀到一個錯誤的值也無所謂,因為這裏的引用計數採用了奇偶設計,我們在第一次讀取引用計數的時候並不需要讀取到一個精確的值,既然這樣我們可以直接通過 UnSafe 來讀取,還能剩下一筆內存屏障的開銷。

那為什麼不需要一個精確的值呢 ?因為如果原來的 refCnt 是一個奇數,那無論多線程怎麼併發 retain ,最終得到的還是一個奇數,我們這裏只需要知道 refCnt 是一個奇數就可以直接拋 IllegalReferenceCountException 了。具體讀到的是一個 3 還是一個 5 其實都無所謂。

那如果原來的 refCnt 是一個偶數呢 ?其實也無所謂,我們可能讀到一個正確的值也可能讀到一個錯誤的值,如果恰好讀到一個正確的值,那更好。如果讀取到一個錯誤的值,也無所謂,因為我們後面是用 CAS 進行更新,這樣的話 CAS 就會更新失敗,我們只需要在一下輪 for 循環中更新正確就可以了。

如果讀取到的 refCnt 恰好是 2 ,那就意味着本次 release 之後,ByteBuf 的邏輯引用計數就為 0 了,Netty 會通過 CAS 將 refCnt 更新為 1 。

   private boolean tryFinalRelease0(T instance, int expectRawCnt) {
        return updater().compareAndSet(instance, expectRawCnt, 1); // any odd number will work
    }

如果 CAS 更新失敗,則表示此時有多線程可能併發對 ByteBuf 執行 retain 方法,邏輯引用計數此時可能就不為 0 了,針對這種併發情況,Netty 會在 retryRelease0 方法中進行重試,將 refCnt 減 2 。

    private boolean retryRelease0(T instance, int decrement) {
        for (;;) {
            // 採用 Volatile 的方式讀取 refCnt
            int rawCnt = updater().get(instance), 
            // 獲取邏輯引用計數,如果 refCnt 已經變為奇數,則拋出異常
            realCnt = toLiveRealRefCnt(rawCnt, decrement);
            // 如果執行完本次 release , 邏輯引用計數為 0
            if (decrement == realCnt) {
                // CAS 將 refCnt 更新為 1
                if (tryFinalRelease0(instance, rawCnt)) {
                    return true;
                }
            } else if (decrement < realCnt) {
                // 原來的邏輯引用計數 realCnt 大於 1(decrement)
                // 則通過 CAS 將 refCnt 減 2
                if (updater().compareAndSet(instance, rawCnt, rawCnt - (decrement << 1))) {
                    return false;
                }
            } else {
                // refCnt 字段如果發生溢出,則拋出異常
                throw new IllegalReferenceCountException(realCnt, -decrement);
            }
            // CAS 失敗之後調用 yield
            // 減少無畏的競爭,否則所有線程在高併發情況下都在這裏 CAS 失敗
            Thread.yield(); 
        }
    }

從 retryRelease0 方法的實現中我們可以看出,CAS 是可以原子性的探測到是否有併發情況出現的,如果有併發情況,這裏的所有 CAS 都會失敗,隨後會在下一輪 for 循環中將正確的值更新到 refCnt 中。這一點 ,XADD 指令是做不到的。

如果在進入 release 方法後,第一次讀取的 refCnt 不是 2 ,那麼就不能走上面的 tryFinalRelease0 邏輯,而是在 nonFinalRelease0 中通過 CAS 將 refCnt 的值減 2 。

   private boolean nonFinalRelease0(T instance, int decrement, int rawCnt, int realCnt) {
        if (decrement < realCnt
                && updater().compareAndSet(instance, rawCnt, rawCnt - (decrement << 1))) {
            // ByteBuf 的 rawCnt 減少 2 * decrement
            return false;
        }
        // CAS  失敗則一直重試,如果引用計數已經為 0 ,那麼拋出異常,不能再次 release
        return retryRelease0(instance, decrement);
    }

總結

到這裏,Netty 對引用計數的精彩設計,筆者就為大家完整的剖析完了,一共有四處非常精彩的優化設計,我們總結如下:

  1. 使用性能更優的 XADD 指令來替換 CMPXCHG 指令。
  2. 引用計數採用了奇偶設計,保證了併發語義。
  3. 採用性能更優的 == 運算來替換 & 運算。
  4. 能不走內存屏障就儘量不走內存屏障。
user avatar xuxueli 頭像 tech 頭像 u_11365552 頭像 u_15702012 頭像 lvlaotou 頭像 jkdataapi 頭像 lyflexi 頭像 yunpan-plus 頭像 best_6455a509a2177 頭像 javalover 頭像 kubesphere 頭像 fulade 頭像
點贊 43 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.