.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池化策略StringBuilderPooledObjectPolicyCreateStringBuilderPool()方法,我們可以直接使用它來創建一個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會更加方便

另外StringBuilderCacheMaxBuilderSizeStringBuilderPoolMaxSize都快可以根據項目類型和使用調整,像我們實際中一般都會調整到256KB甚至更大。

附錄

本文源碼鏈接://github.com/InCerryGit/RecycleableStringBuilderExample