[04] C# Alloc Free编程之实践
C# Alloc Free编程之实践
上一篇说了Alloc Free
编程的基本理论. 这篇文章就说怎么具体做实践.
常识
之所以说是常识, 那是因为我们在学任何一门语言的时候, 都能在各种书上看到各种各样的best practice
. 这些内容也确实是最佳实践, 需要去遵守. 但是现实代码里面看到, 大部分都没有遵守这些简单的约定.
这里列举一些常识性的东西:
-
字符串拼接用String.Format, $表达式, StringBuilder等
尤其是
StringBuilder
, 在做一些长一点的字符串拼接, 很有优势.某服务器里面的字符串是密集使用的. 经常会出现String当做Dictionary的Key(这个跟MongoDB有一点关系, MongoDB的dict不能以数字当Key), 然后代码里面遍地是字符串的拼接(简单的用
+
来做). 如果只是做一两次实际上问题并不大, 但是很多时候是在每个玩家的Loop里面去做, 平白无故分配内存的系数多了几十倍. -
频繁的使用keys, values访问容器
var keys = dict.Keys; foreach(var key in keys) { //xxx }
Dictionary下访问Keys, 和直接foreach差别不是很大. 只是会多new几个小对象(其实也不应该).
但是在ConcurrentDictionary下, 访问成本就比较高了.
private ReadOnlyCollection<TKey> GetKeys() { int toExclusive = 0; ReadOnlyCollection<TKey> result; try { this.AcquireAllLocks(ref toExclusive); int countInternal = this.GetCountInternal(); if (countInternal < 0) { throw new OutOfMemoryException(); } List<TKey> list = new List<TKey>(countInternal); for (int i = 0; i < this.m_tables.m_buckets.Length; i++) { for (ConcurrentDictionary<TKey, TValue>.Node node = this.m_tables.m_buckets[i]; node != null; node = node.m_next) { list.Add(node.m_key); } } result = new ReadOnlyCollection<TKey>(list); } finally { this.ReleaseLocks(0, toExclusive); } return result; }
ConcurrentDictionary访问Keys会真的遍历整个字典然后把所有key拷贝一遍. 这个成本就非常高了.
之所以代码这么写, 是因为在项目早期, 出现了遍历的过程中修改容器的操作, 所以C#会抛出一个异常(C#的迭代器和容器会有版本号, C++的没有). 然后他们为了避免这个, 才想出这么一个歪门邪路. 正确的做法找到API设计缺陷的地方, 重新设计.
-
尽量使用struct来保存小的对象
C#的对象布局, 在class对象的头部有两个int64长度额外空间, 一个用来保存同步块(和HashCode), 另外一个用来保存vtable. 然后才是对象的本身的数据. 所以如果对象的成员非常少(小), 就没有必要使用class. 一来增加GC的负担, 一来每次alloc还需要消耗25ns左右的时间.
C#高版本也有提供
ValueTuple
这样的类, 用来减少临时类/小类产生的额外开销. C#有值语义和引用语义两种语义, 所以设计的时候需要考虑其开销, 更方便的进行控制. -
避免装箱拆箱
装箱是指把struct值类型对象, 放到堆上去的过程, 中间也会补齐同步块和vtable; 拆箱又要把数据从堆上拷贝回来. 所以尽量避免使用
System.Collection
下面的容器, 而选择泛型容器.这一点上, C#比Java就有一点优势, 泛型容器的参数可以是值类型. 做深入的思考, Golang的
interface
对象, 实际上也是一个装箱的对象, 因为每一个interface都是一个pair<data*, vtable>
. 而不同的是, C#的装箱把data和vtable合并成一个对象了, golang还是两个对象. -
慎用MemoryStream等
.NET Core内置的
MemoryStream
等虽然有Slice版本的重载, 但是内部还是会分配额外的数组, 并不是那么轻量级.而且MemoryStream继承自
IDisposable
接口需要及时Dispose, 否则会有很多内存声明周期被延后非常多的时间.这一点在某游戏服务器最开始的服务器版本内, 没有考虑到, 最原始的编解码器在大量使用MemoryStream. 正确的实践应该是之前文章所提到的大量使用
IByteBuffer
而不是用Stream. -
深拷贝
服务器或多或少会需要一些深拷贝. 很多程序员就到网上抄的那种
JSON序列化
然后再反序列化
的版本, 只是负责跑通代码逻辑, 而实际上代码性能很差. 将JSON序列化换成例如, BSON, 或者.NET Core内置的序列化, 都是不行的.深拷贝如果手写的话, 显然是一件非常枯燥乏味的事情. 而所有枯燥乏味的事情都是可以通过
编译时期的代码生成
或者运行时的代码生成
来实现. 编译时期的代码生成就类似protobuf和protoc这个概念, 编辑好的proto文件重新编译, 那生成的Message类是可以再clone的; 但是在C#这种具有一定动态性的语言里面, 是不需要这么搞.思路有两种, 一种是运行时反射去遍历对象的属性和数据成员, 然后动态的去设置其值; 还有一种是动态的反射该类型的属性和数据成员, 动态的生成一个函数, 去设置值. 后面这个做法可以做到非常高的性能.
使用上例如
DeepCloner
, 就更为简单:var copy = list.DeepClone(); //此处是一个扩展函数
-
protobuf
repeated
字段这边单独把Protobuf repeated字段列出来, 是因为在同步客户端服务器信息的时候, 严重依赖repeated字段, 极端情况下甚至可能会出现几百个元素的数组, 然后这些数组会不停的重新创建, 这一点对GC压力非常大.
修改的方式也比较简单, 在每个Player或者Entity身上都挂在一个Message实例, 同步的时候使用这一个对象; 然后通过反射来修改这个Message上面的私有变量, 减少每次重新构造该Message时的成本.
-
Linq
Linq
对简化编程有很大的帮助. 但是在高频函数内滥用, 会导致极大的GC负担.例如ToList
可以将内容拷贝到另外一个长久持有的List里面去, 而不是每次都用完就释放.Linq
还有一个问题是很多传参是需要传入一个Func
(闭包), 用来实现灵活性, 该闭包最终会在堆上, 会产生额外的开销.
类似的这样的实践还有很多, 需要不断的补充列表进行知识更新.
更进一步
上面只是说了不应该用什么, 或者怎么用, 下面将一些需要修改更多代码才能实现的优化.
字符串的拼接和转换
例如某服务器内有大量路径的拼接, 或者Key的拼接, 但是文件路径和Key又不会频繁发生变化, 所以在服务器内部时时刻刻去拼接是恨不合算的事情.
那么对一个Item1, Item2和Item3三段拼成的一个完整的字符. 那么可以可以:
- 到全局的只读Dictionary里面去查找, 找到了返回
- 没找到, 则上
lock
, 到只写的Dictionary里面去找, 找到了返回 - 没找到, 给只写的Dictionary内增加该元素, 然后生成一个拷贝给只读的对象, 返回
通过很简单的编程方式(封装一次多处调用), 就可以大量减少字符串的拼接.
再例如XLua和Lua虚拟机交互的过程中, 因为C#内的String是UTF-16编码的, 而Lua的String是ASCII兼容的(可以兼容UTF-8编码), 那么传递的过程中必然要产生一次转换. 对于低频交互则不会产生问题, 但是高频不行.
根据观察发现, 大部分C#传递给Lua的字符串都是比较固定的, 所以当时做了一个LRU<String, byte[]>
, 把字符串到byte[]的转换这一步省下来了, 但是byte[]到Lua VM这一步还是没有省下来.
物理引擎频繁AllocArray
服务器内用VelcroPhysics
来做运动的模拟(防止外挂和穿帮, 还有怪物的移动模拟, 还有少量的碰撞检测). 在做profile的时候发现其中有一个对象, 在不停的New Array
. 这个DistanceProxy
对象会获取物体的几个点(组成的边所表达的形状), 然后在场景内跟不同的物体算距离(应该是做碰撞检测类似的东西). 每个场景按照25帧的速度去模拟, 那么中间的计算量会产生很多的垃圾对象; 之前做过benchmark, 大概400个玩家的副本, 一分钟的样子产生了数十万个垃圾对象.
所以后来经过仔细研究, 发现DistanceProxy所代表的的物体, 最多是6边型(6个顶点), 最多的是4边型. 然后使用的地方也只有两处, 都是一次性的调用, 基本上就是new一个DistanceProxy对象, 算一下, 就扔掉了. 好在DistanceProxy对象本身是struct.
所以就只需要优化那个Array就行了. 那么可以在每个线程上弄一个Array的Pool, 这个Pool很小, 只需要有2个大小(实际里面塞了4个数组), 然后用的时候从Pool里面Get一个, 用完了归还.
C#有一个概念叫IDisposable
, 意思是有一些非托管资源, 可以用using语句括起来, 在scope结束之后, 语言会做确定性的释放, 不会产生内存泄漏(不管有没有发生异常).
所以可以让这个DistanceProxy对象继承自IDisposable
, 然后调用的释放就变成了:
DistanceInput input = new DistanceInput();
input.ProxyA = new DistanceProxy(shapeA, indexA);
input.ProxyB = new DistanceProxy(shapeB, indexB);
input.TransformA = xfA;
input.TransformB = xfB;
input.UseRadii = true;
using var _1 = input.ProxyA; //重点是这两句
using var _2 = input.ProxyB;
具体问题具体分析, 找到问题的根本, 改起来实际上比较简单的.
隐蔽的知识
上面说的那些知识, 是很容易能想到的, 不管是有意还是无意写出来的. 但是C#还有一些隐性的Alloc, 会被忽视掉.
例如lambda
表达式, 或者闭包.
我们在C++里面经常会写到类似这样的代码:
template<typename F>
void ForEach(F fn)
{
for(const auto& item : vec)
fn(item);
}
ForEach([=](const int& item) =>
{
std::cout << item << std::endl;
});
例如这个ForEach的fn参数, 他是按照值来传递(最多会被move过去), 这种传递方式产生的消耗是很少的; 而且C++对lambda表达式还可以做inline
. 最终整个代码的效率是非常高的, 因为0抽象
.
但是在C#里面, 情况就不一样了.
//1
vec.ForEach((item) => Console.Write(item.ToString()));
//2
var fn = (item) => Console.Write(item.ToString());
Vec.ForEach(fn);
在1
里面每次代码执行到ForEach的时候, 都会产生一个临时的闭包对象, 该对象分配在堆上, 调用完毕就变成垃圾对象; 但是在2
里面, 如果我们把fn对象的生命周期变长一点, 那么后面的ForEach调用就不会有额外的开销.
某服务器内部在大量使用这种lambda表达式. 后来借助VS 2019的.NET 对象分配跟踪
这种优化手段, 找到了所有的高频调用.
有一些高频调用仅仅是为了遍历某一个List或者Dictionary, 直接手动展开, 多写两三行代码, 也不算是很难的事情.
如果.NET CLR
有逃逸分析
的话, 整个问题就会变得简单, 就不需要编写这样的代码. 好消息是github已经有类似的issue, 而且官方已经在着手处理; 坏消息是不知道哪个版本会加进来.
工具以及优化思路
工具的选择
工具的选择很简单, 只有宇宙第一IDE--VS2019
. 然后具体的项是: 调试 -> 性能探查器 -> .NET对象分配跟踪 -> 自定义100个对象采集一次. 每个对象都跟踪的话, 服务器会跑的非常慢. 所以每100个采集一次就够了.
然后开启机器人, 跑具体的业务逻辑. 跑个一两分钟就可以停下来, 查看报告.
从这张图里面可以看到某种类型的对象分配的次数, 和哪里分配的比较多. 重点找那些逻辑层里面导致的, 因为像MongoDB Client
和DotNetty
里面分配比较多的对象, 也没办法优化, 尤其是MongoDB Client
.
优化思路
最开始对C#优化没有重视Alloc这方面的优化, 以为ServerGC可以掌控一切, 实践下来发现不是这样. 所以对未来如果有C#写服务器, 或者其他托管语言写服务器的话, 优化的方式应该是:
- 开启WorkStationsGC, 该模式对Alloc更为敏感
- 先优化Alloc次数, 尽可能修改掉高频率Alloc对象的地方
- 然后再去优化算法
- 切换成ServerGC
在优化完Alloc之后, 整个服务器的运行速度有明显的提升(高出一个到两个数量级). 从最开始的OOM到后面5000人online只有15%的CPU占有率(腾讯云SA2 32C64G云主机).
Linux下sampling
服务器在Windows上面优化好了之后, Linux上还是要跑一下Sampling, 可以看看perf和flamegraph在linux下的使用, 文章参考处有列出.
参考: