# 渲染独立进程化 — 实现计划 > **状态**:实施中(阶段 0、阶段 1 已完成) > **分支**:`feat/render-process-isolation` > **范围**:把视频帧渲染拆到独立进程,主进程通过共享内存 + stdio 调度多个渲染 worker,全程无锁。 --- ## 1. 背景(为什么做) Oak(Olive 分叉,Qt6/C++17 视频编辑器)当前是**单进程**架构:所有渲染在主进程的后台 `QThread` 里完成(`app/render/rendermanager.cpp` 的 `video_thread_` / `audio_thread_` / `waveform_threads_` 等),通过 `RenderManager::RenderFrame()` → `RenderThread` 队列 → `RenderProcessor::Process()` 的 ticket 异步管线工作。 把渲染留在主进程有三个问题: 1. **崩溃传染** —— OFX 第三方插件(0.3 里程碑的核心目标“任意 OFX 插件加载不崩溃”)一旦崩溃,会带走整个编辑器,丢失未保存的工作。 2. **难以横向扩展** —— GPU 上下文、解码器缓存都绑在一个进程里,无法利用多核/多 GPU 并行。 3. **预渲染受限** —— 预渲染窗口(见 `TODO.md` 的 LRU 预渲染计划)受单进程资源约束。 **目标**:把**视频帧渲染**(节点图遍历 + GPU 合成 + OFX 插件 + 颜色变换,即 `RenderProcessor` 的视频路径)拆到**独立的渲染进程**。主进程作为调度器,通过**共享内存 + stdio** 与**多个**渲染 worker 通信。硬性要求**无锁**:跨进程数据交换走预分配的共享内存 slot 池 + SPSC 环形索引队列, 控制平面走 stdio 上的换行分隔消息。 ### 1.1 已确认的范围决策 | 维度 | 决策 | |---|---| | **拆分范围** | 仅**视频帧渲染**。音频/波形/dry-run 暂留主进程。→ worker 链接 OpenGL / OCIO / OpenImageIO / OFX,**不**链接 UI(Widgets)。 | | **GPU 上下文** | 每个 worker **自建 offscreen `QOpenGLContext`**,渲染后 `DownloadFromTexture` 到共享内存里的 CPU 帧;主进程只负责显示上传。 | | **素材输入** | **主进程解码**(复用现有 `DecoderCache`),把解码后的原始帧经共享内存喂给 worker。→ worker **不**链接 FFmpeg。 | | **图同步** | **全量序列化**整个节点图(复用 `ProjectSerializer`),架构预留增量通道。 | | **帧回传** | **固定 slot 池 + 无锁环形队列**(按最大分辨率预分配)。 | | **控制协议** | **纯文本 NDJSON**(每行一条 JSON),便于 `cat`/`tee` 调试、手工注入测试。大块图数据走临时文件传路径。 | | **落地策略** | **分阶段**,每步可编译可验证,旧的进程内渲染保留为默认,用开关切换。 | --- ## 2. 现有架构锚点(复用,不重写) | 关注点 | 文件 / 符号 | |---|---| | 渲染调度/线程池 | `app/render/rendermanager.{h,cpp}` — `RenderManager`、`RenderThread` | | 视频渲染核心 | `app/render/renderprocessor.{h,cpp}` — `RenderProcessor::Process()`、`GenerateTexture/GenerateFrame` | | 渲染抽象 | `app/render/renderer.h`、`app/render/opengl/openglrenderer.{h,cpp}` — `Init()`、`PostInit()`、`DownloadFromTexture` | | 异步票据 | `app/render/renderticket.{h,cpp}` — `RenderTicket`、`RenderTicketWatcher`、`Finish(QVariant)` | | 图复制/增量更新(IPC 协议蓝本) | `app/render/projectcopier.{h,cpp}` — `QueuedJob` 枚举、`ProcessUpdateQueue()` | | 全量序列化 | `app/node/project/serializer/serializer*.{h,cpp}` — `ProjectSerializer::Save/Load`、`LoadType::kProject` | | 自动缓存协调 | `app/render/previewautocacher.{h,cpp}` — 票据的实际消费者 | | 帧内存(单段连续 buffer) | `app/codec/frame.{h,cpp}` + `app/render/framemanager.h` — `data_`/`linesize_`/`allocated_size()` | | 帧消费/显示 | `app/widget/viewer/viewer.cpp` — `SetDisplayImage()`、`ticket->Get()` | | 进程入口 | `app/main.cpp` — `QSurfaceFormat` 设置(OpenGL 3.2 core)、`AA_ShareOpenGLContexts` | | 构建 | 根 `CMakeLists.txt`、`app/CMakeLists.txt` — `add_executable(olive-editor ...)` + `libolive-editor` OBJECT 库 | **关键观察**: - `RenderProcessor::Process()` 已是无状态静态入口,参数全在 `ticket->property(...)` 里。这是进程边界的天然切割点。 - `Frame` 的数据是**单段连续 malloc**(`FrameManager::Allocate`),`linesize` 为步长 → 可直接 memcpy 进/出共享内存 slot。 - `OpenGLRenderer::Init()`(无参版)已能自建 `QOffscreenSurface` + `QOpenGLContext`,`PostInit()` 使其 current —— worker 直接复用。 - 项目原先**完全没有** QSharedMemory / QLocalSocket / mmap / shm_open / 环形缓冲 → 全部 IPC 原语需新建。 - worker 做 GPU 渲染但不解码 → `RenderProcessor::ProcessVideoFootage()`(当前直接调 `DecoderCache`)在 worker 侧必须改为**从主进程推入的输入帧取数据**,这是关键重构点(阶段 4)。 --- ## 3. 目标架构 ``` ┌─────────────────── 主进程 (olive-editor) ───────────────────┐ │ Viewer / PreviewAutoCacher │ │ │ GetSingleFrame() │ │ ▼ │ │ RenderManager (调度器) │ │ ├─ DecoderCache ← 解码原始素材帧 │ │ ├─ RenderWorkerPool ← 新增 │ │ │ ├─ WorkerProcess #0 (QProcess + stdio + SHM) │ │ │ ├─ WorkerProcess #1 │ │ │ └─ ... │ │ └─ ProjectSerializer ← 全量图快照 │ └─────────────────────────────────────────────────────────────┘ stdio (控制平面: NDJSON, 每行一条 JSON 消息) SHM (数据平面: 输入素材帧 slot 池 + 输出帧 slot 池, 无锁环形索引) │ ┌──────────────── 渲染进程 (olive-render-worker) ×N ───────────┐ │ workermain: 读 stdin NDJSON 控制循环 │ │ ├─ 反序列化节点图 (ProjectSerializer::Load) │ │ ├─ offscreen QOpenGLContext + OpenGLRenderer │ │ ├─ RenderProcessor (视频路径; ProcessVideoFootage 改为 │ │ │ 从输入 SHM slot 取帧, 不再直接解码) │ │ └─ DownloadFromTexture → 写输出 SHM slot → 发 frame_ready │ └─────────────────────────────────────────────────────────────┘ ``` ### 3.1 无锁 IPC 设计 **控制平面(stdio)**:worker 的 stdin/stdout,**纯文本 NDJSON**——每条消息一行 compact `QJsonObject`,`\n` 结尾。仅承载低频控制流量(握手、提交任务、取消、关闭)。 纯文本便于 `cat`/`tee` 抓管道调试、手工注入测试;单读单写天然无锁。诊断信息走 stderr, 绝不污染 stdout 控制通道。**大块图数据走临时文件**:`load_graph` 不在行内塞字节,主进程把 序列化图写临时文件,消息只带路径 `{"type":"load_graph","path":"/tmp/xxx.ove"}`。 **数据平面(共享内存)**:每个 worker 一段共享内存,封装在 `SharedMemoryRegion` (POSIX `shm_open`+`mmap` / Windows `CreateFileMapping`+`MapViewOfFile`)。布局由 `FrameSlotPool` 管理: - **两个 SPSC 环形队列**(`SpscRingBuffer`)的原子游标(`std::atomic` head/tail, `memory_order_acquire/release`):`free_ring`(空闲 slot 索引)和 `ready_ring`(已填充 slot 索引)。每个环单生产者单消费者 → 无需互斥锁。 - **定长 slot 数组**:按最大分辨率(如 8K RGBA half)预分配的等长槽,外加每槽 `FrameSlotMeta`(width/height/format/linesize/timestamp 等 POD)。 - **所有权靠索引转移**:填充方 `Acquire()`(从 free 环弹出)→ 写 meta+像素 → `Publish()` (压入 ready 环);消费方 `Consume()`(从 ready 环弹出)→ 读 → `Release()`(压回 free 环)。 环满即天然背压,无需额外锁。 一个 pool 建模单向帧流。输出方向(worker→主)放渲染结果;输入方向(主→worker)放解码素材。 --- ## 4. 分阶段实现计划 > 每个阶段都能独立编译、独立验证。前期阶段不改变现有行为(进程内渲染仍是默认), > 用开关切到多进程路径,最后再切默认。 ### ✅ 阶段 0:IPC 基础设施(已完成) 新增 `app/render/ipc/` 模块: - `spscringbuffer.h` —— header-only,`std::atomic` 游标的单生产者单消费者环形索引队列,POD,可直接放共享内存。 - `sharedmemoryregion.{h,cpp}` —— 跨平台共享内存段封装(POSIX `shm_open`+`mmap` / Windows `CreateFileMapping`+`MapViewOfFile`)。直接用原生 API 而非 `QSharedMemory`(后者带隐式信号量与引用计数,不适合大帧)。 - `frameslotpool.{h,cpp}` —— 在共享内存段上布局两个环 + 定长 slot 池;提供 `Acquire/Publish/Consume/Release` 与 `FrameSlotMeta`。 - `ipcmessage.{h,cpp}` —— NDJSON 控制消息编解码(`WriteMessage`/`ReadMessage` + 各类型的 `ToJson/FromJson`)。 **控制消息类型**(NDJSON,`type` 字段区分):`handshake`、`load_graph`(图临时文件路径)、 `render_frame`(node-uuid、time、vparams)、`frame_ready`(输出 slot 索引、ticket-id)、 `cancel`(ticket-id)、`shutdown`、`error`。预留 `graph_update` 增量类型(阶段 6 实现)。 **测试**(`tests/gtest/render_ipc_test.cpp`,Google Test): - 环形队列:基础语义 + 回绕 + **并发 200 万值** FIFO 无丢失无重复。 - slot 池:单线程握手 + 耗尽/回填 + **并发 20 万帧**数据完整性。 - NDJSON:类型往返 + 逐字节半包 + 畸形行跳过 + 错误类型拒绝。 > 注意:`SpscRingBuffer` 内部数组访问器命名为 `slot_array()` 而非 `slots()`,以规避 Qt 的 `slots` 宏。 ### ✅ 阶段 1:worker 可执行目标(已完成) - `app/CMakeLists.txt` 新增 `add_executable(olive-render-worker ...)`,复用 `libolive-editor` OBJECT 库 + `olive-version-obj`,与 `olive-gtest` 同款链接方式。 - 新增 `app/render/worker/workermain.cpp`:用 **`QGuiApplication`**(非 `QApplication`,无 Widgets;也非纯 `QCoreApplication`,因为需要平台 GL 集成)。 - 安装与主进程一致的 `QSurfaceFormat`(OpenGL 3.2 core,24 位深度),设置 `AA_UseDesktopOpenGL` / `AA_ShareOpenGLContexts`。 - 当前行为:`OpenGLRenderer::Init()` + `PostInit()` 建 offscreen GL 上下文 → 校验 `context()->isValid()` → 在 stdout 打一行 NDJSON 握手(含实际 GL 版本)→ 干净退出。 - **链接说明**:当前用全量 `OLIVE_LIBRARIES`(含 Widgets/FFmpeg),裁剪 UI-only 依赖留到后续阶段。 **验证结果**:worker 在默认平台与 `-platform offscreen` 下均成功输出 `{"gl_major":3,"gl_minor":2,...,"type":"handshake"}`,stdout 仅一行合法 JSON,退出码 0。 ### 阶段 2:worker 主循环 + 单帧渲染回路(基础回路已接入) - ✅ `workermain.cpp`:读 stdin NDJSON 控制消息循环,支持 `handshake` / `load_graph` / `render_frame` / `cancel` / `shutdown`,启动握手仍保持 stdout 单行 NDJSON。 - ✅ `load_graph` → `ProjectSerializer::Load(LoadType::kProject)` 反序列化出 `Project` + 节点图; `ProjectSerializer::LoadData` 现在暴露旧 ptr token → 新 `Node*` 映射,worker 用它解析 `render_frame.node`。旧版 serializer 已有的 node UUID 映射也保留兼容。 - ✅ `render_frame` → 构造本地 `RenderTicket`(参数从消息填 property,复刻 `RenderManager::RenderFrame` 的关键 `setProperty`)→ `RenderProcessor::Process(ticket, renderer, decoder_cache=nullptr, shader_cache)`。 - ✅ 先**不**接输入素材:渲染结果为 `FramePtr` 后写入输出 `FrameSlotPool` slot, 填 `FrameSlotMeta`,发布 slot 并回 `frame_ready`。 - ✅ 临时测试驱动启动 1 个 worker,加载最小 SolidGenerator 项目,主进程从输出 slot 读回 64x64 F32 RGBA 帧并校验元数据与像素非零。待固化为自动化测试。 **验证结果**: - `cmake --build build --target olive-render-worker olive-gtest -j2` 通过。 - `QT_QPA_PLATFORM=offscreen build/tests/gtest/olive-gtest --gtest_filter='SpscRingBuffer*:*FrameSlotPool*:*IpcMessage*:*ProjectSerializer*' --gtest_brief=1` 通过,11 个测试全部通过。 - 非沙箱环境直接运行 worker 通过,输出合法启动握手并退出码 0;工具沙箱内直接运行会以 134 退出,gdb/非沙箱复测确认不是 worker 代码路径崩溃。 - 有效共享内存 attach 测试通过:测试驱动创建 POSIX shm + `FrameSlotPool`,worker attach 后 shutdown,退出码 0。 - 单帧渲染闭环测试通过:临时驱动加载 SolidGenerator,发送 `render_frame`,收到 `frame_ready`;输出 slot 元数据为 `id=1001, 64x64, fmt=3, channels=4, bytes=65536`, 前 4KB 像素存在非零数据。 ### 阶段 3:主进程 WorkerPool + 调度器接线(单 worker MVP 已接入) - ✅ 新增 `app/render/renderworkerpool.{h,cpp}`: - 当前 MVP 用后台 `QThread` 持有任务队列,每个任务启动 1 个 `olive-render-worker`, 建立输出 SHM 段 + stdio 管道。 - `SubmitFrame(RenderTicketPtr, RenderVideoParams)`:写全量图快照临时文件 → 发送 `handshake` / `load_graph` / `render_frame` → worker 回 `frame_ready` 后从输出 slot 拷出 `FramePtr` → `ticket->Finish(...)`。对上层 `RenderTicketWatcher`/`Viewer` 保持透明。 - 当前仅支持普通视频 `ReturnType::kFrame`;素材输入仍按阶段 4 处理,失败或不支持时回退旧路径。 - ✅ `RenderManager` 增加 `kMultiProcess` backend 分支(与 `kOpenGL` 并存),开关开启且 WorkerPool 接受任务时 `RenderFrame()` 走 `RenderWorkerPool`。 - ✅ `Config` 增加 `RenderProcessIsolationEnabled`,默认 `false`,默认仍走进程内 `kOpenGL`。 - 待补:常驻 N worker、忙闲/负载派发、崩溃重启与重派、Viewer 开关实测。 **验证结果**: - `cmake --build build --target olive-render-worker olive-editor -j22` 通过。 - `QT_QPA_PLATFORM=offscreen build/tests/gtest/olive-gtest --gtest_filter='SpscRingBuffer*:*FrameSlotPool*:*IpcMessage*:*ProjectSerializer*' --gtest_brief=1` 通过,11 个测试全部通过。 - 非沙箱环境 `printf '{"type":"shutdown"}\n' | build/app/olive-render-worker` 通过,输出合法 handshake。 ### 阶段 4:素材输入解耦(关键重构) - `RenderProcessor::ProcessVideoFootage()`(`renderprocessor.cpp:397`)当前经 `ResolveDecoderFromInput` + `DecoderCache` 解码。worker 不链接 FFmpeg,需改为从输入 slot 取已解码帧上传纹理。 - 主进程侧 `RenderWorkerPool` 派发前用 `DecoderCache` 解出所需原始帧写入输入 slot,索引随 `render_frame` 一起发。 - 先支持单素材片段,再扩展到多层/转场。 - 验证:渲染含真实素材的时间线帧,与单进程结果逐像素一致。 ### 阶段 5:多 worker、取消、健壮性 - WorkerPool 扩到多 worker 并行预渲染窗口(对接 `PreviewAutoCacher` 范围缓存)。 - `cancel`:取消票据时通知 worker 丢弃在途任务(复用 `CancelableObject`/`RenderTicket::IsCancelled`)。 - worker 崩溃检测(`QProcess::finished` 异常码)→ 自动重启 + 重发 `load_graph` + 重派未完成票据。这是 OFX 崩溃隔离收益的兑现点。 - 背压:slot 池/环满时调度器暂缓派发(环满即天然背压)。 ### 阶段 6:图增量同步(可选优化) - 把 `ProjectCopier` 的 `QueuedJob`(kNodeAdded/kEdgeAdded/kValueChanged…)编码成 `graph_update` 消息,worker 侧等价 `ProcessUpdateQueue`,省去每次全量序列化。 - 阶段 0 已预留消息类型,此处填实现。 --- ## 5. 文件清单 **新增** | 文件 | 阶段 | 状态 | |---|---|---| | `app/render/ipc/spscringbuffer.h` | 0 | ✅ | | `app/render/ipc/sharedmemoryregion.{h,cpp}` | 0 | ✅ | | `app/render/ipc/frameslotpool.{h,cpp}` | 0 | ✅ | | `app/render/ipc/ipcmessage.{h,cpp}` | 0 | ✅ | | `app/render/ipc/CMakeLists.txt` | 0 | ✅ | | `tests/gtest/render_ipc_test.cpp` | 0 | ✅ | | `app/render/worker/workermain.cpp` | 1/2 | ✅ 基础主循环 | | `app/render/renderworkerpool.{h,cpp}` | 3 | ✅ 单 worker MVP | **修改** | 文件 | 阶段 | 状态 | |---|---|---| | `app/render/CMakeLists.txt`(加 `add_subdirectory(ipc)`) | 0 | ✅ | | `tests/gtest/CMakeLists.txt`(注册 ipc 测试) | 0 | ✅ | | `app/CMakeLists.txt`(新增 `olive-render-worker` target) | 1 | ✅ | | `app/node/project/serializer/serializer*.{h,cpp}`(暴露加载映射供 worker 查节点) | 2 | ✅ | | `app/render/rendermanager.{h,cpp}`(`kMultiProcess` 分支 + WorkerPool 接线) | 3 | ✅ 单 worker MVP | | `app/render/renderprocessor.cpp`(`ProcessVideoFootage` 改取输入 slot) | 4 | 待办 | | `app/config/config.cpp`(多进程开关默认值) | 3 | ✅ 默认关闭 | --- ## 6. 验证方式(端到端) 1. **IPC 单元测试**:多线程压测 SPSC 环形队列 + slot 池,确认无锁正确性(无丢失/重复/数据竞争,可配 TSan)。— 阶段 0 已覆盖。 2. **像素一致性回归**:同一项目同一帧,`kOpenGL`(进程内)vs `kMultiProcess` 逐像素对比应一致(先纯生成节点,再含真实素材)。 3. **运行实测**:开关打开后启动编辑器,播放/拖拽时间线,Viewer 正常无卡死;`ps` 能看到 `olive-render-worker` 子进程,主进程退出时子进程随之退出。 4. **崩溃隔离**:人为让 worker 段错误(或加载会崩的 OFX 插件),确认主进程存活、WorkerPool 自动重启并恢复渲染。 5. **性能**:多 worker 预渲染窗口吞吐 vs 单进程基线对比。 --- ## 7. 开放问题(实现时定) - SHM slot 尺寸/数量的默认值(按硬件分档,参考 `TODO.md` 同款问题)。 - worker 数默认值(CPU/GPU 数推导)。 - OFX 插件在多 worker 下的句柄/许可证并发是否有限制。 - worker 链接集裁剪时机:何时安全移除 Widgets/FFmpeg 依赖(依赖阶段 4 素材解耦完成)。