按: 本文成文于一年前 [2013-01-13],修相关的 bug 修得郁闷,遂成文,[2014-03-20] 整理旧文档时稍作修订。

为什么从本质上讲,渲染逻辑不适合放到子线程中去?

因为渲染是一个需要大量的 CPU 和 GPU 同时参与的操作,无论如何组织架构,在事实上均难以避免高频和实时的较大数量的数据的更新和传输,这是渲染(尤其是 大量动态物体的数据在不断被改变 的情况下)的本质决定的。

从实践上看,那些线程化渲染(threaded rendering)的常见做法中,勉强去做每帧同步,或隔帧同步,或延迟1-N帧同步,均会导致大量的细节被暴露给整个架构。此亦从根本上违反了软件开发封装的基本思想——任何一点底层的改动(只要涉及到数据同步和交换),均需要对相关系统的大部分运行机制有充分的了解,否则极易造成线程间的延迟,starving(可勉强译为饥饿,不知正解)或不必要的等待。整个系统充斥着不相干系统间的隐形依赖,从而变得僵化和脆弱,及不可避免的复杂度剧增。

请注意,我不是在说线程化渲染性能不好。正相反,把渲染从主线程拆出去能迅速看到效果,毕竟跟数据同步带来的开销相比,节省下来的hard stall(可勉强译为“硬卡”,指难以从调用方角度优化的卡顿)从毫秒数上要大得多。为了获得这种好处,人们通常很难抗拒这么做(正如 Unreal 引擎的实现)。与之相对的是,由于绝大部分项目的主线程代码是一整坨游戏的逻辑,几乎毫无阻碍地直接访问所有的子系统和数据,这就意味着在一个已有系统中把游戏逻辑切割后分出去(若不从头设计的话)事实上近乎不可能。

摘录一段 John Carmack 的观点(原文链接)吧:

The Doom 4 codebase now jumps through hoops to create the game window from the render thread and pump messages on it, but the better solution, which I have implemented in another project under development, is to leave the rendering on the launch thread, and run the game logic in the spawned thread.

显然,卡神在新的项目(Doom 4之后)里做到了把逻辑从主线程里切出去。从实现上讲,这会需要更高的开发人员的素质(做具体逻辑的同学需要有较强的并发意识,消息传递和数据访问的控制力);而从架构上讲,这对整个系统是有很大好处的,主要的子系统被逐步地解耦合,从根本上降低了整个项目的复杂度。考虑到随着时间推移,单个机器上处理器的数目不断在增长,提高各个子系统的独立性和内聚性(相比“一个需要繁重数据同步的渲染线程和一个臃肿的主线程”而言)在将来会带来更多的性能优势。

关于在这种情形下模块的设计,云风也曾有过很不错的论述(原文链接):

接口的最小知识表达就是用一致的 C 函数调用约定...应该是无全局相关状态的。这不仅仅是为了线程安全,而是可以保证没有隐式约定(额外的知识)。
...一个独立模块需要解决的问题,通常对外界的信息交换应该是低频的,它应该是可以独立工作解决更复杂的问题的。而不应该是不断的要求外部告知它新的状态变换。

此处所谓的隐式约定即是前文中提到的“不相干系统间的隐形依赖”。而上文提到的“切割主线程的逻辑模块”这一过程,可以促使程序员对“数据是如何在子系统间交换和更新”有更深的认识(是把人家的数据直接拿来用,拿来改?还是经由统一定义的接口函数调用?亦或是异步收发消息?)对这些问题的考察和理解,将有助于程序员持续地做出有利于整体设计的重构。

简单地说,我们希望放进线程里的东西有两个特点:

  1. 高度内聚的独立模块(如封装良好的物理系统或AI)这样良好定义的系统与外界数据交换的特点是低频低带宽(low frequency, low bandwidth)。
  2. 高强度地使用某个单一的系统资源 (如CPU密集或IO密集)。

按照这个特征的符合度,常见子系统大致可按如下排列

音频 > 硬盘IO > 网络IO > 物理模拟 > 一般游戏逻辑的模拟 > 输入设备响应(对应键盘鼠标事件)和用于渲染的视频输出

这意味着(理想情况下)我们应该只在主线程保留“输入设备的响应”和“屏幕渲染输出”这两样。巧合的是,这两样恰恰是游戏内玩家对延迟最敏感,对实时性要求也最高的系统。整个系统的设计也变得足够简单和清晰:主线程运行在所能达到的最佳帧率上;输入和输出无延迟;实时性的资源在主线程分配和释放(线程间的数据同步最小化);每个子系统以各自理想的更新频率工作(线程间的消息通信最小化)。

当然,利用闲置的GPU资源进行通用处理,则不在此列。但在设计时仍需非常小心,这类运算 更宜降低数据交换的频率(尤其是写回) ,避免对总线的长时间占用,避免经由颠簸效应影响到GPU的本职工作——渲染。

最后再说一句,从系统设计的角度讲,上述讨论也基本适用于 独立出单独的进程 的情况。
再多罗嗦一句,对于有些年头的项目,这样的重构可能不太现实,性价比不高;而对于新开的项目,可以是一个供参考的思路。

(完)
Gu Lu

Comments
Write a Comment

Tags

随笔   游戏开发   Programming   C/C++   优化   Unity   C++   知乎   游戏设计   比特币   Unity3D   区块链   软件开发   Bitcoin   引擎设计   系统架构   Production   idtech   中国文化   加密货币   项目管理   游戏评论   资源管理   资源流水线   效率   道德经   网络   方法论   模板编程   Blockchain   Lua   Blockchain Computing   Oculus   GDC   渲染   VR   PerfAssist   Unity MemoryProfiler   BCH   经济学   信息过载   行业报告   字体   Productivity   图形   网络编程   Dice   协程   EMC   Premake   测试   中间件   Game Engine   新手引导   区块链游戏   Methodology   CI   命令行解析   goroutine   ndk   Ethereum   nanomsg   自动化   Scripting   摘录   Debugging   同步技术   cppcon   C++模板   DOOM3   技术评估   Unity GC   C++11   学习方法   Surface Pro 3   Engine Evaluation   CRT   文化   笔记   golang   图形编程   多线程   ETH   Bitcoin Cash   cppcon14   Bitcoin SV   Visual Studio   Unity Coroutine   跨语言可变参数列表   团队协作   货币   Deployment   Visual Assist   工程改进   Michael Abrash   exp   开放世界   量子计算   域名   虚拟现实   系统重构   slua   遮挡剔除   完美转发   协作式调度   Modern C++   类型推导   Memory Debugging   个人成长   小故事   BTC   暴雪   产品   历史   错误处理   Unity Profiler   MOD  

知识共享许可协议
本作品由Gu Lu创作,采用知识共享Attribution-NonCommercial-NoDerivatives 4.0 国际许可协议进行许可。