在之前的文章中,我们介绍了 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
的系列文章[1]。
使用 StringBuilderCache
另外一个方案就是在.NET 中存在很久的类,如果大家翻阅过.NET 的一些代码,在有字符串拼接的场景可以经常见到它的身影。但是它和ValueStringBuilder
一样不是公开可用的,这个类叫StringBuilderCache
。下方所示就是它的源码,源码链接点击这里[2]:
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 甚至更大。
附录
本文源码链接:
https://github.com/InCerryGit/RecycleableStringBuilderExample
参考资料
[1]
系列文章: https://www.cnblogs.com/artech/p/object-pool-01.html
[2]源码链接点击这里: https://github.com/dotnet/runtime/blob/main/src/libraries/Common/src/System/Text/StringBuilderCache.cs