编写高性能的C#代码之字符串的另类骚操作

作者介绍:

史蒂夫·戈登(Steve Gordon)是Microsoft MVP,Pluralsight的作者,布莱顿(英国西南部城市)的高级开发人员和社区负责人。

在本文中,我将继续有关编写高性能C#和.NET代码的系列文章[1]。这次,我将重点介绍String类型–

String.Create

-一种可用的新方法。.NET Core 2.1中首次引入该方法,目前计划将该方法发布后作为.NET Standard 2.1的一部分包含在内。.

STRING.CREATE做什么?

String.Create方法支持有效创建需要在运行时构建或计算的字符串。在我进一步讨论之前,让我们花一点时间来介绍有关字符串的一些事实。

•在.NET中,字符串是一种流行的类型,用于表示文本数据。•字符串是引用类型,它们的数据存储在托管堆中。•根据设计,字符串是不可变的,这意味着一旦创建,就无法修改其数据。

从高性能的角度来看,这些事实的结合会导致字符串出现问题。从高层次上讲,我们编写高性能代码的目标通常是减少运行该代码的执行时间,并删除内存分配。

由于其不变性,对字符串进行操作通常会导致分配过多。如果要提取字符串的一部分,则会导致创建新字符串以及在旧字符串和新字符串占用的内存之间复制字符串数据。如果我们想将字符串转换为大写,这也会导致在堆上分配新的字符串。

如果我们想使用仅在运行时可用的数据以编程方式创建字符串,则也会出现问题。串联字符串也将导致分配和复制。对于长字符串,尤其是由许多组成部分组成的字符串,此成本可能会显着增加。

这并不意味着在适当的时候不应该使用字符串,但是在编写高度优化的代码时就成为一个问题。

在运行时构造字符串时使用的标准解决方案是使用StringBuilder,该StringBuilder使用附加了字符的内部缓冲区。当您在StringBuilder上调用build方法时,这将导致最终的字符串分配。

当串联多个元素时,StringBuilder通常比普通串联更有效(始终使用基准测试来验证您的方案)。

StringBuilder仍然需要字符的中间缓冲区,因此在那里要分配堆,并在从缓冲区构建字符串时再加上一个副本。

StringBuilder本身是一个类,因此使用其中的分配。

在ASP.NET Core团队已经在热路径上通过池化和共享StringBuilder的实例来解决这个分配成本问题,这是有意义的,例如在中间件等地方。

什么时候使用STRING.CREATE?

在日常开发过程中,不需要String.Create。它有一个特定的目的,即以高度优化的方式从某些现有数据实用地创建字符串,或者可能仅通过算法来创建字符串。

在这种情况下,主要的优化是帮助我们避免不必要的分配和数据复制。我们将在几分钟后看一个可行的示例,但在此之前,让我们考虑一些更通用的用例。

在用于ASP.NET Core的Kestrel Web服务器中,每个请求都会创建唯一的ID。在这种情况下,要求构建一个长度和格式已知的字符串,该字符串将唯一地标识请求。由于此操作每秒可能完成数千次,因此使其性能良好至关重要。String.Create允许在这种情况下有效地构造字符串。

STRING.CREATE如何工作?

String.Creates提供了一个非常短的窗口,允许我们从本质上打破字符串的不变性规则。这听起来有些吓人,但还不如我讲的那么糟糕。可能发生数据突变的窗口仅在返回对字符串的第一个引用之前。在此简短窗口之后,将无法修改现有字符串的数据。

在内部,String.Create在堆上分配适当的内存部分,以包含字符串数据的char数组。为此,该方法将字符串所需的长度作为第一个参数。这是一个重要的限制,您必须知道或能够预先计算出要创建的字符串的确切字符长度。

这是Create方法的签名:

 public static string Create<TState> (int length, TState state, System.Buffers.SpanAction<char,TState> action);

该方法采用第二个参数,这是构造字符串所需的一般状态。一会儿我们来专门介绍这个状态。 

