藉助於有效的自動化垃圾回收機制,.NET讓開發人員不在關心物件的生命週期,但實際上很多效能問題都來源於GC。並不說.NET的GC有什麼問題,而是物件生命週期的跟蹤和管理本身是需要成本的,不論交給應用還是框架來做,都會對效能造成影響。在一些對效能比較敏感的應用中,我們可以通過物件複用的方式避免垃圾物件的產生,進而避免GC因物件回收導致的效能損失。物件池是物件複用的一種常用的方式。
.NET提供了一個簡單高效的物件池框架,並使用在ASP.NET自身框架中。這個物件池狂框架由“Microsoft.Extensions.ObjectPool”這個NuGet包提供,我們可以通過新增這個NuGet包它引入我們的應用中。接下來我們就通過一些簡單的示例來演示一下物件池的基本程式設計模式。
一、物件的借與還
和絕大部分的物件池程式設計方式一樣,當我們需要消費某個物件的時候,我們不會直接建立它,而是選擇從物件池中“借出”一個物件。一般來說,如果物件池為空,或者現有的物件都正在被使用,它會自動幫助我們完成物件的建立。借出的物件不再使用的時候,我們需要及時將其“歸還”到物件池中以供後續複用。我們在使用.NET的物件池框架時,主要會使用如下這個ObjectPool<T>型別,針對池化物件的借與還體現在它的Get和Return方法中。
public abstract class ObjectPool<T> where T: class
{
public abstract T Get();
public abstract void Return(T obj);
}
我們接下來利用一個簡單的控制檯程式來演示物件池的基本程式設計模式。在添加了針對“Microsoft.Extensions.ObjectPool”這個NuGet包的引用之後,我們定義瞭如下這個FoobarService型別來表示希望池化複用的服務物件。如程式碼片段所示,FoobarService具有一個自增整數表示Id屬性作為每個例項的唯一標識,靜態欄位_latestId標識當前分發的最後一個標識。
public class FoobarService
{
internal static int _latestId;
public int Id { get; }
public FoobarService() => Id = Interlocked.Increment(ref _latestId);
}
通過物件池的方式來使用FoobarService物件體現在如下的程式碼片段中。我們通過呼叫ObjectPool型別的靜態方法Create<FoobarService>方法得到針對FoobarService型別的物件池,這是一個ObjectPool<FoobarService>物件。針對單個FoobarService物件的使用體現在本地方法ExecuteAsync中。如程式碼片段所示,我們呼叫ObjectPool<FoobarService>物件的Get方法從物件池中借出一個Foobar物件。為了確定物件是否真的被複用,我們在控制檯上打印出物件的標識。我們通過延遲1秒鐘模擬針對服務物件的長時間使用,並在最後通過呼叫ObjectPool<FoobarService>物件的Return方法將借出的物件釋放到物件池中。
class Program
{
static async Task Main()
{
var objectPool = ObjectPool.Create<FoobarService>();
while (true)
{
Console.Write("Used services: ");
await Task.WhenAll(Enumerable.Range(1, 3).Select(_ => ExecuteAsync()));
Console.Write("\n");
}
async Task ExecuteAsync()
{
var service = objectPool.Get();
try
{
Console.Write($"{service.Id}; ");
await Task.Delay(1000);
}
finally
{
objectPool.Return(service);
}
}
}
}
在Main方法中,我們構建了一個無限迴圈,並在每次迭代中並行執行ExecuteAsync方法三次。演示例項執行之後會在控制檯上輸出如下所示的結果,可以看出每輪迭代使用的三個物件都是一樣的。每次迭代,它們從物件池中被借出,使用完之後又回到池中供下一次迭代使用。
二、依賴注入
我們知道依賴注入是已經成為 .NET Core的基本程式設計模式,針對物件池的程式設計最好也採用這樣的程式設計方式。如果採用依賴注入,容器提供的並不是代表物件池的ObjectPool<T>物件,而是一個ObjectPoolProvider物件。顧名思義, ObjectPoolProvider物件作為物件池的提供者,用來提供針對指定物件型別的ObjectPool<T>物件。
.NET提供的大部分框架都提供了針對IServiceCollection介面的擴充套件方法來註冊相應的服務,但是物件池框架並沒有定義這樣的擴充套件方法,所以我們需要採用原始的方式來完成針對ObjectPoolProvider的註冊。如下面的程式碼片段所示,在創建出ServiceCollection物件之後,我們通過呼叫AddSingleton擴充套件方法註冊了ObjectPoolProvider的預設實現型別DefaultObjectPoolProvider。
class Program
{
static async Task Main()
{
var objectPool = new ServiceCollection().AddSingleton<ObjectPoolProvider, DefaultObjectPoolProvider>()
.BuildServiceProvider()
.GetRequiredService<ObjectPoolProvider>()
.Create<FoobarService>();
…
}
}
在利用ServiceCollection物件創建出代表依賴注入容器的IServiceProvider物件之後,我們利用它提取出ObjectPoolProvider物件,並通過呼叫其Create<T>方法得到表示物件池的ObjectPool<FoobarService>物件。改動的程式執行之後同樣會在控制檯輸出如上圖所示的結果。
三、池化物件策略
通過前面的例項演示可以看出,物件池在預設情況下會幫助我們完成物件的建立工作。我們可以想得到,它會在物件池無可用物件的時候會呼叫預設的建構函式來建立提供的物件。如果池化物件型別沒有預設的建構函式呢?或者我們希望執行一些初始化操作呢?
在另一方面,當不在使用的物件被歸還到物件池之前,很有可能會執行一些釋放性質的操作(比如集合物件在歸還之前應該被清空)。還有一種可能是物件有可能不能再次複用(比如它內部維護了一個處於錯誤狀態並無法恢復的網路連線),那麼它就不能被釋放會物件池。上述的這些需求都可以通過IPooledObjectPolicy<T>介面表示的池化物件策略來解決。
同樣以我們演示例項中使用的FoobarService型別,如果並不希望使用者直接呼叫建構函式來建立對應的例項,所以我們按照如下的方式將其建構函式改為私有,並定義了一個靜態的工廠方法Create來建立FoobarService物件。當FoobarService型別失去了預設的無參建構函式之後,我們演示的程式將無法編譯。
public class FoobarService
{
internal static int _latestId;
public int Id { get; }
private FoobarService() => Id = Interlocked.Increment(ref _latestId);
public static FoobarService Create() => new FoobarService();
}
為了解決這個問題,我們為FoobarService型別定義一個代表池化物件策略的FoobarPolicy型別。如程式碼片段所示,FoobarPolicy型別實現了IPooledObjectPolicy<FoobarService>介面,實現的Create方法通過呼叫FoobarSerivice型別的靜態同名方法完成針對物件的建立。另一個方法Return可以用來執行一些物件歸還前的釋放操作,它的返回值表示該物件還能否回到池中供後續使用。由於FoobarService物件可以被無限次複用,所以實現的Return方法直接返回True。
public class FoobarPolicy : IPooledObjectPolicy<FoobarService>
{
public FoobarService Create() => FoobarService.Create();
public bool Return(FoobarService obj) => true;
}
在呼叫ObjectPoolProvider物件的Create<T>方法針對指定的型別建立對應的物件池的時候,我們將一個IPooledObjectPolicy<T>物件作為引數,建立的物件池將會根據該物件定義的策略來建立和釋放物件。
class Program
{
static async Task Main()
{
var objectPool = new ServiceCollection().AddSingleton<ObjectPoolProvider, DefaultObjectPoolProvider>()
.BuildServiceProvider()
.GetRequiredService<ObjectPoolProvider>()
.Create(new FoobarPolicy());
…
}
}
四、物件池的大小
物件池容納物件的數量總歸是有限的,預設情況下它的大小為當前機器處理器數量的2倍,這一點可以通過一個簡單的例項來驗證一下。如下面的程式碼片段所示,我們將演示程式中每次迭代併發執行ExecuteAsync方法的數量設定為當前機器處理器數量的2倍,並將最後一次建立的FoobarService物件的ID打印出來。為了避免控制檯上的無效輸出,我們將ExecuteAsync方法中的控制檯輸出程式碼移除。
class Program
{
static async Task Main()
{
var objectPool = new ServiceCollection().AddSingleton<ObjectPoolProvider, DefaultObjectPoolProvider>()
.BuildServiceProvider()
.GetRequiredService<ObjectPoolProvider>()
.Create(new FoobarPolicy());
var poolSize = Environment.ProcessorCount * 2;
while (true)
{
while (true)
{
await Task.WhenAll(Enumerable.Range(1, poolSize).Select(_ => ExecuteAsync()));
Console.WriteLine($"Last service: {FoobarService._latestId}");
}
} async Task ExecuteAsync()
{
var service = objectPool.Get();
try
{
await Task.Delay(1000);
}
finally
{
objectPool.Return(service);
}
}
}
}
上面這個演示例項表達的意思是:物件池的大小和物件消費率剛好是一致的。在這種情況下,消費的每一個物件都是從物件池中提取出來,並且能夠成功還回去,那麼物件的建立數量就是物件池的大小。下圖所示的是演示程式執行之後再控制檯上的輸出結果,整個應用的生命週期範圍內一共只會有16個物件被創建出來,因為我當前機器的處理器數量為8。
如果物件池的大小為當前機器處理器數量的2倍,那麼我們倘若將物件的消費率提高,意味著池化的物件將無法滿足消費需求,新的物件將持續被創建出來。為了驗證我們的想法,我們按照如下的方式將每次迭代執行任務的數量加1。
class Program
{
static async Task Main()
{
var objectPool = new ServiceCollection().AddSingleton<ObjectPoolProvider, DefaultObjectPoolProvider>()
.BuildServiceProvider()
.GetRequiredService<ObjectPoolProvider>()
.Create(new FoobarPolicy());
var poolSize = Environment.ProcessorCount * 2;
while (true)
{
while (true)
{
await Task.WhenAll(Enumerable.Range(1, poolSize + 1)
.Select(_ => ExecuteAsync()));
Console.WriteLine($"Last service: {FoobarService._latestId}");
}
}
…
}
}
再次執行改動後的程式,我們會在控制檯上看到如下圖所示的輸出結果。由於每次迭代針對物件的需求量是17,但是物件池只能提供16個物件,所以每次迭代都必須額外建立一個新的物件。
五、物件的釋放
由於物件池容納的物件數量是有限的,如果現有的所有物件已經被提取出來,它會提供一個新建立的物件。從另一方面講,我們從物件池得到的物件在不需要的時候總是會還回去,但是物件池可能容不下那麼多物件,它只能將其丟棄,被丟棄的物件將最終被GC回收。如果物件型別實現了IDisposable介面,在它不能回到物件池的情況下,它的Dispose方法應該被立即執行。
為了驗證不能正常回歸物件池的物件能否被及時釋放,我們再次對演示的程式作相應的修改。我們讓FoobarService型別實現IDisposable介面,並在實現的Dispose方法中將自身ID輸出到控制檯上。然後我們按照如下的方式以每次迭代併發量高於物件池大小的方式消費物件。
class Program
{
static async Task Main()
{
var objectPool = new ServiceCollection().AddSingleton<ObjectPoolProvider, DefaultObjectPoolProvider>()
.BuildServiceProvider()
.GetRequiredService<ObjectPoolProvider>()
.Create(new FoobarPolicy()); while (true)
{
Console.Write("Disposed services:");
await Task.WhenAll(Enumerable.Range(1, Environment.ProcessorCount * 2 + 3).Select(_ => ExecuteAsync()));
Console.Write("\n");
} async Task ExecuteAsync()
{
var service = objectPool.Get();
try
{
await Task.Delay(1000);
}
finally
{
objectPool.Return(service);
}
}
}
} public class FoobarService: IDisposable
{
internal static int _latestId;
public int Id { get; }
private FoobarService() => Id = Interlocked.Increment(ref _latestId);
public static FoobarService Create() => new FoobarService();
public void Dispose() => Console.Write($"{Id}; ");
}
演示程式執行之後會在控制檯上輸出如下圖所示的結果,可以看出對於每次迭代消費的19個物件,只有16個能夠正常回歸物件池,有三個將被丟棄並最終被GC回收。由於這樣的物件將不能被複用,它的Dispose方法會被呼叫,我們定義其中的釋放操作得以被及時執行。