Skip to content

单进程程序结构

从单进程程序的结构上看,常见程序的结构可以分成两种:one-shot 和 daemon。

One-shot 程序读取命令行参数后,从前到后一次性执行,处理完数据后直接退出。这类程序通常较为简单,如命令行工具、批处理脚本等。这种程序可以认为是一次 IO 请求。

Daemon 程序在解析完命令行参数和配置文件后进入死循环,通过阻塞式系统调用监听和等待它感兴趣的事件,并在事件发生时被操作系统唤醒,然后执行业务逻辑。除非触发关闭命令或者异常,否则会按照期望一直运行下去。这是我们平时编写多数程序的结构,包括前端的 GUI 客户端程序和后端的 server 程序。这种程序可以认为是一个持续运行的状态机。

c
// daemon 程序
int main(int argc, char *argv[]) {
  // 读取命令行参数,形成启动参数
  Args *args = parseArgs(argc, argv);
  // 读取配置文件,形成程序配置
  Config *config = read(file);

  while(true) {
    // 查找配置中的监听任务
    void *event = findTask(args, config);
    if (event == NULL) {
      break;
    }

    // 阻塞式调用,根据配置信息按照要求监听客户端的请求
    void* incoming = listen(args, config);

    // 业务层寻找处理该请求的函数
    void* handler = findHandler(args, config, incoming);

    // 分发该请求
    void* result = distribute(args, config, incoming, handler);

    // 响应请求
    response(incoming, result);
  }

  // 释放程序的所有资源
  cleanAll(args, config);
  return 0;
}

// 清除监听任务的请求处理器
void handleShutdown(Args *args, Config *config, ...) {
  cleanTask(args, config);
}

Daemon 程序

从纵向来看,程序需要在开始时解析配置和参数,然后进入循环,从配置和参数中寻找需要监听和处理的任务。如果找到了,则根据任务的类型,将具体任务分发给相应的处理函数,得到处理结果,然后把结果返回给客户端,往复循环。

何时停止?当需要监听的事件和任务被处理完了,或者触发了清理监听任务的处理器的时候,由于没有任务,则程序 break 出循环,程序结束。

同时,在处理任务的过程中,可能由于监听到了新的事件发生,从而向任务队列中添加了新的任务,也就是任务是可增可减、动态变化的,形如一个状态机的结构。

从横向看,daemon 程序的结构可以被概括为一个"前中后"三层结构——前端视图层中间业务层后端持久层

  • 视图层:面向自己的客户端,监听客户端的请求和操作,解析和翻译操作,并将请求的任务分发给中间业务层
  • 中间层:负责进行主体复杂逻辑的实现和繁重工作的处理,它在处理业务的过程中可能需要依赖外界的持久化数据,或者将数据进行持久化,它会选择性地调用后端持久层,进行数据持久化的操作
  • 后端持久层:通过系统 IO 调用,向进程外发送和获取数据,或是作为数据的直接管理者,代替进程中的其他部分管理数据

在横向的三层中,必须要保持单向依赖,以简化程序的结构,让层间的功能保持独立。

客户端 Daemon 程序

相较于服务端的 server 程序,客户端的 daemon 渲染程序有一个特殊的地方:渲染 daemon 在主循环中加入了渲染 tick。主循环针对键盘 IO 等事件不再立即回应(相比 server 程序是立即回应),渲染 daemon 会采用计时器来计时,每过一段时间批量处理所有的事件,并结合游戏程序经常采用的 ECS 架构模式,批量并行处理上层业务逻辑,这些上层的业务逻辑通常由 GUI 客户端开发者编写。

MVC 架构

MVC(Model-View-Controller)是现代 daemon 程序的典型架构模式,特别适用于 Web 应用和桌面应用。它将程序划分为三层,各层负责自己的逻辑,拆分了复杂的业务模块,降低了软件开发的复杂度。

三层职责

  • Model(模型):负责数据和业务逻辑,管理应用程序的状态,处理数据持久化
  • View(视图):负责用户界面展示,显示模型中的数据,不包含业务逻辑
  • Controller(控制器):处理用户输入,更新模型,选择适当的视图进行显示

MVC 的工作流程

用户请求 → Controller → Model(业务逻辑) → 数据更新

View ← 数据渲染 ← Controller ← 状态返回
  1. 用户通过视图发起请求
  2. 控制器接收请求并处理
  3. 控制器调用模型处理业务逻辑
  4. 模型更新数据状态
  5. 控制器选择视图进行展示
  6. 视图从模型获取数据并渲染

MVC 的进化

MVC 的缺点是 controller 的体积较大,逻辑在该层沉积;为了减少该层的臃肿,MVP 和 MVVM 架构是在 MVC 的架构上微调,使得 controller 层完全不再感知 view,自此 MVC 结构也逐渐和 MVP,MVVM 混淆在一起,MVC 正式成为了三层单向依赖架构程序的代名词。

用户请求 → View → Presenter → Model → 数据更新

        ← View ← Presenter ← 结果返回

或者使用更加现代的称呼

UI -> Service -> DAO

MVP(Model-View-Presenter)

  • Presenter 替代 Controller,作为中间层完全隔离 View 和 Model
  • View 和 Model 完全分离,View 只负责展示,Presenter 处理所有逻辑
  • 更适合桌面应用和传统客户端应用

MVVM(Model-View-ViewModel)

  • ViewModel 作为 View 和 Model 的桥梁
  • 通过数据绑定机制实现 View 和 ViewModel 的自动同步
  • 更适合现代前端框架(如 Vue、Angular)

