[04] C# Alloc Free编程之实践
<h1 dir="auto" data-sourcepos="1:1-1:27">C# Alloc Free编程之实践</h1><p dir="auto" data-sourcepos="3:1-3:90">上一篇说了<code>Alloc Free</code>编程的基本理论. 这篇文章就说怎么具体做实践.</p>
<h2 dir="auto" data-sourcepos="5:1-5:9">常识</h2>
<p dir="auto" data-sourcepos="7:1-7:268">之所以说是常识, 那是因为我们在学任何一门语言的时候, 都能在各种书上看到各种各样的<code>best practice</code>. 这些内容也确实是最佳实践, 需要去遵守. 但是现实代码里面看到, 大部分都没有遵守这些简单的约定.</p>
<p dir="auto" data-sourcepos="9:1-9:37">这里列举一些常识性的东西:</p>
<ul dir="auto" data-sourcepos="11:1-102:0">
<li data-sourcepos="11:1-15:402">
<p data-sourcepos="11:3-11:63">字符串拼接用String.Format, $表达式, StringBuilder等</p>
<p data-sourcepos="13:3-13:82">尤其是<code>StringBuilder</code>, 在做一些长一点的字符串拼接, 很有优势.</p>
<p data-sourcepos="15:3-15:402">某服务器里面的字符串是密集使用的. 经常会出现String当做Dictionary的Key(这个跟MongoDB有一点关系, MongoDB的dict不能以数字当Key), 然后代码里面遍地是字符串的拼接(简单的用<code>+</code>来做). 如果只是做一两次实际上问题并不大, 但是很多时候是在每个玩家的Loop里面去做, 平白无故分配内存的系数多了几十倍.</p>
</li>
<li data-sourcepos="16:1-61:316">
<p data-sourcepos="16:3-16:41">频繁的使用keys, values访问容器</p>
<pre class="code highlight js-syntax-highlight plaintext white"><code><span id="LC1" class="line" lang="plaintext">var keys = dict.Keys;
<span id="LC2" class="line" lang="plaintext">foreach(var key in keys)
<span id="LC3" class="line" lang="plaintext">{
<span id="LC4" class="line" lang="plaintext"> //xxx
<span id="LC5" class="line" lang="plaintext">}</span></span></span></span></span></code></pre>
<p data-sourcepos="25:3-25:114">Dictionary下访问Keys, 和直接foreach差别不是很大. 只是会多new几个小对象(其实也不应该).</p>
<p data-sourcepos="27:3-27:64">但是在ConcurrentDictionary下, 访问成本就比较高了.</p>
<pre class="code highlight js-syntax-highlight plaintext white"><code><span id="LC1" class="line" lang="plaintext">private ReadOnlyCollection<TKey> GetKeys()
<span id="LC2" class="line" lang="plaintext">{
<span id="LC3" class="line" lang="plaintext"> int toExclusive = 0;
<span id="LC4" class="line" lang="plaintext"> ReadOnlyCollection<TKey> result;
<span id="LC5" class="line" lang="plaintext"> try
<span id="LC6" class="line" lang="plaintext"> {
<span id="LC7" class="line" lang="plaintext"> this.AcquireAllLocks(ref toExclusive);
<span id="LC8" class="line" lang="plaintext"> int countInternal = this.GetCountInternal();
<span id="LC9" class="line" lang="plaintext"> if (countInternal < 0)
<span id="LC10" class="line" lang="plaintext"> {
<span id="LC11" class="line" lang="plaintext"> throw new OutOfMemoryException();
<span id="LC12" class="line" lang="plaintext"> }
<span id="LC13" class="line" lang="plaintext"> List<TKey> list = new List<TKey>(countInternal);
<span id="LC14" class="line" lang="plaintext"> for (int i = 0; i < this.m_tables.m_buckets.Length; i++)
<span id="LC15" class="line" lang="plaintext"> {
<span id="LC16" class="line" lang="plaintext"> for (ConcurrentDictionary<TKey, TValue>.Node node = this.m_tables.m_buckets; node != null; node = node.m_next)
<span id="LC17" class="line" lang="plaintext"> {
<span id="LC18" class="line" lang="plaintext"> list.Add(node.m_key);
<span id="LC19" class="line" lang="plaintext"> }
<span id="LC20" class="line" lang="plaintext"> }
<span id="LC21" class="line" lang="plaintext"> result = new ReadOnlyCollection<TKey>(list);
<span id="LC22" class="line" lang="plaintext"> }
<span id="LC23" class="line" lang="plaintext"> finally
<span id="LC24" class="line" lang="plaintext"> {
<span id="LC25" class="line" lang="plaintext"> this.ReleaseLocks(0, toExclusive);
<span id="LC26" class="line" lang="plaintext"> }
<span id="LC27" class="line" lang="plaintext"> return result;
<span id="LC28" class="line" lang="plaintext">}</span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></code></pre>
<p data-sourcepos="59:3-59:119">ConcurrentDictionary访问Keys会真的遍历整个字典然后把所有key拷贝一遍. 这个成本就非常高了.</p>
<p data-sourcepos="61:3-61:316">之所以代码这么写, 是因为在项目早期, 出现了遍历的过程中修改容器的操作, 所以C#会抛出一个异常(C#的迭代器和容器会有版本号, C++的没有). 然后他们为了避免这个, 才想出这么一个歪门邪路. 正确的做法找到API设计缺陷的地方, 重新设计.</p>
</li>
</ul>
<ul dir="auto" data-sourcepos="11:1-102:0">
<li data-sourcepos="62:1-66:214">
<p data-sourcepos="62:3-62:41">尽量使用struct来保存小的对象</p>
<p data-sourcepos="64:3-64:338">C#的对象布局, 在class对象的头部有两个int64长度额外空间, 一个用来保存同步块(和HashCode), 另外一个用来保存vtable. 然后才是对象的本身的数据. 所以如果对象的成员非常少(小), 就没有必要使用class. 一来增加GC的负担, 一来每次alloc还需要消耗25ns左右的时间.</p>
<p data-sourcepos="66:3-66:214">C#高版本也有提供<code>ValueTuple</code>这样的类, 用来减少临时类/小类产生的额外开销. C#有值语义和引用语义两种语义, 所以设计的时候需要考虑其开销, 更方便的进行控制.</p>
</li>
<li data-sourcepos="67:1-71:326">
<p data-sourcepos="67:3-67:20">避免装箱拆箱</p>
<p data-sourcepos="69:3-69:230">装箱是指把struct值类型对象, 放到堆上去的过程, 中间也会补齐同步块和vtable; 拆箱又要把数据从堆上拷贝回来. 所以尽量避免使用<code>System.Collection</code>下面的容器, 而选择泛型容器.</p>
<p data-sourcepos="71:3-71:326">这一点上, C#比Java就有一点优势, 泛型容器的参数可以是值类型. 做深入的思考, Golang的<code>interface</code>对象, 实际上也是一个装箱的对象, 因为每一个interface都是一个<code>pair<data*, vtable></code>. 而不同的是, C#的装箱把data和vtable合并成一个对象了, golang还是两个对象.</p>
</li>
<li data-sourcepos="72:1-78:220">
<p data-sourcepos="72:3-72:23">慎用MemoryStream等</p>
<p data-sourcepos="74:3-74:137">.NET Core内置的<code>MemoryStream</code>等虽然有Slice版本的重载, 但是内部还是会分配额外的数组, 并不是那么轻量级.</p>
<p data-sourcepos="76:3-76:133">而且MemoryStream继承自<code>IDisposable</code>接口需要及时Dispose, 否则会有很多内存声明周期被延后非常多的时间.</p>
<p data-sourcepos="78:3-78:220">这一点在某游戏服务器最开始的服务器版本内, 没有考虑到, 最原始的编解码器在大量使用MemoryStream. 正确的实践应该是之前文章所提到的大量使用<code>IByteBuffer</code>而不是用Stream.</p>
</li>
<li data-sourcepos="79:1-91:5">
<p data-sourcepos="79:3-79:11">深拷贝</p>
<p data-sourcepos="81:3-81:288">服务器或多或少会需要一些深拷贝. 很多程序员就到网上抄的那种<code>JSON序列化</code>然后<code>再反序列化</code>的版本, 只是负责跑通代码逻辑, 而实际上代码性能很差. 将JSON序列化换成例如, BSON, 或者.NET Core内置的序列化, 都是不行的.</p>
<p data-sourcepos="83:3-83:420">深拷贝如果手写的话, 显然是一件非常枯燥乏味的事情. 而所有枯燥乏味的事情都是可以通过<code>编译时期的代码生成</code>或者<code>运行时的代码生成</code>来实现. 编译时期的代码生成就类似protobuf和protoc这个概念, 编辑好的proto文件重新编译, 那生成的Message类是可以再clone的; 但是在C#这种具有一定动态性的语言里面, 是不需要这么搞.</p>
<p data-sourcepos="85:3-85:273">思路有两种, 一种是运行时反射去遍历对象的属性和数据成员, 然后动态的去设置其值; 还有一种是动态的反射该类型的属性和数据成员, 动态的生成一个函数, 去设置值. 后面这个做法可以做到非常高的性能.</p>
<p data-sourcepos="87:3-87:47">使用上例如<code>DeepCloner</code>, 就更为简单:</p>
<pre class="code highlight js-syntax-highlight plaintext white"><code><span id="LC1" class="line" lang="plaintext">var copy = list.DeepClone();//此处是一个扩展函数</span></code></pre>
</li>
<li data-sourcepos="92:1-96:245">
<p data-sourcepos="92:3-92:27">protobuf <code>repeated</code>字段</p>
<p data-sourcepos="94:3-94:277">这边单独把Protobuf repeated字段列出来, 是因为在同步客户端服务器信息的时候, 严重依赖repeated字段, 极端情况下甚至可能会出现几百个元素的数组, 然后这些数组会不停的重新创建, 这一点对GC压力非常大.</p>
<p data-sourcepos="96:3-96:245">修改的方式也比较简单, 在每个Player或者Entity身上都挂在一个Message实例, 同步的时候使用这一个对象; 然后通过反射来修改这个Message上面的私有变量, 减少每次重新构造该Message时的成本.</p>
</li>
<li data-sourcepos="97:1-102:0">
<p data-sourcepos="97:3-97:6">Linq</p>
<p data-sourcepos="99:3-99:216"><code>Linq</code>对简化编程有很大的帮助. 但是在高频函数内滥用, 会导致极大的GC负担.例如<code>ToList</code>可以将内容拷贝到另外一个长久持有的List里面去, 而不是每次都用完就释放.</p>
<p data-sourcepos="101:3-101:155"><code>Linq</code>还有一个问题是很多传参是需要传入一个<code>Func</code>(闭包), 用来实现灵活性, 该闭包最终会在堆上, 会产生额外的开销.</p>
</li>
</ul>
<p dir="auto" data-sourcepos="103:1-103:84">类似的这样的实践还有很多, 需要不断的补充列表进行知识更新.</p>
<h2 dir="auto" data-sourcepos="105:1-105:15">更进一步</h2>
<p dir="auto" data-sourcepos="107:1-107:116">上面只是说了不应该用什么, 或者怎么用, 下面将一些需要修改更多代码才能实现的优化.</p>
<h4 dir="auto" data-sourcepos="109:1-109:32">字符串的拼接和转换</h4>
<p dir="auto" data-sourcepos="111:1-111:199">例如某服务器内有大量路径的拼接, 或者Key的拼接, 但是文件路径和Key又不会频繁发生变化, 所以在服务器内部时时刻刻去拼接是恨不合算的事情.</p>
<p dir="auto" data-sourcepos="113:1-113:92">那么对一个Item1, Item2和Item3三段拼成的一个完整的字符. 那么可以可以:</p>
<ol dir="auto" data-sourcepos="114:1-117:0">
<li data-sourcepos="114:1-114:63">到全局的只读Dictionary里面去查找, 找到了返回</li>
<li data-sourcepos="115:1-115:79">没找到, 则上<code>lock</code>, 到只写的Dictionary里面去找, 找到了返回</li>
<li data-sourcepos="116:1-117:0">没找到, 给只写的Dictionary内增加该元素, 然后生成一个拷贝给只读的对象, 返回</li>
</ol>
<p dir="auto" data-sourcepos="118:1-118:98">通过很简单的编程方式(封装一次多处调用), 就可以大量减少字符串的拼接.</p>
<p dir="auto" data-sourcepos="120:1-120:260">再例如XLua和Lua虚拟机交互的过程中, 因为C#内的String是UTF-16编码的, 而Lua的String是ASCII兼容的(可以兼容UTF-8编码), 那么传递的过程中必然要产生一次转换. 对于低频交互则不会产生问题, 但是高频不行.</p>
<p dir="auto" data-sourcepos="122:1-122:230">根据观察发现, 大部分C#传递给Lua的字符串都是比较固定的, 所以当时做了一个<code>LRU<String, byte[]></code>, 把字符串到byte[]的转换这一步省下来了, 但是byte[]到Lua VM这一步还是没有省下来.</p>
<h4 dir="auto" data-sourcepos="124:1-124:33">物理引擎频繁AllocArray</h4>
<p dir="auto" data-sourcepos="126:1-127:455">服务器内用<code>VelcroPhysics</code>来做运动的模拟(防止外挂和穿帮, 还有怪物的移动模拟, 还有少量的碰撞检测). 在做profile的时候发现其中有一个对象, 在不停的<code>New Array</code>. 这个<code>DistanceProxy</code>对象会获取物体的几个点(组成的边所表达的形状), 然后在场景内跟不同的物体算距离(应该是做碰撞检测类似的东西). 每个场景按照25帧的速度去模拟, 那么中间的计算量会产生很多的垃圾对象; 之前做过benchmark, 大概400个玩家的副本, 一分钟的样子产生了数十万个垃圾对象.</p>
<p dir="auto" data-sourcepos="129:1-129:300">所以后来经过仔细研究, 发现DistanceProxy所代表的的物体, 最多是6边型(6个顶点), 最多的是4边型. 然后使用的地方也只有两处, 都是一次性的调用, 基本上就是new一个DistanceProxy对象, 算一下, 就扔掉了. 好在DistanceProxy对象本身是struct.</p>
<p dir="auto" data-sourcepos="131:1-131:229">所以就只需要优化那个Array就行了. 那么可以在每个线程上弄一个Array的Pool, 这个Pool很小, 只需要有2个大小(实际里面塞了4个数组), 然后用的时候从Pool里面Get一个, 用完了归还.</p>
<p dir="auto" data-sourcepos="133:1-133:209">C#有一个概念叫<code>IDisposable</code>, 意思是有一些非托管资源, 可以用using语句括起来, 在scope结束之后, 语言会做确定性的释放, 不会产生内存泄漏(不管有没有发生异常).</p>
<p dir="auto" data-sourcepos="135:1-135:98">所以可以让这个DistanceProxy对象继承自<code>IDisposable</code>, 然后调用的释放就变成了:</p>
<pre class="code highlight js-syntax-highlight plaintext white"><code><span id="LC1" class="line" lang="plaintext">DistanceInput input = new DistanceInput();
<span id="LC2" class="line" lang="plaintext">input.ProxyA = new DistanceProxy(shapeA, indexA);
<span id="LC3" class="line" lang="plaintext">input.ProxyB = new DistanceProxy(shapeB, indexB);
<span id="LC4" class="line" lang="plaintext">input.TransformA = xfA;
<span id="LC5" class="line" lang="plaintext">input.TransformB = xfB;
<span id="LC6" class="line" lang="plaintext">input.UseRadii = true;
<span id="LC7" class="line" lang="plaintext">
<span id="LC8" class="line" lang="plaintext">using var _1 = input.ProxyA; //重点是这两句
<span id="LC9" class="line" lang="plaintext">using var _2 = input.ProxyB;</span></span></span></span></span></span></span></span></span></code></pre>
<p dir="auto" data-sourcepos="149:1-149:83">具体问题具体分析, 找到问题的根本, 改起来实际上比较简单的.</p>
<h2 dir="auto" data-sourcepos="151:1-151:18">隐蔽的知识</h2>
<p dir="auto" data-sourcepos="153:1-153:145">上面说的那些知识, 是很容易能想到的, 不管是有意还是无意写出来的. 但是C#还有一些隐性的Alloc, 会被忽视掉.</p>
<p dir="auto" data-sourcepos="155:1-155:38">例如<code>lambda</code>表达式, 或者闭包.</p>
<p dir="auto" data-sourcepos="157:1-157:55">我们在C++里面经常会写到类似这样的代码:</p>
<pre class="code highlight js-syntax-highlight plaintext white"><code><span id="LC1" class="line" lang="plaintext">template<typename F>
<span id="LC2" class="line" lang="plaintext">void ForEach(F fn)
<span id="LC3" class="line" lang="plaintext">{
<span id="LC4" class="line" lang="plaintext"> for(const auto& item : vec)
<span id="LC5" class="line" lang="plaintext"> fn(item);
<span id="LC6" class="line" lang="plaintext">}
<span id="LC7" class="line" lang="plaintext">
<span id="LC8" class="line" lang="plaintext">ForEach([=](const int& item) =>
<span id="LC9" class="line" lang="plaintext">{
<span id="LC10" class="line" lang="plaintext"> std::cout << item << std::endl;
<span id="LC11" class="line" lang="plaintext">});</span></span></span></span></span></span></span></span></span></span></span></code></pre>
<p dir="auto" data-sourcepos="173:1-173:238">例如这个ForEach的fn参数, 他是按照值来传递(最多会被move过去), 这种传递方式产生的消耗是很少的; 而且C++对lambda表达式还可以做<code>inline</code>. 最终整个代码的效率是非常高的, 因为<code>0抽象</code>.</p>
<p dir="auto" data-sourcepos="175:1-175:41">但是在C#里面, 情况就不一样了.</p>
<pre class="code highlight js-syntax-highlight plaintext white"><code><span id="LC1" class="line" lang="plaintext">//1
<span id="LC2" class="line" lang="plaintext">vec.ForEach((item) =>Console.Write(item.ToString()));
<span id="LC3" class="line" lang="plaintext">
<span id="LC4" class="line" lang="plaintext">//2
<span id="LC5" class="line" lang="plaintext">var fn = (item) => Console.Write(item.ToString());
<span id="LC6" class="line" lang="plaintext">Vec.ForEach(fn);</span></span></span></span></span></span></code></pre>
<p dir="auto" data-sourcepos="188:1-188:168">在<code>1</code>里面每次代码执行到ForEach的时候, 都会产生一个临时的闭包对象, 该对象分配在堆上, 调用完毕就变成垃圾对象; 但是在<code>2</code>里面, 如果我们把fn对象的生命周期变长一点, 那么后面的ForEach调用就不会有额外的开销.</p>
<p dir="auto" data-sourcepos="188:1-188:168">某服务器内部在大量使用这种lambda表达式. 后来借助VS 2019的<code>.NET 对象分配跟踪</code>这种优化手段, 找到了所有的高频调用.</p>
<p dir="auto" data-sourcepos="190:1-190:144">有一些高频调用仅仅是为了遍历某一个List或者Dictionary, 直接手动展开, 多写两三行代码, 也不算是很难的事情.</p>
<p dir="auto" data-sourcepos="192:1-192:232">如果<code>.NET CLR</code>有<code>逃逸分析</code>的话, 整个问题就会变得简单, 就不需要编写这样的代码. 好消息是github已经有类似的issue, 而且官方已经在着手处理; 坏消息是不知道哪个版本会加进来.</p>
<h2 dir="auto" data-sourcepos="194:1-194:27">工具以及优化思路</h2>
<h4 dir="auto" data-sourcepos="196:1-196:20">工具的选择</h4>
<p dir="auto" data-sourcepos="198:1-198:268">工具的选择很简单, 只有宇宙第一IDE--<code>VS2019</code>. 然后具体的项是: 调试 -> 性能探查器 -> .NET对象分配跟踪 -> 自定义100个对象采集一次. 每个对象都跟踪的话, 服务器会跑的非常慢. 所以每100个采集一次就够了.</p>
<p dir="auto" data-sourcepos="200:1-200:100">然后开启机器人, 跑具体的业务逻辑. 跑个一两分钟就可以停下来, 查看报告.</p>
<p dir="auto" data-sourcepos="202:1-202:43"><img src="https://img2020.cnblogs.com/blog/25535/202009/25535-20200914094626001-467652631.png" alt="" loading="lazy"></p>
<p> </p>
<p> </p>
<p dir="auto" data-sourcepos="204:1-204:254">从这张图里面可以看到某种类型的对象分配的次数, 和哪里分配的比较多. 重点找那些逻辑层里面导致的, 因为像<code>MongoDB Client</code>和<code>DotNetty</code>里面分配比较多的对象, 也没办法优化, 尤其是<code>MongoDB Client</code>.</p>
<h4 dir="auto" data-sourcepos="206:1-206:17">优化思路</h4>
<p dir="auto" data-sourcepos="208:1-208:232">最开始对C#优化没有重视Alloc这方面的优化, 以为ServerGC可以掌控一切, 实践下来发现不是这样. 所以对未来如果有C#写服务器, 或者其他托管语言写服务器的话, 优化的方式应该是:</p>
<ol dir="auto" data-sourcepos="210:1-214:0">
<li data-sourcepos="210:1-210:54">开启WorkStationsGC, 该模式对Alloc更为敏感</li>
<li data-sourcepos="211:1-211:72">先优化Alloc次数, 尽可能修改掉高频率Alloc对象的地方</li>
<li data-sourcepos="212:1-212:27">然后再去优化算法</li>
<li data-sourcepos="213:1-214:0">切换成ServerGC</li>
</ol>
<p dir="auto" data-sourcepos="215:1-215:211">在优化完Alloc之后, 整个服务器的运行速度有明显的提升(高出一个到两个数量级). 从最开始的OOM到后面5000人online只有15%的CPU占有率(腾讯云SA2 32C64G云主机).</p>
<h4 dir="auto" data-sourcepos="217:1-217:21">Linux下sampling</h4>
<p dir="auto" data-sourcepos="219:1-219:157">服务器在Windows上面优化好了之后, Linux上还是要跑一下Sampling, 可以看看perf和flamegraph在linux下的使用, 文章参考处有列出.</p>
<p dir="auto" data-sourcepos="221:1-221:7">参考:</p>
<ol dir="auto" data-sourcepos="222:1-227:59">
<li data-sourcepos="222:1-222:97">C# Emit</li>
<li data-sourcepos="223:1-223:59">DeepCloner</li>
<li data-sourcepos="224:1-224:76">.NET Inline Closure Call</li>
<li data-sourcepos="225:1-225:71">.NET Alloc On Stack</li>
<li data-sourcepos="226:1-226:96">.NET Profiling On Linux</li>
<li data-sourcepos="227:1-227:59">Flamegraph</li>
</ol><br><br>
来源:https://www.cnblogs.com/egmkang/p/13665090.html
頁:
[1]