baidu

[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三段拼成的一个完整的字符. 那么可以可以:

  1. 到全局的只读Dictionary里面去查找, 找到了返回
  2. 没找到, 则上lock, 到只写的Dictionary里面去找, 找到了返回
  3. 没找到, 给只写的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 ClientDotNetty里面分配比较多的对象, 也没办法优化, 尤其是MongoDB Client.

优化思路

最开始对C#优化没有重视Alloc这方面的优化, 以为ServerGC可以掌控一切, 实践下来发现不是这样. 所以对未来如果有C#写服务器, 或者其他托管语言写服务器的话, 优化的方式应该是:

  1. 开启WorkStationsGC, 该模式对Alloc更为敏感
  2. 先优化Alloc次数, 尽可能修改掉高频率Alloc对象的地方
  3. 然后再去优化算法
  4. 切换成ServerGC

在优化完Alloc之后, 整个服务器的运行速度有明显的提升(高出一个到两个数量级). 从最开始的OOM到后面5000人online只有15%的CPU占有率(腾讯云SA2 32C64G云主机).

Linux下sampling

服务器在Windows上面优化好了之后, Linux上还是要跑一下Sampling, 可以看看perf和flamegraph在linux下的使用, 文章参考处有列出.

参考:

  1. C# Emit
  2. DeepCloner
  3. .NET Inline Closure Call
  4. .NET Alloc On Stack
  5. .NET Profiling On Linux
  6. Flamegraph
posted @ 2020-09-14 09:47  egmkang  阅读(742)  评论(7编辑  收藏  举报