由前陣子 lock + 共用靜態物件 vs 每次新建物件的效能案例,當新建物件成本不高且方法非 Thread-Safe 時,共用靜態物件加 lock 還不如每次新建物件有效率,該如何決策顯而易見。但如果新建物件的成本很高呢?若建構物件需要耗用大量 CPU 或記憶體,甚至得爭奪有限資源(固定數量的 Socket Port、網路連線數有上限... 等),放任物件數無限制成長對效能必定有害。實務上有個常見的經典案例 - 資料庫連線。提到資料庫連線,我想大家就知道這類問題的最佳解決方案是什麼?開一個 Pool,裡面存入有限數量的物件開放共用。

要實做 Object Pool 機制的細節不少,必須要控制物件數量、掌握使用/閒置狀態、決定何時增減物件數量、處理 Thread-Safe 議題... 等等。要自己土砲 Object Pool 說難不難,但好消息是 .NET 有現成的程式庫,不一定要自己造輪子。

直接用前面文章的 JSEvaluator 當範例,我修改 JSEvaluator.cs 移去 static 部分,改成必須建立 Instance 才能使用,而建構式需重新編譯 JScript 產生組件,在前一篇效能測試裡我們已見識過這有多耗時。編譯組件部分其實可改為靜態變數提升效能,但這裡故意不要最佳化,將 JSEvaluator 塑迼成建構成本頗高的物件,以突顯 Object Pool 的價值。

using System;
using System.CodeDom.Compiler;
using System.Reflection;
using System.Threading;

namespace JSEval
{
    public class JSEvaluator
    {
        Type jseType = null;
        const string jScriptCode = @"class JSEvaluator {  
public function Eval(expr : String) : String { return eval(expr); } 
}";
        static object syncObject = new object();
        static int InstanceCount = 0;
        public int SeqNo { get; private set; }
        public bool EnableDebugLog = false;
        public static void ResetCounter()
        {
            InstanceCount = 0;
        }
        public JSEvaluator()
        {
            jseType = CodeDomProvider.CreateProvider("JScript")
                .CompileAssemblyFromSource(
                    new CompilerParameters { GenerateInMemory = true },
                    jScriptCode)
                .CompiledAssembly.GetType("JSEvaluator");
            lock (syncObject)
            {
                InstanceCount++;
                SeqNo = InstanceCount;
                if (EnableDebugLog) 
                    Console.WriteLine($"Instance #{SeqNo} created");
            }

        }
        public string Eval(string expr)
        {
            var jse = Activator.CreateInstance(jseType);
            return jseType.InvokeMember("Eval", BindingFlags.InvokeMethod,
                null, jse, new object[] { expr }).ToString();
        }
    }
}

再來談談怎麼利用 .NET 的 Object Pool 程式庫。請從 NuGet 下載安裝 Microsoft.Extensions.ObjectPool:

應用 ObjectPool<T> 的最簡單寫法是建立一個 DefaultPooledObjectPolicy<JSEvaluator> 再以其當參數建立 DefaultObjectPool<T>,如此就做好一個簡易版的 ObjectPool。需要 T 物件時,呼叫 ObjectPool.Get(),可取回閒置的 T 物件再利用,若無閒置物件可用,就 new T 新建一個;使用完畢則呼叫 ObjectPool.Return(T) 歸還放回 ObjectPool。DefaultObjectPool 的策略是最多保留 CPU 核數兩倍個閒置物件,若歸還時 Pool 已滿就丟棄物件留待 GC 回收。若一直被 Get() 取用卻無人歸遇,ObjectPool 將源源不斷建立新物件,跟 Connection Pool 不同,ObjectPool 只有置閒物件上限,沒有建立物件數量上限,因此沒有超過最大數量要等待、等到逾時會出錯等機制。

//基本使用說明
var policy = new DefaultPooledObjectPolicy<JSEvaluator>();
//建立 Pool,預設最多保留 CPU Core * 2 個閒置物件備用,多餘的丟棄
var pool = new DefaultObjectPool<JSEvaluator>(policy);
//若 Pool 沒有置閒物件時,每次 Get 都會產生新的
var jse1 = pool.Get();
//使用完畢用 Return 歸還
pool.Return(jse1);

以下用幾個實驗展示 ObjectPool 的行為:

實驗一,Get 3 個,Return 2 個,再 Get 3,Return 的兩個可重複使用,預計會建出 4 個 JSEvaluator:

實驗二,設 ObjectPool maximumRetained = 1,Get 3 個,Return 2 個,再 Get 3。Return 的 2 個中一個會被丟棄,故只有一個能重複使用,預計建立 5 個 JSEvaluator:

實驗三,Parallel.For 平行跑 64 次,透過 ObjectPool,只需建立 5 個 JSEvaluator 物件:(實驗三之後程式碼"預設只留 1 個閒置物件"註解忘了改掉 Orz 請自行忽略)

實驗四,Parallel.For 平行跑 64 次,使用 ObjectPool,但加入 Thread.Sleep 模擬計算時間 0.5 秒,Parallel.For 遇等待時間偏久逐步增加 Thread 數,Pool 物件供不應求,建立物件的機率增加,總共用到 7 個 JSEvaluator 物件:

實驗五,Parallel.For 平行跑 64 次,使用 ObjectPool,但只 Get() 不 Return(),ObjectPool 將不斷建立 JSEvaluator 物件且無上限,最終建立 64 個:

如果對 DefaultPooledObjectPolicy 的運作方式不滿意,開發者也可以實作 IPooledObjectPolicy 自訂建立物件及歸還物件的特定邏輯,或是開發自己的 ObjectPoolProvider 版本取代 DefaultObjectPoolProvider。

ObjectPool<T> 特別適合物件建構成本偏高,想用最小力氣降底物件數量提升效能的場合,功能雖然陽春,但只要寫幾行程式就能實現物件重複利用,執行效能或 Thread-Safe 嚴謹性又肯定勝過自己土砲,遇到需考量共用物件加速的情境,可優先納入考量。

Examples of how to use ObjectPool<T> to reuse your object with a little effort.


Comments

# by Soon

可否請教黑大,圖 2 (NuGet manager那張),有沒有使用什麼繪圖工具去快速遮罩下方,其他不重要的搜尋結果呢?應該不是手工一行一行貼海苔條吧😆 成品看起來不突兀也滿美觀的

# by Acy

實驗3之後的註解是不是都沿用到實驗2的註解而忘了改 XD "建立 pool,預設只留 1 個閒置物件備用"

# by Jeffrey

to Soon, 遮罩是用 Snagit 的視覺簡化工具做的,我最愛的新版功能之一。參考:https://blog.darkthread.net/blog/snagit-2021/

# by Jeffrey

to Acy, 你說對了 Orz 沒力氣重抓圖,已補上說明。謝謝提醒。

# by Soon

原來是 Snagit,隱約記得看過黑大分享(拍腦) 感謝~

Post a comment