考虑到 Unity 准备在 2024 年前后,推出基于 dotnet Runtime 的版本,本篇文章也标记为 Unity 分类,等后面 Unity 准备好之后,再对新版的客户端进行改造
在日常开发过程中,字符串的拼接通常会占用大量的 GC,通常拼接字符串我们会使用如下几种方法
1. 1 + "/" + 2 + "/" + 3
2. StringBuilder
3. string.Format("{0}/{1}/{2}", 1, 2, 3)
4. "{1}/{2}/{3}" // 美元符号因为博客解析问题,此处省略
无论上述的哪一种方法,在 dotnet 5.0 的环境下,都无法做到 0 GC,但是在 dotnet 6.0 的版本,微软推出了一套基于 InterpolatedString 的解决方案,做到了即使是 值类型 字符串的拼接,也没有装箱/拆箱,以及 GC 问题
性能问题
很多时候开发者图方便,直接使用第一种方式,对字符串进行拼接,但实际生成的代码性能非常非常糟糕
// 原始代码
public string Concat()
{
return 1 + "/" + 2 + "/" + 3;
}
// 实际生成的代码
public string Concat()
{
string[] array = new string[5];
array[0] = 1.ToString();
array[1] = "/";
array[2] = 2.ToString();
array[3] = "/";
array[4] = 3.ToString();
return string.Concat(array);
}
同样的,如果我们使用 string.Format
函数对字符串进行拼接时,由于参数是 object
,对于值类型一样会有装箱和拆箱的问题
InterpolatedString 实现
正常服务器项目的日志打印,都会定制一份自己的实现,一般用于控制日志等级,搜集日志等等。由于有这层封装,我们对于日志 GC 的整体改造会简单许多,这里以我们项目实际接入为例
注意此处需要
dotnet 6.0
以及C#10
,低版本无法使用
此时如果我们使用 $
对字符串进行拼接时,生成的代码就完全不一样了
// 生成前
public string Dotnet5Interpolate()
{
return "{1}/{2}/{3}"; // 省略了美元符号
}
// 生成后
public string Dotnet5Interpolate()
{
return string.Format("{0}/{1}/{2}", 1, 2, 3);
}
// 生成前
public string Dotnet6Interpolate()
{
return "{1}/{2}/{3}"; // 省略了美元符号
}
// 生成后
public string Dotnet6Interpolate()
{
DefaultInterpolatedStringHandler defaultInterpolatedStringHandler = new DefaultInterpolatedStringHandler(2, 3);
defaultInterpolatedStringHandler.AppendFormatted(1);
defaultInterpolatedStringHandler.AppendLiteral("/");
defaultInterpolatedStringHandler.AppendFormatted(2);
defaultInterpolatedStringHandler.AppendLiteral("/");
defaultInterpolatedStringHandler.AppendFormatted(3);
return defaultInterpolatedStringHandler.ToStringAndClear();
}
这里的 DefaultInterpolatedStringHandler
是一个 ref struct
,所以所有操作均在栈上执行,而微软为了解决 值类型 的装箱拆箱,将 AppendFormatted 方法定义为了泛型方法
public void AppendFormatted<T>(T value);
至此我们还需要对现有的日志接口进行改造,改造过程也非常简单,我们先定义 LogInterpolatedStringHandler
结构体
[InterpolatedStringHandler]
public ref struct LogInterpolatedStringHandler
{
private DefaultInterpolatedStringHandler _inner_handler;
public LogInterpolatedStringHandler(int literal_length, int formatted_count)
{
_inner_handler = new DefaultInterpolatedStringHandler(literal_length, formatted_count);
}
public override string ToString() => _inner_handler.ToString();
public void AppendLiteral(string literal) => _inner_handler.AppendLiteral(literal);
public void AppendFormatted<T>(T value) => _inner_handler.AppendFormatted(value);
public string ToStringAndClear() => _inner_handler.ToStringAndClear();
}
假设日志有 Debug
接口,那么此处的写法如下
public static class Log
{
public static void Debug(ref LogInterpolatedStringHandler msg)
{
// 注意这里记得使用 ToStringAndClear 方法
// 底层使用了 ArrayPool,此时需要将 char[] 还给池子
xxx.Log(msg.ToStringAndClear());
}
}
// 后续全部省略了美元符号
// 输入我是日志,注意此处如果没有写美元符号,无法通过编译
Log.Debug("我是日志")
// 输出 我是日志 1/2/3
Log.Debug("我是日志 {1}/{2}/{3}");
int value = 123;
// 输出 123
Log.Debug("{value}");
这样我们就完成了 0GC 的字符串拼接,对于上层的业务逻辑来说,打印日志仅允许使用 $
符号对字符串进行拼接,从而限制了一些程序喜欢使用 +
来拼接字符串的写法,而且即使是外面想使用 string.Format
传入字符串,也同样无法通过编译