10

深入解析 C# 的 String.Create 方法

 3 years ago
source link: https://www.cnblogs.com/willick/p/14152115.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

深入解析 C# 的 String.Create 方法

作者:Casey McQuillan

译者:精致码农

原文:http://dwz.win/YVW

说明:原文比较长,翻译时精简了很多内容,对于不重要的细枝末节只用了一句话概括,但不并影响阅读。

你还记得上一次一个无足轻重的细节点燃你思考火花的时刻吗?作为一个软件工程师,我习惯于专注于一个从未见过的微小细节。那一时刻,我大脑的齿轮会开始转动,我喜欢这样的时刻

最近,我在逛 Twitter 时发生了一件事。我看到了 David Fowler 和 Damian Edwards 之间的这段交流,他们讨论了 .NET 的 Span<T> API。我以前使用过 Span<T> API,但我在推文中发现了一些不一样的新东西。

1_5-KiYiQ3Oqq6muE17DD5dA.png

上面使用的 String.Create 方法是我从未见过的用法。我决定要揭开 String.Create 的神秘面纱。此时我在问自己一个问题:

为什么用这个方法创建字符串而不用其它的?

我便开始探索,它把我带到了一些有趣的地方,我想和你分享。在本文中,我们将深入探讨几个话题:

  • String.Create 与其它 API 有什么不同?
  • String.Create 做得更好的是什么,它如何让我的 C# 代码更快?
  • String.Create 的性能能提高多少?

为了书写方便,我将用下面的词来指代 .NET 中的几个 API:

  • Create — 指代 String.Create()
  • Concat — 指代 String.Concat()+操作符
  • StringBuilder — 指代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;
}

步骤相似,但有一个关键的区别:

  1. FastAllocateString 根据 length 参数分配内存。
  2. 将新分配的 string 转换为 Span<char>
  3. 调用 action,并将 Span<char> 实例与 state 作为参数。

这种方法避免了多余的内存分配,因为它允许我们传入 SpanAction,这是一组有关如何创建字符串的方法,而不是要求我们将需要放入字符串中的所有字节进行二次复制。

1_zqXvXhg7cqnvhVNHBU2-og.png
1_cXNibkdYiFJtpr3ZDZiTjA.png

对比上面两张图,图二的 Create 比图一构造函数少了一块内存分配。

String.Create 好在哪

此时,你可能会对Create方法感到好奇,但你不一定知道为什么它比你之前使用过的方法更好。Create API 的用处是因地制宜的,但在适当的情况下,它可以发挥极大的威力。

  • 它会预先分配一块内存空间,然后给你一个接口来安全地填充这个空间。其他创建字符串的方法可能需要编写不安全代码或管理缓冲池。
  • 它避免了对数据进行额外的复制操作,这通常使内存的分配更少。这也减少了来自垃圾收集器的压力,可以加快程序的整体效率。
  • 它允许你将高性能代码集中在应用程序的业务需求上,而不是将你的字符串构建代码与复杂的内存管理交织在一起。

ID生成器示例

只有当你已经知道最终字符串的长度时,你才能使用Create方法。然而,你可以创造性地使用这个约束,并发现几种利用Create的方法。我在 dotnet/aspnetcoredotnet/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];
            });
        }
    }
}

算法很简单:

  • 使用UTC的最新Tick计数作为ID的起始值,Tick计数数是一个64位的整数。
  • 在每次请求新的ID时以一递增。
  • 将值右移5(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

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK