做者:Casey McQuillangit
译者:精致码农github
原文:http://dwz.win/YVW算法
说明:原文比较长,翻译时精简了不少内容,对于不重要的细枝末节只用了一句话归纳,但不并影响阅读。数组
你还记得上一次一个无足轻重的细节点燃你思考火花的时刻吗?做为一个软件工程师,我习惯于专一于一个从未见过的微小细节。那一时刻,我大脑的齿轮会开始转动,我喜欢这样的时刻。安全
最近,我在逛 Twitter 时发生了一件事。我看到了 David Fowler 和 Damian Edwards 之间的这段交流,他们讨论了 .NET 的 Span<T>
API。我之前使用过 Span<T>
API,但我在推文中发现了一些不同的新东西。bash
上面使用的 String.Create
方法是我从未见过的用法。我决定要揭开 String.Create
的神秘面纱。此时我在问本身一个问题:并发
为何用这个方法建立字符串而不用其它的?app
我便开始探索,它把我带到了一些有趣的地方,我想和你分享。在本文中,咱们将深刻探讨几个话题:函数
String.Create
与其它 API 有什么不一样?String.Create
作得更好的是什么,它如何让个人 C# 代码更快?String.Create
的性能能提升多少?为了书写方便,我将用下面的词来指代 .NET 中的几个 API:性能
String.Create()
String.Concat()
或+
操做符StringBuilder
构造字符串或使用其流式 API。.NET Core 代码库是在 GitHub 开源的,这提供了一个很好的机会来深刻分析微软本身的实践。他们提供了 Create API,因此看看他们如何使用它,应该能找到有价值的发现。让咱们从深刻了解 String
对象及其相关 API 开始。
要想从原始字符数据中构造一个 string
,你须要使用构造函数,它须要一个指向 char
数组的指针。若是直接使用这个 API,则须要将单个字符放入特定的数组位置。下面是使用这个构造函数分配一个字符串的代码。建立字符串的方法还有不少,但这是我认为与 Create 方法最相近的。
string Ctor(char[]? value) { if (value == null || value.Length == 0) return Empty; string result = FastAllocateString(value.Length); Buffer.Memmove( elementCount: (uint)result.Length, // derefing Length now allows JIT to prove 'result' not null below destination: ref result._firstChar, source: ref MemoryMarshal.GetArrayDataReference(value)); return result; }
这里的两个重要步骤是:
FastAllocateString
分配内存。FastAllocateString
是在 .NET Runtime 中实现的,它几乎是全部字符串分配内存的基础。Buffer.Memmove
,它将原来数组中的全部字节复制到新分配的字符串中。要使用这个构造函数,咱们须要向它提供一个 char
数组。在它的工做完成后,咱们最终会获得一个(当前没必要要的)char
数组和一个字符串,数组有与字符串相同的数据。若是咱们要修改原来的数组,字符串是不会被修改的,由于它是一个独立的、不一样的数据副本。在高性能的 .NET 环境中,节省对象和数组的内存分配是很是有价值的,由于它减小了 .NET 垃圾回收器每次运行时须要作的工做。每个留在内存中的额外对象都会增长收集的频率,并损耗总性能。
为了与构造函数造成对比,并消除这种没必要要的内存分配,咱们来看一下 Create 方法的代码。
public static string Create<TState>(int length, TState state, SpanAction<char, TState> action) { if (action == null) throw new ArgumentNullException(nameof(action)); if (length <= 0) { if (length == 0) return Empty; throw new ArgumentOutOfRangeException(nameof(length)); } string result = FastAllocateString(length); action(new Span<char>(ref result.GetRawStringData(), length), state); return result; }
步骤类似,但有一个关键的区别:
FastAllocateString
根据 length
参数分配内存。string
转换为 Span<char>
。action
,并将 Span<char>
实例与 state
做为参数。这种方法避免了多余的内存分配,由于它容许咱们传入 SpanAction
,这是一组有关如何建立字符串的方法,而不是要求咱们将须要放入字符串中的全部字节进行二次复制。
对比上面两张图,图二的 Create 比图一构造函数少了一块内存分配。
此时,你可能会对Create方法感到好奇,但你不必定知道为何它比你以前使用过的方法更好。Create API 的用处是因地制宜的,但在适当的状况下,它能够发挥极大的威力。
只有当你已经知道最终字符串的长度时,你才能使用Create方法。然而,你能够创造性地使用这个约束,并发现几种利用Create的方法。我在 dotnet/aspnetcore 和 dotnet/runtime 的代码库中进行了搜索,看看微软团队在哪些地方用了这个API。
下面这个类来自 ASP.NET Core 仓库,用来为每一个Web请求生成相关ID。这些ID的格式由数字(0-9)和大写字母(A-V)组成。
// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; using System.Threading; namespace Microsoft.AspNetCore.Connections { internal static class CorrelationIdGenerator { // Base32 encoding - in ascii sort order for easy text based sorting private static readonly char[] s_encode32Chars = "0123456789ABCDEFGHIJKLMNOPQRSTUV".ToCharArray(); // Seed the _lastConnectionId for this application instance with // the number of 100-nanosecond intervals that have elapsed since 12:00:00 midnight, January 1, 0001 // for a roughly increasing _lastId over restarts private static long _lastId = DateTime.UtcNow.Ticks; public static string GetNextId() => GenerateId(Interlocked.Increment(ref _lastId)); private static string GenerateId(long id) { return string.Create(13, id, (buffer, value) => { char[] encode32Chars = s_encode32Chars; buffer[12] = encode32Chars[value & 31]; buffer[11] = encode32Chars[(value >> 5) & 31]; buffer[10] = encode32Chars[(value >> 10) & 31]; buffer[9] = encode32Chars[(value >> 15) & 31]; buffer[8] = encode32Chars[(value >> 20) & 31]; buffer[7] = encode32Chars[(value >> 25) & 31]; buffer[6] = encode32Chars[(value >> 30) & 31]; buffer[5] = encode32Chars[(value >> 35) & 31]; buffer[4] = encode32Chars[(value >> 40) & 31]; buffer[3] = encode32Chars[(value >> 45) & 31]; buffer[2] = encode32Chars[(value >> 50) & 31]; buffer[1] = encode32Chars[(value >> 55) & 31]; buffer[0] = encode32Chars[(value >> 60) & 31]; }); } } }
算法很简单:
character_index * 5
)位,获取最右边的5位(shifted_value & 31
),并根据预先肯定的字符表(encode32Chars
)选择一个字符,从后向前填充到buffer
。译者注:64位的整数,每5位一划分可划为13段,前十二段为5位,最后一段为4位。之因此5位一划分是由于 2^5-1=31,能够确保字符表(
encode32Chars
)的每一个字符均可以被索引到(encode32Chars[31]
为V
)。若以4位划分,则最大的索引是15,字符表就有一半的字符轮空。
咱们用 StringBuilder 做为咱们比较对象。我之因此选择StringBuilder,是由于它一般被推荐为常规字符串拼接性能较好的API。我写了额外的实现,尝试使用StringBuilder(有容量)、StringBuilder(无容量)和简单拼接。
运行性能 Benchmarks:
内存分配 Benchmarks:
String.Create()
方法在性能(16.58纳秒)和内存分配(只有48 bytes)方面表现得最好。
C# Roslyn 编译器在优化字符串拼接时很是聪明。编译器会倾向于将屡次使用加号 +
运算符转换为对 Concat 的单次调用,而且极可能有许多我不知道的额外技巧。因为这些缘由,拼接一般是一个快速的操做,但在简单场景下,它仍然能够用 Create 替代。
用 Create 方法演示拼接的示例代码:
public static class ConcatenationStringCreate { public static string Concat(string first, string second) { first ??= string.Empty; second ??= String.Empty; bool addSpace = second.Length > 0; int length = first.Length + (addSpace ? 1 : 0) + second.Length; return string.Create(length, (first, second, addSpace), (dst, v) => { ReadOnlySpan<char> prefix = v.first; prefix.CopyTo(dst); if (v.addSpace) { dst[prefix.Length] = ' '; ReadOnlySpan<char> detail = v.second; detail.CopyTo(dst.Slice(prefix.Length + 1, detail.Length)); } }); } }
我在 .NET Core 源代码中只找到一个真正的例子后,就写了这个特殊的示例。这像是一个能够合理抽象的示例,而且能够在重度使用加号 +
操做符或 String.Concat
的代码库中使用。
下面是运行性能和内存分配的 Benchmarks:
Create 要比 Concat (加号 +
操做符或 String.Concat
)快那么几个百分点。对于大部分场景,Concat 拼接的性能仍是能够的,不须要封装 Create 方法作优化。但若是你是以每秒几百万的速度拼接字符串(好比一个高流量的Web应用),性能提升几个百分点也是值得的。
String.Create 虽然有较好的性能,但通常只在性能要求较高场景下使用。一个良好的系统取决于不少指标,做为软件工程师,咱们不能只追求性能指标,而忽略了大局。通常来讲,我认为简洁可维护的代码应该优于梦幻般的性能。
本文性能测试的有关代码都放在了 GitHub:
https://github.com/cmcquillan/StringCreateBenchmarks