按: 本文成文于一年前 [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 函数调用约定…应该是无全局相关状态的。这不仅仅是为了线程安全,而是可以保证没有隐式约定(额外的知识)。 …一个独立模块需要解决的问题,通常对外界的信息交换应该是低频的,它应该是可以独立工作解决更复杂的问题的。而不应该是不断的要求外部告知它新的状态变换。
此处所谓的隐式约定即是前文中提到的“不相干系统间的隐形依赖”。而上文提到的“切割主线程的逻辑模块”这一过程,可以促使程序员对“数据是如何在子系统间交换和更新”有更深的认识(是把人家的数据直接拿来用,拿来改?还是经由统一定义的接口函数调用?亦或是异步收发消息?)对这些问题的考察和理解,将有助于程序员持续地做出有利于整体设计的重构。
简单地说,我们希望放进线程里的东西有两个特点:
- 高度内聚的独立模块(如封装良好的物理系统或AI)这样良好定义的系统与外界数据交换的特点是低频低带宽(low frequency, low bandwidth)。
- 高强度地使用某个单一的系统资源 (如CPU密集或IO密集)。
按照这个特征的符合度,常见子系统大致可按如下排列
音频 > 硬盘IO > 网络IO > 物理模拟 > 一般游戏逻辑的模拟 > 输入设备响应(对应键盘鼠标事件)和用于渲染的视频输出
这意味着(理想情况下)我们应该只在主线程保留“输入设备的响应”和“屏幕渲染输出”这两样。巧合的是,这两样恰恰是游戏内玩家对延迟最敏感,对实时性要求也最高的系统。整个系统的设计也变得足够简单和清晰:主线程运行在所能达到的最佳帧率上;输入和输出无延迟;实时性的资源在主线程分配和释放(线程间的数据同步最小化);每个子系统以各自理想的更新频率工作(线程间的消息通信最小化)。
当然,利用闲置的GPU资源进行通用处理,则不在此列。但在设计时仍需非常小心,这类运算 更宜降低数据交换的频率(尤其是写回) ,避免对总线的长时间占用,避免经由颠簸效应影响到GPU的本职工作——渲染。
最后再说一句,从系统设计的角度讲,上述讨论也基本适用于 独立出单独的进程 的情况。 再多罗嗦一句,对于有些年头的项目,这样的重构可能不太现实,性价比不高;而对于新开的项目,可以是一个供参考的思路。
(全文完)