.NET性能優化-復用StringBuilder
在之前的文章中,我們介紹了dotnet在字元串拼接時可以使用的一些性能優化技巧。比如:
- 為
StringBuilder
設置Buffer初始大小 - 使用
ValueStringBuilder
等等
不過這些都多多少少有一些局限性,比如StringBuilder
還是會存在new StringBuilder()
這樣的對象分配(包括內部的Buffer)。ValueStringBuilder
無法用於async/await
的上下文等等。都不夠的靈活。
那麼有沒有一種方式既能像StringBuilder
那樣用於async/await
的上下文中,又能減少記憶體分配呢?
其實這可以用到存在很久的一個Tips,那就是想辦法復用StringBuilder
。目前來說復用StringBuilder
推薦兩種方式:
- 使用ObjectPool來創建
StringBuilder
的對象池 - 如果不想單獨創建一個對象池,那麼可以使用
StringBuilderCache
使用ObjectPool復用
這種方式估計很多小夥伴都比較熟悉,在.NET Core的時代,微軟提供了非常方便的對象池類ObjectPool
,因為它是一個泛型類,可以對任何類型進行池化。使用方式也非常的簡單,只需要在引入如下nuget包:
dotnet add package Microsoft.Extensions.ObjectPool
Nuget包中提供了默認的StringBuilder
池化策略StringBuilderPooledObjectPolicy
和CreateStringBuilderPool()
方法,我們可以直接使用它來創建一個ObjectPool:
var provider = new DefaultObjectPoolProvider();
// 配置池中StringBuilder初始容量為256
// 最大容量為8192,如果超過8192則不返回池中,讓GC回收
var pool = provider.CreateStringBuilderPool(256, 8192);
var builder = pool.Get();
try
{
for (int i = 0; i < 100; i++)
{
builder.Append(i);
}
builder.ToString().Dump();
}
finally
{
// 將builder歸還到池中
pool.Return(builder);
}
運行結果如下圖所示:
當然,我們在ASP.NET Core等環境中可以結合微軟的依賴注入框架使用它,為你的項目添加如下NuGet包:
dotnet add package Microsoft.Extensions.DependencyInjection
然後就可以寫下面這樣的程式碼,從容器中獲取ObjectPoolProvider
達到同樣的效果:
var objectPool = new ServiceCollection()
.AddSingleton<ObjectPoolProvider, DefaultObjectPoolProvider>()
.BuildServiceProvider()
.GetRequiredService<ObjectPoolProvider>()
.CreateStringBuilderPool(256, 8192);
var builder = objectPool.Get();
try
{
for (int i = 0; i < 100; i++)
{
builder.Append(i);
}
builder.ToString().Dump();
}
finally
{
objectPool.Return(builder);
}
更加詳細的內容可以閱讀蔣老師關於ObjectPool
的系列文章。
使用StringBuilderCache
另外一個方案就是在.NET中存在很久的類,如果大家翻閱過.NET的一些程式碼,在有字元串拼接的場景可以經常見到它的身影。但是它和ValueStringBuilder
一樣不是公開可用的,這個類叫StringBuilderCache
。
下方所示就是它的源碼,源碼鏈接點擊這裡:
namespace System.Text
{
/// <summary>為每個執行緒提供一個快取的可復用的StringBuilder的實例</summary>
internal static class StringBuilderCache
{
// 這個值360是在與性能專家的討論中選擇的,是在每個執行緒使用儘可能少的記憶體和仍然覆蓋VS設計者啟動路徑上的大部分短暫的StringBuilder創建之間的折衷。
internal const int MaxBuilderSize = 360;
private const int DefaultCapacity = 16; // == StringBuilder.DefaultCapacity
[ThreadStatic]
private static StringBuilder? t_cachedInstance;
// <summary>獲得一個指定容量的StringBuilder.</summary>。
// <remarks>如果一個適當大小的StringBuilder被快取了,它將被返回並清空快取。
public static StringBuilder Acquire(int capacity = DefaultCapacity)
{
if (capacity <= MaxBuilderSize)
{
StringBuilder? sb = t_cachedInstance;
if (sb != null)
{
// 當請求的大小大於當前容量時,
// 通過獲取一個新的StringBuilder來避免Stringbuilder塊的碎片化
if (capacity <= sb.Capacity)
{
t_cachedInstance = null;
sb.Clear();
return sb;
}
}
}
return new StringBuilder(capacity);
}
/// <summary>如果指定的StringBuilder不是太大,就把它放在快取中</summary>
public static void Release(StringBuilder sb)
{
if (sb.Capacity <= MaxBuilderSize)
{
t_cachedInstance = sb;
}
}
/// <summary>ToString()的字元串生成器,將其釋放到快取中,並返回生成的字元串。</summary>
public static string GetStringAndRelease(StringBuilder sb)
{
string result = sb.ToString();
Release(sb);
return result;
}
}
}
這裡我們又複習了ThreadStatic
特性,用於存儲執行緒唯一的對象。大家看到這個設計就知道,它是存在於每個執行緒的StringBuilder
快取,意味著只要是一個執行緒中需要使用的程式碼都可以復用它,不過它的是復用小於360個字元StringBuilder
,這個能滿足絕大多數場景的使用,當然大家也可以根據自己項目實際情況,調整它的大小。
要使用的話,很簡單,我們只需要把這個類拷貝出來,變成一個公共的類,然後使用相同的測試程式碼即可。
跑分及總結
按照慣例,跑個分看看,這裡模擬的是小字元串拼接場景:
using System.Text;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Order;
using BenchmarkDotNet.Running;
using Microsoft.Extensions.ObjectPool;
BenchmarkRunner.Run<Bench>();
[MemoryDiagnoser]
[HtmlExporter]
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
public class Bench
{
private readonly int[] _arr = Enumerable.Range(0,50).ToArray();
[Benchmark(Baseline = true)]
public string UseStringBuilder()
{
return RunBench(new StringBuilder(16));
}
[Benchmark]
public string UseStringBuilderCache()
{
var builder = StringBuilderCache.Acquire(16);
try
{
return RunBench(builder);
}
finally
{
StringBuilderCache.Release(builder);
}
}
private readonly ObjectPool<StringBuilder> _pool = new DefaultObjectPoolProvider().CreateStringBuilderPool(16, 256);
[Benchmark]
public string UseStringBuilderPool()
{
var builder = _pool.Get();
try
{
return RunBench(builder);
}
finally
{
_pool.Return(builder);
}
}
public string RunBench(StringBuilder buider)
{
for (int i = 0; i < _arr.Length; i++)
{
buider.Append(i);
}
return buider.ToString();
}
}
結果如下所示,和我們想像中的差不多。
根據實際的高性能編程來說:
- 程式碼中沒有
async/await
最佳是使用ValueStringBuilder
,前面文章也說明了這一點 - 程式碼中盡量復用
StringBuilder
,不要每次都new()
創建它 - 在方便依賴注入的場景,可以多使用
StringBuilderPool
這個池化類 - 在不方便依賴注入的場景,使用
StringBuilderCache
會更加方便
另外StringBuilderCache
的MaxBuilderSize
和StringBuilderPool
的MaxSize
都快可以根據項目類型和使用調整,像我們實際中一般都會調整到256KB甚至更大。
附錄
本文源碼鏈接://github.com/InCerryGit/RecycleableStringBuilderExample