MVC 的最佳实践

  • 保持模型简单:只包含数据和业务逻辑,避免在模型中处理 UI 逻辑
  • 视图职责单一:只负责展示,不包含业务逻辑
  • 使用依赖注入:降低组件耦合,提高可测试性
  • 分层解耦:确保层间的单向依赖,避免循环依赖

ECS 架构

ECS(Entity-Component-System)是一种主要用于渲染程序和游戏开发的架构模式。它通过将数据和行为分离来实现高度模块化和可扩展的系统设计。在游戏中,结合渲染引擎的渲染节奏,插空批量执行游戏逻辑。

核心优势

  • 模块化:基于组合优于继承的思想,组件可以自由组合,系统可以独立开发。开发者可以根据需要灵活地组合不同的组件来创建新的实体类型,而无需修改现有代码。
  • 性能优化:组件数据存储在连续的内存空间中,数据局部性好,便于实现缓存友好型代码。特别适合处理大量相似对象,系统可以高效地遍历和处理相同类型的组件数据。
  • 可维护性:通过关注点分离,使代码结构更加清晰。每个系统只负责处理特定类型的组件,组件只包含数据,系统只包含逻辑,这种清晰的职责划分使得代码易于理解和维护。
  • 灵活性:实体可以在运行时动态添加或移除组件,系统可以独立启用或禁用,便于实现热重载功能。

核心概念

Entity(实体)

  • 实体是游戏世界中的基本单位
  • 实体本身不包含任何数据或行为
  • 实体只是一个唯一的标识符,用于组合不同的组件
  • 例如:玩家、敌人、道具等都可以是实体

Component(组件)

  • 组件是纯数据容器
  • 每个组件只包含特定类型的数据
  • 组件没有行为逻辑
  • 常见组件示例:
    • PositionComponent:位置信息(x, y, z 坐标)
    • HealthComponent:生命值(当前值、最大值)
    • SpriteComponent:精灵/图像(纹理、尺寸)
    • CollisionComponent:碰撞信息(碰撞盒、碰撞组)

System(系统)

  • 系统是处理逻辑的地方
  • 每个系统负责处理特定类型的组件
  • 系统不存储状态,只处理数据
  • 常见系统示例:
    • MovementSystem:处理移动逻辑
    • RenderingSystem:处理渲染逻辑
    • CollisionSystem:处理碰撞检测
    • HealthSystem:处理生命值变化

ECS 的工作流程

c
// ECS 的典型执行流程
int gameLoop(Config *config) {
  while (running) {
    // 1. 输入处理
    inputSystem.process();

    // 2. 系统更新(按依赖顺序)
    movementSystem.update(deltaTime);
    collisionSystem.update();
    healthSystem.update();

    // 3. 渲染
    renderingSystem.render();

    // 4. 等待下一个 tick
    waitForNextFrame();
  }
  return 0;
}

int main(int argc, char **argv) {
    Config *config = readConfig();

    config->inputSystem = initInputSystem();
    // ...
    config->movementSystem = initMovementSystem();

    int ret = gameLoop(config);

    return ret;
}

ECS 的最佳实践

  • 保持组件简单:组件只包含数据,不包含逻辑
  • 系统职责单一:每个系统只负责一类特定的行为
  • 避免系统间依赖:系统之间应该尽可能独立,通过组件通信
  • 合理组织数据:使用结构体数组(SoA)而非数组结构体(AoS)以提高缓存命中率
  • 注意性能瓶颈:避免在系统执行过程中添加/删除组件或实体

掉帧问题:ECS 可以极大地提高一次渲染循环中的业务处理计算量,但是,如果开发者在渲染循环中加入了阻塞任务,那么极有可能导致在渲染循环即将到来的下一个周期,还没能够处理完上一个周期的任务,因此错过了本轮的渲染画面更新,从而导致了掉帧的情况。掉帧对于用户来说,表现行为就是画面一卡一卡的。

MVC 与 ECS 的对比

MVC 和 ECS 都是处理 daemon 程序的架构模式,但它们的适用场景和设计理念有所不同。

适用场景

特性MVCECS
应用类型Web 应用、桌面应用游戏、渲染程序
处理方式事件驱动、即时响应批量处理、定时渲染
性能要求中等极高
复杂度较低较高
  • ECS 是针对 MVC 架构中中间业务处理层的并行性不够和批量化处理能力不够的现实情况提出的业务处理模式。ECS 可以通过 System 抽象,将不同类型的业务进行并行化逻辑,提高了 CPU 的利用率。

  • ECS 与渲染程序的渲染节奏更加契合。渲染程序会采用一定的渲染节奏,每隔一段时间触发一次批量化的业务逻辑计算,再进行画面的更新,而不是像 MVC 程序(例如 web server)那样,一旦接受到新的 IO 任务就开始一次业务分发和处理。

    这将有效提高程序在大规模 IO 事件和批量化任务面前的吞吐能力,但是在一定程度上引入了延迟,这也是为什么 server 程序不采用 ECS 的原因。

延迟与吞吐的权衡

  • MVC:低延迟、中等吞吐。适合需要快速响应用户请求的应用,如 Web 服务、企业应用等。
  • ECS:较高延迟、极高吞吐。适合需要处理大量实体和复杂逻辑的游戏和渲染程序,延迟通常在一帧以内(16.67ms @ 60fps),用户感知不明显。