作者:Casey McQuillan

译者:精致码农

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

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

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

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

上面使用的 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,这是一组有关如何创建字符串的方法,而不是要求我们将需要放入字符串中的所有字节进行二次复制。

对比上面两张图,图二的 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

深入解析 C# 的 String.Create 的方法的更多相关文章

  1. String 的 intern() 方法解析

    一.概述 JDK7 之前和之后的版本,String 的 intern() 方法在实现上存在差异,本文的说明环境是 JDK8,会在文末说明 intern() 方法的版本差异性. intern() 方法是 ...

  2. java基础解析系列(一)---String、StringBuffer、StringBuilder

    java基础解析系列(一)---String.StringBuffer.StringBuilder 前言:本系列的主题是平时容易疏忽的知识点,只有基础扎实,在编码的时候才能更注重规范和性能,在出现bu ...

  3. dotnet 6 使用 string.Create 提升字符串创建和拼接性能

    本文告诉大家,在 dotnet 6 或更高版本的 dotnet 里,如何使用 string.Create 提升字符串创建和拼接的性能,减少拼接字符串时,需要额外申请的内存,从而减少内存回收压力 本文也 ...

  4. java String 中 intern方法的概念

    1. 首先String不属于8种基本数据类型,String是一个对象. 因为对象的默认值是null,所以String的默认值也是null:但它又是一种特殊的对象,有其它对象没有的一些特性. 2. ne ...

  5. Java构造和解析Json数据的两种方法详解二

    在www.json.org上公布了很多JAVA下的json构造和解析工具,其中org.json和json-lib比较简单,两者使用上差不多但还是有些区别.下面接着介绍用org.json构造和解析Jso ...

  6. JavaScript中String.prototype.replace() 方法的使用

    摘抄于:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/String/replace ...

  7. java基础解析系列(九)---String不可变性分析

    java基础解析系列(九)---String不可变性分析 目录 java基础解析系列(一)---String.StringBuffer.StringBuilder java基础解析系列(二)---In ...

  8. Google官方网络框架-Volley的使用解析Json以及加载网络图片方法

    Google官方网络框架-Volley的使用解析Json以及加载网络图片方法 Volley是什么? Google I/O 大会上,Google 推出 Volley的一个网络框架 Volley适合什么场 ...

  9. 解析Json字符串的三种方法

    在很多时候,我们的需要将类似 json 格式的字符串数据转为json, 下面将介绍日常中使用的三种解析json字符串的方法 1.首先,我们先看一下什么是 json 格式字符串数据,很简单,就是 jso ...

随机推荐

  1. python笔记(1)---数据类型

    数据类型 基本的五大数据类型 对python中的变量有如下的五种基本的数据类型: Number数字 list列表 Tuple元组 string字符串 Dictionary字典 1.Number [注意 ...

  2. Maven一定要会的这几个知识!

    一.Maven概念 Maven是一个项目管理和整合工具.Maven为开发者提供了一套完整的构建生命周期框架.开发团队几乎不用花多少时间就能够自动完成工程的基础构建配置,因为Maven使用了一个标准的目 ...

  3. 面试官:连Spring AOP都说不明白,自己走还是我送你?

    前言 因为假期原因,有一段时间没给大家更新了!和大家说个事吧,放假的时候一位粉丝和我说了下自己的被虐经历,在假期前他去某互联网公司面试,结果直接被人家面试官Spring AOP三连问给问的一脸懵逼!其 ...

  4. Shamir秘密共享方案 (Python)

    Shamir's Secret Sharing scheme is an important cryptographic algorithm that allows private informati ...

  5. PHP 统计目录下文件数和文件大小

    1 /** 2 * 统计文件数和文件大小 3 */ 4 private function getFileCacheCount($pathName) 5 { 6 $data = [ 7 'num' =& ...

  6. python安装第三方库aiohtpp,sanio失败,pip install multidict 失败问题

    1.python的第三库安装地址:http://www.lfd.uci.edu/~gohlke/pythonlibs 2. 3.pip安装.whl文件指定该文件的位置

  7. NTML

     NTLM:         1.客户端向服务器发送一个请求,请求中包含明文的登陆用户名.在服务器中已经存储了登陆用户名和对应的密码hash         2.服务器接收到请求后,NTLMv2协议下 ...

  8. Zookeeper(5)---分布式锁

    基于临时序号节点来实现分布式锁 为什么要用临时节点呢?如果拿到锁的服务宕机了,会话失效ZK自己也会删除掉临时的序号节点,这样也不会阻塞其他服务. 流程: 1.在一个持久节点下面创建临时的序号节点作为锁 ...

  9. PyQt(Python+Qt)学习随笔:QTableView的sortingEnabled属性

    老猿Python博文目录 老猿Python博客地址 sortingEnabled属性用于控制是企业视图按列排序功能,如果此属性为True,则对tableView视图中的数据启用排序,如果此属性为Fal ...

  10. PyQt(Python+Qt)学习随笔:Qt Designer中Action创建的方法

    在Qt Designer中,可以两种方法创建Action对象,一种是菜单定义时,一种是单独定义. 一.定义菜单创建Action 在Qt Designer中创建菜单时,如果对应菜单是最终执行的菜单项,则 ...