最后,create方法接受一个委托,该委托应在分配的堆内存上进行操作以设置最终的字符串数据。在这种情况下,参数是SpanAction,它在System.Buffers中定义。

由于Span 类型不能用作泛型类型参数,因此不能使用标准的Action委托。相反,SpanAction支持采用将用作内部Span 的类型的类型。在这种情况下,我们正在处理字符。

SpanAction委托就是魔力所在。在分配了字符串所需的char []内存之后,然后可以使用我们传递的委托来填充该数组中的字符。委托完成后,将返回内部使用该数组的字符串,并已正确设置其值。

让我们考虑一下不使用此方法即可构建字符串的最低分配方式之一。我们可能会使用临时char数组作为缓冲区来构建字符串数据,然后将该数组传递给字符串的构造函数。这基本上就是StringBuilder为我们所做的。这种方法将导致两种分配,一种分配给缓冲区,另一种分配给字符串。所涉及的阵列之间也会发生一些内存复制。

可能是这样的:

 using System;

    namespace StringCreateSample    {        class Program        {            private const char spaceSeparator = ' '; // space separator character

            static void Main()            {                // Our source data (state) which will be composed into the final string.                var context = new ContextData                {                    FirstString = "Hello",                    SecondString = ".NET",                    ThirdString = "friends."                };

                var length = context.FirstString.Length + 1 +                     context.SecondString.Length + 1 +                     context.ThirdString.Length;

                var buffer = new char[length]; // allocation

                var position = 0;

                for (var i = 0; i < context.FirstString.Length; i++)                {                    buffer[i] = context.FirstString[i];                    position++;                }

                buffer[position++] = spaceSeparator;

                for (var i = 0; i < context.SecondString.Length; i++)                {                    buffer[position++] = context.SecondString[i];                }

                buffer[position++] = spaceSeparator;

                for (var i = 0; i < context.ThirdString.Length; i++)                {                    buffer[position++] = context.ThirdString[i];                }

                Console.WriteLine(new string(buffer)); // string allocation + copy            }        }

        internal struct ContextData        {            public string FirstString { get; set; }            public string SecondString { get; set; }            public string ThirdString { get; set; }        }    }

另一个选择是使用不安全的代码,或者在.NET Core 2.1及更高版本中,我们可以使用Span 支持来安全地使用小的堆栈分配缓冲区,而不是堆分配的数组。

只要缓冲区的大小不是太大,这将是一个不错的选择,并且我们将仅针对最后一个字符串进行一次堆分配。

但是,将需要一个副本来将数据从堆栈内存中移到字符串堆内存中。这具有很小的执行时间成本。 我们的示例Main方法中为实现此目的所做的更改如下所示:

static void Main()    {        // Our source data (state) which will be composed into the final string.        var context = new ContextData        {            FirstString = "Hello",            SecondString = ".NET",            ThirdString = "friends."        };

        var length = context.FirstString.Length + 1 +             context.SecondString.Length + 1 +             context.ThirdString.Length;

        // In real-world code we should ensure we don't try to allocate too much on the stack!        // Ignoring that risk for this example.

        Span<char> buffer = stackalloc char[length]; // DOES NOT heap allocate

        var position = 0;

        for (var i = 0; i < context.FirstString.Length; i++)        {            buffer[i] = context.FirstString[i];            position++;        }

        buffer[position++] = spaceSeparator;

        for (var i = 0; i < context.SecondString.Length; i++)        {            buffer[position++] = context.SecondString[i];        }

        buffer[position++] = spaceSeparator;

        for (var i = 0; i < context.ThirdString.Length; i++)        {            buffer[position++] = context.ThirdString[i];        }

        Console.WriteLine(new string(buffer)); // string allocation + copy from stack memory    }

回到String.Create,我们现在可以了解这如何为我们提供最佳性能。通过避免对字符进行预缓冲(即使该字符在堆栈中),这意味着用于构造字符串的逻辑将直接作用于该字符串将引用的存储器的最终区域。

正确完成后,我们可以以编程方式构建字符串,而无需中间分配,并且具有很高的性能。 在SpanAction中,我们可以通过字符串占用的内存访问Span 。我们可以通过Span修改该内存,将其切成适当的位置并将字符写入基础数组。

传入的状态将允许我们使用现有数据来构建字符串。您可能已经在想一个重要的问题。为什么将状态直接传递给Create方法?为什么我们不能仅仅从委托代码中引用我们需要的数据?

原因是,如果我们捕获变量,则后一种方法将导致关闭。编译器将必须生成一个类来处理此问题,这是我们在此处要避免的堆分配。

另外,这里的关闭将防止委托的缓存,这本身就是我们无法承受的性能损失。相反,Create方法接受状态作为参数,以避免委托形成闭包。

解释起来有点复杂,但是这里的要点是确保状态中需要包含为创建字符串而需要访问的所有对象。

如果要传递多个对象,建议的模式是使用ValueTuple[2]。由于这是一个结构,因此它不会分配任何内容,一旦进入委托,您就可以对其进行解构以获取组成部分。

使用STRING.CREATE的快速示例

在深入研究真实示例之前,让我们快速看一下如何使用String.Create。

using System;

    namespace StringCreateSample    {        class Program        {            private const char spaceSeparator = ' '; // space separator character

            static void Main()            {                // Our source data (state) which will be composed into the final string.                var context = new ContextData                {                    FirstString = "Hello",                    SecondString = ".NET",                    ThirdString = "friends."                };

                var length = context.FirstString.Length + 1 +                     context.SecondString.Length + 1 +                     context.ThirdString.Length;
                var myString = string.Create(length, context, (chars, state) =>                {                    // NOTE: We don't access the context variable in this delegate since                     // it would cause a closure and allocation.                    // Instead we access the state parameter.

                    // will track our position within the string data we are populating                    var position = 0;

                    // copy the first string data to index 0 of the Span<char>                    state.FirstString.AsSpan().CopyTo(chars);                    position += state.FirstString.Length; // update the position

                    // add a space in the current position and increement position by 1                    chars[position++] = spaceSeparator;

                    // copy the second string data to a slice at current position                    state.SecondString.AsSpan().CopyTo(chars.Slice(position));                     position += state.SecondString.Length; // update the position

                    // add a space in the current position and increement position by 1                    chars[position++] = spaceSeparator;

                    // copy the third string data to a slice at current position                    state.ThirdString.AsSpan().CopyTo(chars.Slice(position));                 });

                Console.WriteLine(myString);            }        }

        internal struct ContextData        {            public string FirstString { get; set; }            public string SecondString { get; set; }            public string ThirdString { get; set; }        }    }

这段代码中的注释逐步说明了正在发生的事情。 从上面我们可以看出一个结论,我们有一个ContextData对象,其中包含三个我们要用来构建最终字符串的字符串。

首先,我们计算最终字符串所需的长度,该长度包括组成部分及其之间的间距。

我们将长度传递给string.Create并将上下文作为状态参数传递。

最后,我们定义SpanAction委托的代码,该代码切片为基础Span ,以将组件部分复制到最终字符串中的正确位置。所有这些都是通过为字符串所需的内存分配单个堆来实现的。

如何使用STRING.CREATE –一个真实的例子

现在,让我们根据我遇到的实际情况看一个可行的示例。请注意,这仍然是演示代码。它基于我的生产要求,但是我已经对其进行了简化,以便我们可以专注于特定技术。我有把握地确定它可以进一步优化!

在我的演讲“Turbocharged: Writing High-Performance C# and .NET Code”中,我讨论了一个服务示例,其中,从AWS SQS队列中读取消息后,我需要将消息正文存储到S3存储桶中。

将内容存储到S3中时,我们必须为对象提供唯一的键。因此,此服务必须计算在上载对象时传递到AWS开发工具包的密钥。在我们的案例中,这种情况每天发生1800万次,因此即使是很小的性能提升也会对规模产生重大影响。

密钥由传入消息中的八个元素组成。最终键中仅允许使用小写字母,数字和下划线,并且任何空格都应转换为下划线。构造字符串的第一种方法是使用数组固定组成部分,然后将各个片段连接在一起以形成最终字符串。我不会在这篇文章中显示所有代码,但是您可以在我的GitHub repo中[3]查看一个示例[4]

第二次迭代使用堆栈分配的字符数组作为缓冲区,以形成字符串的最终数据。通过在该内存上使用Span ,我便能够将各种元素复制到堆栈分配的缓冲区中。在Span 上调用ToString导致创建了对象键的最终字符串。再次,我不会在这里显示该代码,因为它很长。如果您想签出,也可以在我的仓库中[5]找到。

在最后的迭代中,我利用了String.Create,这意味着我可以避免将内存从堆栈分配的缓冲区复制到字符串的堆内存中。如果您想浏览该代码,也可以在我的GitHub repo中找到[6]

请记住,这些样本尚未完全优化,其设计目的是演示某些特定技术而非完整的优化。在我的案例中,String.Create在运行的基准测试中仅稍快一些。将来,我将对此进行更深入的探讨。这是我比较这两种方法的基准结果。

编写高性能的C#代码之字符串的另类骚操作
图片

在大多数情况下,String.Create方法的速度要快几纳秒,但是在某些基准测试运行中,它的速度要慢几纳秒。潜在地,我可以对转换逻辑进行一些进一步的优化,从而可以解决这一问题。从逻辑上讲,将数据从堆栈内存复制到字符串堆内存所需的工作较少,应该会更有效率,但是对于您的实际情况而言,它始终值得测试。

为了对此进行研究,我对纯String.Create和stackalloc创建进行了一些基准测试。对于较短的字符串,stackalloc似乎只快一点。这是一个基准测试,在此基准下,我使用两种方法将10个字符的短字符串组合在一起。在这种情况下,每个测试中组合的字符串数中的计数。只有五个项目,根本没有太多。到组合100个字符串时,使用String.Create带来的性能提升更加明显。

编写高性能的C#代码之字符串的另类骚操作
图片

如果您对String.Create的另一个示例用例感兴趣,我已经在ASP.NET Core基于代码的地方确定了String.Create应该改善性能的地方。我提出了一个GitHub问题来[7]证明这一点,并希望参与创建PR以提出最终优化方案。

字符串创建最佳实践

这篇文章中已经有很多信息可以解释一个方法。最后,让我们回顾最重要的几点。

•String.Create提供了一种高性能,低分配的方法来以编程方式创建字符串。•与所有性能优化一样,对原始解决方案进行基准测试,并确保所做的更改具有积极作用。•避免闭包,并确保不要在SpanAction委托中捕获外部变量。•使用ValueTuples可以为状态传递多个对象。

STRING.CREATE的局限性

与您可能熟悉的其他一些创建新字符串的方法相比,使用String.Create涉及更多。我不建议在每个地方都使用此功能,但是在性能较高的应用程序中,它可能会提供一些有价值的收益。

您可能遇到的最大限制是,您必须事先知道(或能够计算)所需字符串的确切长度。您可能需要访问所有组成状态对象的长度,以便计算最终字符串的长度。在某些情况下,构建字符串时有很多条件逻辑,仅知道部件的长度可能还不够。

摘要

String.Create在高性能方案中很有用。一旦了解了它的运行规则,就可以直接使用它。因此,如果您正在优化应用程序中的热路径,那么它是一个值得记住的工具,并且在解析和生成字符串(通常是其主要功能的一部分)的应用程序中可能会获得重大收益。

谢谢阅读!如果您想了解有关高性能.NET和C#代码的更多信息,可以在此处[8]查看我的完整博客文章系列。