.NET無侵入式物件池解決方案

nigture發表於2024-10-16

Pooling(https://github.com/inversionhourglass/Pooling),編譯時物件池元件,在編譯時將指定型別的new操作替換為物件池操作,簡化編碼過程,無需開發人員手動編寫物件池操作程式碼。同時提供了完全無侵入式的解決方案,可用作臨時效能最佳化的解決方案和老久專案效能最佳化的解決方案等。

快速開始

引用Pooling.Fody

dotnet add package Pooling.Fody

確保FodyWeavers.xml檔案中已配置Pooling,如果當前專案沒有FodyWeavers.xml檔案,可以直接編譯專案,會自動生成FodyWeavers.xml檔案:

<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
  <Pooling /> <!--確儲存在Pooling節點-->
</Weavers>
// 1. 需要池化的型別實現IPoolItem介面
public class TestItem : IPoolItem
{
    public int Value { get; set; }

    // 當物件返回物件池化時透過該方法進行重置例項狀態
    public bool TryReset()
    {
        return true;
    }
}

// 2. 在任何地方使用new關鍵字建立該型別的物件
public class Test
{
    public void M()
    {
        var random = new Random();
        var item = new TestItem();
        item.Value = random.Next();
        Console.WriteLine(item.Value);
    }
}

// 編譯後程式碼
public class Test
{
    public void M()
    {
        TestItem item = null;
        try
        {
            var random = new Random();
            item = Pool<TestItem>.Get();
            item.Value = random.Next();
            Console.WriteLine(item.Value);
        }
        finally
        {
            if (item != null)
            {
                Pool<TestItem>.Return(item);
            }
        }
    }
}

IPoolItem

正如快速開始中的程式碼所示,實現了IPoolItem介面的型別便是一個池化型別,在編譯時Pooling會將其new操作替換為物件池操作,並在finally塊中將池化物件例項返還到物件池中。IPoolItem僅有一個TryReset方法,該方法用於在物件返回物件池時進行狀態重置,該方法返回false時表示狀態重置失敗,此時該物件將會被丟棄。

PoolingExclusiveAttribute

預設情況下,實現IPoolItem的池化型別會在所有方法中進行池化操作,但有時候我們可能希望該池化型別在部分型別中不進行池化操作,比如我們可能會建立一些池化型別的管理型別或者Builder型別,此時在池化型別上應用PoolingExclusiveAttribute便可指定該池化型別不在某些型別/方法中進行池化操作。

[PoolingExclusive(Types = [typeof(TestItemBuilder)], Pattern = "execution(* TestItemManager.*(..))")]
public class TestItem : IPoolItem
{
    public bool TryReset() => true;
}

public class TestItemBuilder
{
    private readonly TestItem _item;

    private TestItemBuilder()
    {
        // 由於透過PoolingExclusive的Types屬性排除了TestItemBuilder,所以這裡不會替換為物件池操作
        _item = new TestItem();
    }

    public static TestItemBuilder Create() => new TestItemBuilder();

    public TestItemBuilder SetXxx()
    {
        // ...
        return this;
    }

    public TestItem Build()
    {
        return _item;
    }
}

public class TestItemManager
{
    private TestItem? _cacheItem;

    public void Execute()
    {
        // 由於透過PoolingExclusive的Pattern屬性排除了TestItemManager下的所有方法,所以這裡不會替換為物件池操作
        var item = _cacheItem ?? new TestItem();
        // ...
    }
}

如上程式碼所示,PoolingExclusiveAttribute有兩個屬性TypesPatternTypesType型別陣列,當前池化型別不會在陣列中的型別的方法中進行池化操作;Patternstring型別AspectN表示式,可以細緻的匹配到具體的方法(AspectN表示式格式詳見:https://github.com/inversionhourglass/Shared.Cecil.AspectN/blob/master/README.md ),當前池化型別不會在被匹配到的方法中進行池化操作。兩個屬性可以使用其中一個,也可以同時使用,同時使用時將排除兩個屬性匹配到的所有型別/方法。

NonPooledAttribute

前面介紹了可以透過PoolingExclusiveAttribute指定當前池化物件在某些型別/方法中不進行池化操作,但由於PoolingExclusiveAttribute需要直接應用到池化型別上,所以如果你使用了第三方類庫中的池化型別,此時你無法直接將PoolingExclusiveAttribute應用到該池化型別上。針對此類情況,可以使用NonPooledAttribute表明當前方法不進行池化操作。

public class TestItem1 : IPoolItem
{
    public bool TryReset() => true;
}
public class TestItem2 : IPoolItem
{
    public bool TryReset() => true;
}
public class TestItem3 : IPoolItem
{
    public bool TryReset() => true;
}

public class Test
{
    [NonPooled]
    public void M()
    {
        // 由於方法應用了NonPooledAttribute,以下三個new操作都不會替換為物件池操作
        var item1 = new TestItem1();
        var item2 = new TestItem2();
        var item3 = new TestItem3();
    }
}

有的時候你可能並不是希望方法裡所有的池化型別都不進行池化操作,此時可以透過NonPooledAttribute的兩個屬性TypesPattern指定不可進行池化操作的池化型別。TypesType型別陣列,陣列中的所有型別在當前方法中均不可進行池化操作;Patternstring型別AspectN型別表示式,所有匹配的型別在當前方法中均不可進行池化操作。

public class Test
{
    [NonPooled(Types = [typeof(TestItem1)], Pattern = "*..TestItem3")]
    public void M()
    {
        // TestItem1透過Types不允許進行池化操作,TestItem3透過Pattern不允許進行池化操作,僅TestItem2可進行池化操作
        var item1 = new TestItem1();
        var item2 = new TestItem2();
        var item3 = new TestItem3();
    }
}

AspectN型別表示式靈活多變,支援邏輯非運算子!,所以可以很方便的使用AspectN型別表示式僅允許某一個型別,比如上面的示例可以簡單改為[NonPooled(Pattern = "!TestItem2")],更多AspectN表示式說明,詳見:https://github.com/inversionhourglass/Shared.Cecil.AspectN/blob/master/README.md

NonPooledAttribute不僅可以應用於方法層級,還可以應用於型別和程式集。應用於類等同於應用到類的所有方法上(包括屬性和構造方法),應用於程式集等同於應用到當前程式集的所有方法上(包括屬性和構造方法),另外如果在應用到程式集時沒有指定TypesPattern兩個屬性,那麼就等同於當前程式集禁用Pooling。

無侵入式池化操作

看了前面的內容再看看標題,你可能就在嘀咕“這是哪門子無侵入式,這不純純標題黨”。現在,標題的部分來了。Pooling提供了無侵入式的接入方式,適用於臨時效能最佳化和老久專案改造,不需要實現IPoolItem介面,透過配置即可指定池化型別。

假設目前有如下程式碼:

namespace A.B.C;

public class Item1
{
    public object? GetAndDelete() => null;
}

public class Item2
{
    public bool Clear() => true;
}

public class Item3 { }

public class Test
{
    public static void M1()
    {
        var item1 = new Item1();
        var item2 = new Item2();
        var item3 = new Item3();
        Console.WriteLine($"{item1}, {item2}, {item3}");
    }

    public static async ValueTask M2()
    {
        var item1 = new Item1();
        var item2 = new Item2();
        await Task.Yield();
        var item3 = new Item3();
        Console.WriteLine($"{item1}, {item2}, {item3}");
    }
}

專案在引用Pooling.Fody後,編譯專案時專案資料夾下會生成一個FodyWeavers.xml檔案,我們按下面的示例修改Pooling節點:

<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
  <Pooling>
    <Items>
      <Item pattern="A.B.C.Item1.GetAndDelete" />
      <Item pattern="Item2.Clear" inspect="execution(* Test.M1(..))" />
      <Item stateless="*..Item3" not-inspect="method(* Test.M2())" />
	</Items>
  </Pooling>
</Weavers>

上面的配置中,每一個Item節點匹配一個池化型別,上面的配置中展示了全部的四個屬性,它們的含義分別是:

  • pattern: AspectN型別+方法表示式。匹配到的型別為池化型別,匹配到的方法為狀態重置方法(等同於IPoolItem的TryReset方法)。需要注意的是,重置方法必須是無參的。
  • stateless: AspectN型別表示式。匹配到的型別為池化型別,該型別為無狀態型別,不需要重置操作即可回到物件池中。
  • inspect: AspectN表示式。patternstateless匹配到的池化型別,只有在該表示式匹配到的方法中才會進行池化操作。當該配置預設時表示匹配當前程式集的所有方法。
  • not-inspect: AspectN表示式。patternstateless匹配到的池化型別不會在該表示式匹配到的方法中進行池化操作。當該配置預設時表示不排除任何方法。最終池化型別能夠進行池化操作的方法集合為inspect集合與not-inspect集合的差集。

那麼透過上面的配置,Test在編譯後的程式碼為:

public class Test
{
    public static void M1()
    {
        Item1 item1 = null;
        Item2 item2 = null;
        Item3 item3 = null;
        try
        {
            item1 = Pool<Item1>.Get();
            item2 = Pool<Item2>.Get();
            item3 = Pool<Item3>.Get();
            Console.WriteLine($"{item1}, {item2}, {item3}");
        }
        finally
        {
            if (item1 != null)
            {
                item1.GetAndDelete();
                Pool<Item1>.Return(item1);
            }
            if (item2 != null)
            {
                if (item2.Clear())
                {
                    Pool<Item2>.Return(item2);
                }
            }
            if (item3 != null)
            {
                Pool<Item3>.Return(item3);
            }
        }
    }

    public static async ValueTask M2()
    {
        Item1 item1 = null;
        try
        {
            item1 = Pool<Item1>.Get();
            var item2 = new Item2();
            await Task.Yield();
            var item3 = new Item3();
            Console.WriteLine($"{item1}, {item2}, {item3}");
        }
        finally
        {
            if (item1 != null)
            {
                item1.GetAndDelete();
                Pool<Item1>.Return(item1);
            }
        }
    }
}

細心的你可能注意到在M1方法中,item1item2在重置方法的呼叫上有所區別,這是因為Item2的重置方法的返回值型別為bool,Poolinng會將其結果作為是否重置成功的依據,對於void或其他型別的返回值,Pooling將在方法成功返回後預設其重置成功。

零侵入式池化操作

看到這個標題是不是有點懵,剛介紹完無侵入式,怎麼又來個零侵入式,它們有什麼區別?

在上面介紹的無侵入式池化操作中,我們不需要改動任何C#程式碼即可完成指定型別池化操作,但我們仍需要新增Pooling.Fody的NuGet依賴,並且需要修改FodyWeavers.xml進行配置,這仍然需要開發人員手動操作完成。那如何讓開發人員完全不需要任何操作呢?答案也很簡單,就是將這一步放到CI流程或釋出流程中完成。是的,零侵入是針對開發人員的,並不是真的什麼都不需要做,而是將引用NuGet和配置FodyWeavers.xml的步驟延後到CI/釋出流程中了。

優勢是什麼

類似於物件池這型別的最佳化往往不是僅僅某一個專案需要最佳化,這種最佳化可能是普遍性的,那麼此時相比一個專案一個專案的修改,統一的在CI流程/釋出流程中配置是更為快速的選擇。另外在面對一些古董專案時,可能沒有人願意去更改任何程式碼,即使只是專案檔案和FodyWeavers.xml配置檔案,此時也可以透過修改CI/釋出流程來完成。當然修改統一的CI/釋出流程的影響面可能更廣,這裡只是提供一種零侵入式的思路,具體情況還需要結合實際情況綜合考慮。

如何實現

最直接的方式就是在CI構建流程或釋出流程中透過dotnet add package Pooling.Fody為專案新增NuGet依賴,然後將預先配置好的FodyWeavers.xml複製到專案目錄下。但如果專案還引用了其他Fody外掛,直接覆蓋原有的FodyWeavers.xml可能導致原有的外掛無效。當然,你也可以複雜點透過指令碼控制FodyWeavers.xml的內容,這裡我推薦一個.NET CLI工具,Cli4Fody可以一步完成NuGet依賴和FodyWeavers.xml配置。

<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
  <Pooling>
    <Items>
      <Item pattern="A.B.C.Item1.GetAndDelete" />
      <Item pattern="Item2.Clear" inspect="execution(* Test.M1(..))" />
      <Item stateless="*..Item3" not-inspect="method(* Test.M2())" />
	</Items>
  </Pooling>
</Weavers>

上面的FodyWeavers.xml,使用Cli4Fody對應的命令為:

fody-cli MySolution.sln \
        --addin Pooling -pv 0.1.0 \
            -n Items:Item -a "pattern=A.B.C.Item1.GetAndDelete" \
            -n Items:Item -a "pattern=Item2.Clear" -a "inspect=execution(* Test.M1(..))" \
            -n Items:Item -a "stateless=*..Item3" -a "not-inspect=method(* Test.M2())"

Cli4Fody的優勢是,NuGet引用和FodyWeavers.xml可以同時完成,並且Cli4Fody並不會修改或刪除FodyWeavers.xml中其他Fody外掛的配置。更多Cli4Fody相關配置,詳見:https://github.com/inversionhourglass/Cli4Fody

Rougamo零侵入式最佳化案例

肉夾饃(Rougamo),一款靜態程式碼編織的AOP元件。肉夾饃在2.2.0版本中新增了結構體支援,可以透過結構體最佳化GC。但結構體的使用沒有類方便,不可繼承父類只能實現介面,所以很多MoAttribute中的預設實現在定義結構體時需要重複實現。現在,你可以使用Pooling透過物件池來最佳化肉夾饃的GC。在這個示例中將使用Docker演示如何在Docker構建流程中使用Cli4Fody完成零侵入式池化操作:

目錄結構:

.
├── Lib
│   └── Lib.csproj                       # 依賴Rougamo.Fody
│   └── TestAttribute.cs                 # 繼承MoAttribute
└── RougamoPoolingConsoleApp
    └── BenchmarkTest.cs
    └── Dockerfile
    └── RougamoPoolingConsoleApp.csproj  # 引用Lib.csproj,沒有任何Fody外掛依賴
    └── Program.cs

該測試專案在BenchmarkTest.cs裡面定義了兩個空的測試方法MN,兩個方法都應用了TestAttribute。本次測試將在Docker的構建步驟中使用Cli4Fody為專案增加Pooling.Fody依賴並將TestAttribute配置為池化型別,同時設定其只能在TestAttribute.M方法中進行池化,然後透過Benchmark對比MN的GC情況。

// TestAttribute
public class TestAttribute : MoAttribute
{
    // 為了讓GC效果更明顯,每個TestAttribute都將持有長度為1024的位元組陣列
    private readonly byte[] _occupy = new byte[1024];
}

// BenchmarkTest
public class BenchmarkTest
{
    [Benchmark]
    [Test]
    public void M() { }

    [Benchmark]
    [Test]
    public void N() { }
}

// Program
var config = ManualConfig.Create(DefaultConfig.Instance)
    .AddDiagnoser(MemoryDiagnoser.Default);
var _ = BenchmarkRunner.Run<BenchmarkTest>(config);

Dockerfile

FROM mcr.microsoft.com/dotnet/sdk:8.0
WORKDIR /src
COPY . .

ENV PATH="$PATH:/root/.dotnet/tools"
RUN dotnet tool install -g Cli4Fody
RUN fody-cli DockerSample.sln --addin Rougamo -pv 4.0.4 --addin Pooling -pv 0.1.0 -n Items:Item -a "stateless=Rougamo.IMo+" -a "inspect=method(* RougamoPoolingConsoleApp.BenchmarkTest.M(..))"

RUN dotnet restore

RUN dotnet publish "./RougamoPoolingConsoleApp/RougamoPoolingConsoleApp.csproj" -c Release -o /src/bin/publish

WORKDIR /src/bin/publish
ENTRYPOINT ["dotnet", "RougamoPoolingConsoleApp.dll"]

透過Cli4Fody最終BenchmarkTest.M中織入的TestAttribute進行了池化操作,而BenchmarkTest.N中織入的TestAttribute沒有進行池化操作,最終Benchmark結果如下:

| Method | Mean     | Error   | StdDev   | Gen0   | Gen1   | Allocated |
|------- |---------:|--------:|---------:|-------:|-------:|----------:|
| M      | 188.7 ns | 3.81 ns |  6.67 ns | 0.0210 |      - |     264 B |
| N      | 195.5 ns | 4.09 ns | 11.74 ns | 0.1090 | 0.0002 |    1368 B |

完整示例程式碼儲存在:https://github.com/inversionhourglass/Pooling/tree/master/samples/DockerSample

在這個示例中,透過在Docker的構建步驟中使用Cli4Fody完成了對Rougamo的物件池最佳化,整個過程對開發時完全無感零侵入的。如果你準備用這種方法對Rougamo進行物件池最佳化,需要注意的是當前示例中的切面型別TestAttribute是無狀態的,所以你需要跟開發確認所有定義的切面型別都是無狀態的,對於有狀態的切面型別,你需要定義重置方法並在定義Item節點時使用pattern屬性而不是stateless屬性。

在這個示例中還有一點你可能沒有注意,只有Lib專案引用了Rougamo.Fody,RougamoPoolingConsoleApp專案並沒有引用Rougamo.Fody,預設情況下應用到BenchmarkTestTestAttribute應該是不會生效的,但我這個例子中卻生效了。這是因為在使用Cli4Fody時還指定了Rougamo的相關引數,Cli4Fody會為RougamoPoolingConsoleApp新增了Rougamo.Fody引用,所以Cli4Fody也可用於避免遺漏專案隊Fody外掛的直接依賴,更多Cli4Fody的內容詳見:https://github.com/inversionhourglass/Cli4Fody

配置項

無侵入式池化操作中介紹了Items節點配置,除了Items配置項Pooling還提供了其他配置項,下面是完整配置示例:

<Pooling enabled="true" composite-accessibility="false">
  <Inspects>
    <Inspect>any_aspectn_pattern</Inspect>
    <Inspect>any_aspectn_pattern</Inspect>
  </Inspects>
  <NotInspects>
    <NotInspect>any_aspectn_pattern</NotInspect>
    <NotInspect>any_aspectn_pattern</NotInspect>
  </NotInspects>
  <Items>
    <Item pattern="method_name_pattern" stateless="type_pattern" inspect="any_aspectn_pattern" not-inspect="any_aspectn_pattern" />
    <Item pattern="method_name_pattern" stateless="type_pattern" inspect="any_aspectn_pattern" not-inspect="any_aspectn_pattern" />
  </Items>
</Pooling>
節點路徑 屬性名稱 用途
/Pooling enabled 是否啟用Pooling
/Pooling composite-accessibility AspectN是否使用類+方法綜合可訪問性進行匹配。預設僅按方法可訪問性進行匹配,比如類的可訪問性為internal,方法的可訪問性為public,那麼預設情況下該方法的可訪問性認定為public,將該配置設定為true後,該方法的可訪問性認定為internal
/Pooling/Inspects/Inspect [節點值] AspectN表示式。
全域性篩選器,只有被該表示式匹配的方法才會檢查內部是否使用到池化型別並進行池化操作替換。即使是實現了IPoolItem的池化型別也會受限於該配置。
該節點可配置多條,匹配的方法集合為多條配置的並集。
該節點預設時表示匹配當前程式集所有方法。
最終的方法集合是該節點配置匹配的集合與 /Pooling/NotInspects 配置匹配的集合的差集。
/Pooling/NotInspects/NotInspect [節點值] AspectN表示式。
全域性篩選器,被該表示式匹配的方法的內部不會進行池化操作替換。即使是實現了IPoolItem的池化型別也會受限於該配置。
該節點可配置多條,匹配的方法集合為多條配置的並集。
該節點預設時表示不排除任何方法。
最終的方法集合是 /Pooling/Inspects 配置匹配的集合與該節點配置匹配的集合的差集。
/Pooling/Items/Item pattern AspectN型別+方法名錶達式。
匹配的型別會作為池化型別,匹配的方法會作為重置方法。
重置方法必須是無參方法,如果方法返回值型別為bool,返回值還會被作為是否重置成功的依據。
該屬性與stateless屬性僅可二選一。
/Pooling/Items/Item stateless AspectN型別表示式。
匹配的型別會作為池化型別,該型別為無狀態型別,在回到物件池之前不需要進行重置。
該屬性與pattern僅可二選一。
/Pooling/Items/Item inspect AspectN表示式。
patternstateless匹配到的池化型別,只有在該表示式匹配到的方法中才會進行池化操作。
當該配置預設時表示匹配當前程式集的所有方法。
當前池化型別最終能夠應用的方法集合為該配置匹配的方法集合與not-inspect配置匹配的方法集合的差集。
/Pooling/Items/Item not-inspect AspectN表示式。
patternstateless匹配到的池化型別不會在該表示式匹配到的方法中進行池化操作。
當該配置預設時表示不排除任何方法。
當前池化型別最終能夠應用的方法集合為inspect配置匹配的方法集合與該配置匹配的方法集合的差集。

可以看到配置中大量使用了AspectN表示式,瞭解更多AspectN表示式的用法詳見:https://github.com/inversionhourglass/Shared.Cecil.AspectN/blob/master/README.md

另外需要注意的是,程式集中的所有方法就像是記憶體,而AspectN就像指標,透過指標操作記憶體時需格外小心。將預期外的型別匹配為池化型別可能會導致同一個物件例項被併發的使用,所以在使用AspectN表示式時儘量使用精確匹配,避免使用模糊匹配。

物件池配置

物件池最大物件持有數量

每個池化型別的物件池最大持有物件數量為邏輯處理器數量乘以2Environment.ProcessorCount * 2,有兩種方式可以修改這一預設設定。

  1. 透過程式碼指定

    透過Pool.GenericMaximumRetained可以設定所有池化型別的物件池最大物件持有數量,透過Pool<T>.MaximumRetained可以設定指定池化型別的物件池最大物件持有數量。後者優先順序高於前者。

  2. 透過環境變數指定

    在應用啟動時指定環境變數可以修改物件池最大持有物件數量,NET_POOLING_MAX_RETAIN用於設定所有池化型別的物件池最大物件持有數量,NET_POOLING_MAX_RETAIN_{PoolItemFullName}用於設定指定池化型別的物件池最大物件持有數量,其中{PoolItemFullName}為池化型別的全名稱(名稱空間.類名),需要注意的是,需要將全名稱中的.替換為_,比如NET_POOLING_MAX_RETAIN_System_Text_StringBuilder。環境變數的優先順序高於程式碼指定,推薦使用環境變數進行控制,更為靈活。

自定義物件池

我們知道官方有一個物件池類庫Microsoft.Extensions.ObjectPool,Pooling沒有直接引用這個類庫而選擇自建物件池,是因為Pooling作為編譯時元件,對方法的呼叫都是透過IL直接織入的,如果引用三方類庫,並且三方類庫在後續的更新對方法簽名有所修改,那麼可能會在執行時丟擲MethodNotFoundException,所以儘量減少三方依賴是編譯時元件最好的選擇。

有的朋友可能會擔心自建物件池的效能問題,可以放心的是Pooling物件池的實現是從Microsoft.Extensions.ObjectPool複製而來,同時精簡了ObjectPoolProvider, PooledObjectPolicy等元素,保持最精簡的預設物件池實現。同時,Pooling支援自定義物件池,實現IPool介面定義通用物件池,實現IPool<T>介面定義特定池化型別的物件池。下面簡單演示如何透過自定義物件池將物件池實現換為Microsoft.Extensions.ObjectPool

// 通用物件池
public class MicrosoftPool : IPool
{
    private static readonly ConcurrentDictionary<Type, object> _Pools = [];

    public T Get<T>() where T : class, new()
    {
        return GetPool<T>().Get();
    }

    public void Return<T>(T value) where T : class, new()
    {
        GetPool<T>().Return(value);
    }

    private ObjectPool<T> GetPool<T>() where T : class, new()
    {
        return (ObjectPool<T>)_Pools.GetOrAdd(typeof(T), t =>
        {
            var provider = new DefaultObjectPoolProvider();
            var policy = new DefaultPooledObjectPolicy<T>();
            return provider.Create(policy);
        });
    }
}

// 特定池化型別物件池
public class SpecificalMicrosoftPool<T> : IPool<T> where T : class, new()
{
    private readonly ObjectPool<T> _pool;

    public SpecificalMicrosoftPool()
    {
        var provider = new DefaultObjectPoolProvider();
        var policy = new DefaultPooledObjectPolicy<T>();
        _pool = provider.Create(policy);
    }

    public T Get()
    {
        return _pool.Get();
    }

    public void Return(T value)
    {
        _pool.Return(value);
    }
}

// 替換操作最好在Main入口直接完成,一旦物件池被使用就不再執行進行替換操作

// 替換通用物件池實現
Pool.Set(new MicrosoftPool());
// 替換特定型別物件池
Pool<Xyz>.Set(new SpecificalMicrosoftPool<Xyz>());

不僅僅用作物件池

雖然Pooling的意圖是簡化物件池操作和無侵入式的專案改造最佳化,但得益於Pooling的實現方式以及提供的自定義物件池功能,你可以使用Pooling完成的事情不僅僅是物件池,Pooling的實現相當於在所有無參構造方法呼叫的地方埋入了一個探針,你可以在這裡做任何事情,下面簡單舉幾個例子。

單例

// 定義單例物件池
public class SingletonPool<T> : IPool<T> where T : class, new()
{
    private readonly T _value = new();

    public T Get() => _value;

    public void Return(T value) { }
}

// 替換物件池實現
Pool<ConcurrentDictionary<Type, object>>.Set(new SingletonPool<ConcurrentDictionary<Type, object>>());

// 透過配置,將ConcurrentDictionary<Type, object>設定為池化型別
// <Item stateless="System.Collections.Concurrent.ConcurrentDictionary&lt;System.Type, object&gt;" />

透過上面的改動,你成功的讓所有的ConcurrentDictionary<Type, object>>共享一個例項。

控制訊號量

// 定義訊號量物件池
public class SemaphorePool<T> : IPool<T> where T : class, new()
{
    private readonly Semaphore _semaphore = new(3, 3);
    private readonly DefaultPool<T> _pool = new();

    public T Get()
    {
        if (!_semaphore.WaitOne(100)) return null;

        return _pool.Get();
    }

    public void Return(T value)
    {
        _pool.Return(value);
        _semaphore.Release();
    }
}

// 替換物件池實現
Pool<Connection>.Set(new SemaphorePool<Connection>());

// 透過配置,將Connection設定為池化型別
// <Item stateless="X.Y.Z.Connection" />

在這個例子中使用訊號量物件池控制Connection的數量,對於一些限流場景非常適用。

執行緒單例

// 定義現成單例物件池
public class ThreadLocalPool<T> : IPool<T> where T : class, new()
{
    private readonly ThreadLocal<T> _random = new(() => new());

    public T Get() => _random.Value!;

    public void Return(T value) { }
}

// 替換物件池實現
Pool<Random>.Set(new ThreadLocalPool<Random>());

// 透過配置,將Connection設定為池化型別
// <Item stateless="System.Random" />

當你想透過單例來減少GC壓力但物件又不是執行緒安全的,此時便可以ThreadLocal實現執行緒內單例。

額外的初始化

// 定義現屬性注入物件池
public class ServiceSetupPool : IPool<Service1>
{
    public Service1 Get()
    {
        var service1 = new Service1();
        var service2 = PinnedScope.ScopedServices?.GetService<Service2>();
        service1.Service2 = service2;

        return service1;
    }

    public void Return(Service1 value) { }
}

// 定義池化型別
public class Service2 { }

[PoolingExclusive(Types = [typeof(ServiceSetupPool)])]
public class Service1 : IPoolItem
{
    public Service2? Service2 { get; set; }

    public bool TryReset() => true;
}

// 替換物件池實現
Pool<Service1>.Set(new ServiceSetupPool());

在這個例子中使用Pooling結合DependencyInjection.StaticAccessor完成屬性注入,使用相同方式可以完成其他初始化操作。

發揮想象力

前面的這些例子可能不一定實用,這些例子的主要目的是啟發大家開拓思路,理解Pooling的基本實現原理是將臨時變數的new操作替換為物件池操作,理解自定義物件池的可擴充套件性。也許你現在用不上Pooling,但未來的某個需求場景下,你可能可以用Pooling快速實現而不需要大量改動程式碼。

注意事項

  1. 不要在池化型別的構造方法中執行復用時的初始化操作

    從物件池中獲取的物件可能是複用的物件,被複用的物件是不會再次執行構造方法的,所以如果你有一些初始化操作希望每次複用時都執行,那麼你應該將該操作獨立到一個方法中並在new操作後呼叫而不應該放在構造方法中

    // 修改前池化物件定義
    public class Connection : IPoolItem
    {
        private readonly Socket _socket;
    
        public Connection()
        {
            _socket = new(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            // 不應該在這裡Connect,應該將Connect操作單獨獨立為一個方法,然後再new操作後呼叫
            _socket.Connect("127.0.0.1", 8888);
        }
    
        public void Write(string message)
        {
            // ...
        }
    
        public bool TryReset()
        {
            _socket.Disconnect(true);
            return true;
        }
    }
    // 修改前池化物件使用
    var connection = new Connection();
    connection.Write("message");
    
    // 修改後池化物件定義
    public class Connection : IPoolItem
    {
        private readonly Socket _socket;
    
        public Connection()
        {
            _socket = new(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        }
    
        public void Connect()
        {
            _socket.Connect("127.0.0.1", 8888);
        }
    
        public void Write(string message)
        {
            // ...
        }
    
        public bool TryReset()
        {
            _socket.Disconnect(true);
            return true;
        }
    }
    // 修改後池化物件使用
    var connection = new Connection();
    connection.Connect();
    connection.Write("message");
    
  2. 僅支援將無參構造方法的new操作替換為物件池操作

    由於複用的物件無法再次執行構造方法,所以構造引數對於池化物件毫無意義。如果希望透過構造引數完成一些初始化操作,可以將新建一個初始化方法接收這些引數並完成初始化,或透過屬性接收這些引數。

    Pooling在編譯時會檢查new操作是否呼叫了無參構造方法,如果呼叫了有參構造方法,將不會將本次new操作替換為物件池操作。

  3. 注意不要將池化型別例項進行持久化儲存

    Pooling的物件池操作是方法級別的,也就是池化物件在當前方法中建立也在當前方法結束時釋放,不可將池化物件持久化到欄位之中,否則會存在併發使用的風險。如果池化物件的宣告週期跨越了多個方法,那麼你應該手動建立物件池並手動管理該物件。

    Pooling在編譯時會進行簡單的持久化排查,對於排查出來的池化物件將不進行池化操作。但需要注意的是,這種排查僅可排查一些簡單的持久化操作,無法排查出複雜情況下的持久化操作,比如你在當前方法中呼叫另一個方法傳入了池化物件例項,然後在被呼叫方法中進行持久化操作。所以根本上還是需要你自己注意,避免將池化物件持久化儲存。

  4. 需要編譯時進行物件池操作替換的程式集都需要引用Pooling.Fody

    Pooling的原理是在編譯時檢查所有方法(也可以透過配置選擇部分方法)的MSIL,排查所有newobj操作完成物件池替換操作,觸發該操作是透過Fody新增了一個MSBuild任務完成的,而只有當前程式集直接引用了Fody才能夠完成新增MSBuild任務這一操作。Pooling.Fody透過一些配置使得直接引用Pooling.Fody也可完成新增MSBuild任務的操作。

  5. 多個Fody外掛同時使用時的注意事項

    當專案引用了一個Fody外掛時,在編譯時會自動生成一個FodyWeavers.xml檔案,如果在FodyWeavers.xml檔案已存在的情況下再引用一個其他Fody外掛,此時再編譯,新的外掛將不會追加到FodyWeavers.xml檔案中,需要手動配置。同時在引用多個Fody外掛時需要注意他們在FodyWeavers.xml中的順序,FodyWeavers.xml順序對應著外掛執行順序,部分Fody外掛可能存在功能交叉,不同的順序可能產生不同的效果。

AspectN

在文章的最後再提一下AspectN,之前一直稱其為AspectJ-Like表示式,因為確實是參照AspectJ表示式的格式設計的,不過一直這麼叫也不是辦法,現在按照慣例更名為AspectN表示式(搜了一下,.NET裡面沒有這個名詞,應該不存在衝突)。AspectN最早起源於肉夾饃2.0,用於提供更加精確的切入點匹配,現在再次投入到Pooling中使用。

在使用Fody或直接使用Mono.Cecil開發MSBuild任務外掛時,如何查詢到需要修改的型別或方法永遠是首要任務。最常用的方式便是透過型別和方法上的Attribute後設資料進行定位,但這樣做基本確定了必須要修改程式碼來新增Attribute應用,這是侵入性的。AspectN提供了非侵入式的型別和方法匹配機制,字串可承載的無窮資訊給予了AspectN無限的精細化匹配可能。很多Fody外掛都可以藉助AspectN實現無侵入式程式碼織入,比如ConfigureAwait.Fody,可以使用AspectN實現透過配置指定哪些型別或方法需要應用ConfigureAwait,哪些不需要。

AspectN不依賴於Fody,僅依賴於Mono.Cecil,如果你有在使用Fody或Mono.Cecil,或許可以嘗試一下AspectN(https://github.com/inversionhourglass/Shared.Cecil.AspectN)。AspectN是一個共享專案(Shared Project),沒有釋出NuGet,也沒有依賴具體Mono.Cecil的版本,使用AspectN你需要將AspectN克隆到本地作為共享專案直接引用,如果你的專案使用git進行管理,那麼推薦將AspectN作為一個submodule新增到你的倉庫中(可以參考RougamoPooling)。

相關文章