Go可能是目前最适合做游戏的语言
最近闲着没事又开始看GC的东西. 因为对于一个游戏开发者, 这种可以让你的游戏突然断片er好几帧的底层机制, 实在让人如梗在喉. 而作为一个痛恨C/C++式内存管理的人, GC像是一种必要的恶, 让我感到安全同时又让我浑身不自在: 我既然都他妈用GC语言了, 凭什么写程序的时候还要注意不要产生垃圾啊.
而且很多所谓避免分配垃圾的写法, 相当的恶心. 把C#当作C++来写, 甚至比C++更啰嗦. 这不就像, 用condom叫人不要太大力免得破; 开空调让人把温度调高省电; 玩VR却只能用传送. 那你发明这玩意是图啥...人类进步不就是图个爽吗?
于是我又走上了寻找最适合游戏的内存管理的研究过程. 本文是一个过程记录.
引用计数
扫描式的GC会断片er, 那么第一个想到的当然是引用计数. 可是引用计数从GC算法大战中败下阵来不是没有原因的. 其最大的两个问题: 一, 无法收集循环引用. 二, 计数机制的性能问题.
第一个问题循环引用, 又有两种解决方案.
一, 就是不解决... 不要动手! 听我解释. 其实循环引用产生的几率是很低的. 感觉上比开发者写出内存泄漏的概率还低. 而且循环引用是一个好发现好治疗的问题. 程序跑个一天, 开个工具扫描一下, 就能发现是哪些类型串起来了, 开发人员大概就知道怎么改掉了. 其实对于大多数应用, 特别是游戏来讲不是什么大问题. 毕竟内存泄漏只是漏了点内存, 又不会崩. 事后改了就好了. Swift的设计者就是这样想的.
二, 引入一个扫描式的GC... 住手啊, 这是业界成熟方案! 相信多数人看到这个方案都气笑了, 用引用计数本就是想不用扫描式GC. 你又加回去不是脱了裤子放屁吗. 但其实这种GC可以是非常轻量的, 完全可以后台同步运行而不影响其它应用线程. 所以不会产生断片er的问题.
但是以上两个方案却还不能解决一个很多(不包括我)喜欢引用计数的人纠结的问题: RAII不完美了. 本质上上面两个方案都不能在循环引用对象和root脱钩的一瞬间回收他们. 那么假设这些对象又是持有特殊资源的RAII对象, 那么RAII就坏了. 这对于喜欢RAII, 或者那些觉得用引用计数唯一的理由就是RAII的人确实是一个交易破坏者...
当然我觉得无所谓, 写C#的那些年里我还真没发现只能RAII才能解决的问题. 当然有RAII很多东西能方便很多, 但和自动内存回收比起来, 急需RAII才能解决的问题范围太小了, 我可以接受手动一点. 当然即使是这样, 在很多人看来上面的方案都是那么的不完美, 作为一个完美主义者, 我当然可以理解你把这个作为不用引用计数的理由.
第二个问题性能就更难了. 但其实并不是一个不能缓解的问题.
这方面有很多古代(70-00)先贤的论文. 每次我看这些70年代的人, 都已经把我今天在想的问题考虑的这么明白的时候. 就感觉这世界上聪明人真的太多了, 问题不够用. 低垂的果子早就摘完了, 剩下的都是些目前状态下几乎无解的问题. 只能等下一个业界跨越式的发展, 把大家拉到一个新的高度, 才能摘到.
引用计数的性能问题说详细了, 其实是引用赋值操作的时候做的+1/-1操作. 那么这个开销可以从两个维度看, 做操作的次数, 设为n; 每次操作的开销, 设为m. 那么整个开销就是n x m.
先从n, 也就是减少操作次数上想办法. 方法其实有很多, 比如基于堆栈的上下文分析, 去掉那些不会逃逸(这是个go的说法)的对象的引用计算. 在合适的地方插入析构的代码. 另外引用作为参数传递的时候的复制过程也是可以安全跳过计数操作. 因为只要是函数参数, 至少说明在整个函数的执行过程中生命周期是保证的.
还有个比较奇葩的想法是延迟计数, 说起来有点复杂就不展开了, 不过我不是很喜欢这个方案, 感觉把问题复杂化了. 另外有个合并计数变化的思路, 但是这个我还没想明白, 总感觉有点啥问题. 想研究的朋友可以看下英文维基百科的引用计数条目, 里面列了几篇论文.
总之从n上面做文章, 个人感觉50%到80%的性能提升是可以做到的. 瞎猜的, 其实我没有证据..
m上做文章就比较难了. 引用计数的操作说起来简单, 就是+1/-1, 完了判断下是否为0. 但实际上问题不止这些, 有一个被很多人念叨的问题是这个操作破坏了cpu cache. 理由是, 很多时候程序在做A.p = B操作的时候, 其实不会去读B的内存地址的, 这只是一个指针操作. 然而一旦引入了引用计数, 你就需要把B上面的计数值读一下, 那么cache就刷新了. 不过我觉得这个造成的影响很难评估, 可能根本就不大, 因为很多时候操作指针的前后, 程序还是会去读这片内存的.
另外一个问题是多线程带来的, 引用计数在多线程的环境下有了更复杂的问题. 要保证两个线程访问同一串数据的时候你的对象不会用着用着就没了, 需要锁或者compare-swap这样的原子操作. 这个的代价就真的大了.
那么解决方案可能很多人都猜到了, 一如既往的...就是不解决.
要么我这个语言就不提供多线程功能, 脚本语言里其实不少.
要么就不提供多线程访问同一个对象的功能. 只能用类似go的管道那种东西.
最后还可以整一个所谓的主从线程. 只要是从线程里面的访问都没有权限改变属于主线程中对象的引用计数.
当然以上的改造都意味者使用者要对现有的语言功能做出阉割. 对于有些人来说是不能接受的. 但是对于天天写lua的国内游戏开发者应该没什么站得住的立场....
值得一提的是, 引用计数的问题其实完全可以通过硬件解决. 据说苹果就为compare-swap操作做了单独的设计, 以加快自家的语言执行速度. 其实这是个很自然的操作, 毕竟同类的操作不止是引用计数有用到. 另外还有人搞过专门做引用计数的硬件. 这个思路其实是比较完美的解决方案, 没有并发问题, 也不影响cpu cache, 问题是没有大厂推动估计很难有成熟的方案出来.
所以一圈下来大家也看到了, 引用计数不是一个完美的解决方案. 但确实是一个可行的方案. 采用引用计数作为gc方案的语言很少, 但是还是有swift, obj-c, squirrel, python这些广泛使用的语言在用. 值得一说的是, 很多人说觉得因为每次操作都有做引用计算, 所以天生就比扫描式gc来的慢. 其实这个是不成立的, 长期运行的情况下每个对象都要扫描的gc快, 还是只计算当前操作对象的引用计数快其实很难说. 很多人写个benchmark分配了几M内存, 一跑, 就说你看扫描式gc就是快. 其实他的程序甚至都没跑到要gc的点, 咔咔咔在那顺序分配内存呢, 能不快吗? 比引用计数快都说保守了, 比C/C++都快好吧. java程序卡住十几秒那种时候怎么不说...
扫描式GC
引用计数研究之后, 其实想想上面说的上下文分析减少计数操作其实完全可以用到扫描式GC啊. 所以又开始看看现在的GC方案搞的怎么样.
这方面的资料确实有点太多了, 毕竟业界主攻方向. 但是我遇到的一个主要疑惑是, 为什么几乎所有的语言都在GC里面实现了Compact过程, 也就是内存整理. 实际我的理解是, compact完全是和GC不相关的两个东西, GC采用传统的内存回收方式而不进行整理是完全可行的. 从C/C++开始, 虽然内存碎片问题困扰业界很多年, 到TMalloc才缓解很多, 但是真的说不上是一个deal breaker啊. 难道GC的设计者们真就都这么理想主义, 非要一劳永逸的解决所有内存管理问题?
Compact当然好, 谁都知道. 加速分配过程, 优化cpu cache, 减小内存占用. 问题是compact的代价惊人啊... 把整个内存倒腾一遍能有快的? 多数的gc其实就是慢在这步. 去掉它卡世界的问题不就能好很多? 于是我又查了下, Java, C#都是做compact的. 而像go和lua都是不做compact的. lua嘛平时用的多大概有个了解, 感觉上确实比Unity里面GC来的平滑多了. 那么go语言呢?
Go的优势
总算是说到标题了. 随便上网上搜了下, 厉害了, 宣称是能达到毫秒级, 比C# GC快到不知道哪里去了. 于是我自己写了benchmark, 重复分配和释放30G的对象. 测试下来下go最大暂停时间16ms左右, 是c#的1/100.... 另外值得一说的是完成上面的测试C#完成花了接近两分钟, Go只要30秒. 虽然16ms, 依然是一个追求高效的游戏不可接受的时间, 但是考虑到实际游戏程序很少可能会达到这种1秒1G的垃圾吞吐量, 实际应用环境下应该是不错了.
除了采用并行的三色标记法, 不做compact让暂停短了很多之外. go的内存管理的语法设计也展现出设计者对于gc问题的思考.
基本上所有的带GC语言中, 你声明的对象除了基本类型基本都是分配在堆上. 平时程序传递的都是引用. 而go语言明确的给出了对象的引用(指针)和拷贝两种不同的操作. 这样配合go的上下文逃逸检测机制, go开发者可以很方便的控制垃圾生产的量, 在复制对象和制造垃圾之间做权衡. 而且还是完全内存安全的.
和C#这种要么用value type统统复制, 要么class统统引用的粗暴设计是完全不同的. 回到文章开头提到的, 在C#里做个本地字符串连接, 或者linq操作都要产生垃圾. 非要避免垃圾就会写成一种, 这个语言不是这样设计的感觉. 而go就给我的感觉就是, 这个语言就是这样设计的. 所以go确实不愧于一个带gc的C这样的说法.
所以总结下的话, 目前流行的语言中, Go是目前最平滑的扫描式GC, 也是就最适合写游戏的语言. 假如,, 有引擎支持它的话.
其实我还是更喜欢引用计数啦. 真正的怎么折腾都没有暂停的世界. 只是限制确实有点多, 也没有一个广泛应用的生态.