Nebula3的多线程架构
Nebula3的代码运行在两种根本不同的方案中. 第一种方案我称之为”Fat Thread”. 一个Fat Thread在一个线程中运行一个完整的子系统(如渲染, 音频, AI, 物理, 资源管理), 并且基本上锁定在一个特定的核心上.
第二种类型的线程我叫它”Job”. 一个job是一些数据和用于处理这些数据的包装成C++对象的代码. 工作调度程序掌管了Job对象, 并且把工作分配给低负载的核心来保持它们一直处于忙碌状态.
显然, 挑战就是设计一个经过全面考虑的系统, 以保持所有的核心一直均匀地忙碌着. 这不但意味着连续的活动需要在游戏每帧的空闲时期内轮流交替, 而且要求job对象不得不事先(如每帧前)创建好, 这样才能在各种Fat Thread空闲时填充当前帧的空白.
这是我希望进行更多试验和调整的地方.
第二个挑战就是让程序员的工作尽量的简单. 一个游戏应用程序员(逻辑程序员)在任何时候都不应该关心他运行在一个多线程的环境中, 不应该担心会产生死锁或改写了其它线程的数据, 也不应该瞎搞一些临界区, 事件和信号量. 同样, 整个引擎的架构也不应该是”脆弱的”. 大部分传统的多线程代码在一定程度上都会发生紊乱, 或者忘记了临界区而打乱数据.
当线程间需要进行数据共享和通信时, 多线程就变得很棘手. 像两个临界区这样的解决方案也会导致脆弱代码问题.
从大的角度来说, Nebula3通过一个”并行Nebula”的概念解决了这个两个问题. 其思想就是运行了一个完整子系统的”Fat Thread”都有自己的最小Nebula运行库, 这个最小运行库刚好包含了这个子系统需要的部分. 因此, 如果这个运行在它自己线程中的子系统需要进行文件访问, 它会有一个跟其它Fat Thread完全分离的文件服务器(file server). 这个解决方案的优点是, 大部分Nebula中的代码都不需要知道它运行在一个多线程的环境中, 因为在fat thread之间没有数据进行共享. 运行着的每个最小Nebula内核是跟其它Nebula内核完全隔离的. 缺点就是, 重复的数据会浪费一些内存, 但是我们只是占用几KB, 而不是MB.
这些数据冗余消除了细密的锁定, 并且解决把程序员从思考每一行代码的多线程安全性中解放了出来.
当然, 从某种意义上说Fat Thread间的通信是肯定会发生的, 要不然这整个思想就没有意义了. 方法就是建立一个且只有一个的标准通信系统, 并且保证这个通信系统是可靠而快速的. 这就是消息系统的由来. 要跟一个Fat Thread通信的话只有发送一个消息给它. 消息是一个简单的C++对象, 它包含了一些带有get/set方法的数据. 通过这个标准的通信手段, 实际上只有消息子系统才需要是线程安全的(同样, 访问跟消息相关的资源时, 如内存缓冲区, 必须受到约束, 因们它们代表了共享数据). (xoyojank: 我说咋那么多Message…)
这样虽然解决了Fat Thread方案中大多数的多线程问题, 但没有解决Job对象的任何事情. Nebula3很有可能需要约束一个Job对象能做什么和不能做什么. 最直接的行为就是限制job做内存缓冲区的计算. 那样的话, job中就不能存在复杂的运行库(不能文件I/O, 不能访问渲染等等). 如果这样还不够的话, 必须定义一个”job运行时环境”, 就像Fat Thread中的那样. 因为一个job不会发起它自己的线程, 而且还会被调度到一个已经存在的线程池中. 就这个方面来说, 这不存在什么问题.
到现在为止(xoyojank: 2007/01/21, 最新版本已经实现了多数子系统的多线程化), 只有IO子系统作为概念证明在Fat Thread中得到实现, 并且它运行得很今人满意. 在做传统的同步IO工作时, 一个Nebula3程序可以直接调用本地线程的IO子系统. 所以像列出文件夹的内容或删除一个文件, 只会调用一个简单的C++方法. 对于异步IO工作, 定义了一些常见的IO操作消息(如ReadStream, WriteStream, CopyFile, DeleteFile, 等等). 进行异步IO只需要几行代码: 创建一个消息对象, 填充数据, 并发送这个消息到一个IOInterface单件. 如果必要的话, 这可能会需要等待和轮询异步操作.
这样的好处就是, 整个IO子系统没有一行多线程意义上的代码, 因为各个在不同的Fat Thread中的IO子系统是完全隔离的(当然, 同步肯定会发生在一些IO操作上, 但那都留给操作系统了).