18 KiB
渲染独立进程化 — 实现计划
状态:实施中(阶段 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 异步管线工作。
把渲染留在主进程有三个问题:
- 崩溃传染 —— OFX 第三方插件(0.3 里程碑的核心目标“任意 OFX 插件加载不崩溃”)一旦崩溃,会带走整个编辑器,丢失未保存的工作。
- 难以横向扩展 —— GPU 上下文、解码器缓存都绑在一个进程里,无法利用多核/多 GPU 并行。
- 预渲染受限 —— 预渲染窗口(见
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<uint32_t>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}—— 跨平台共享内存段封装(POSIXshm_open+mmap/ WindowsCreateFileMapping+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-editorOBJECT 库 +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后写入输出FrameSlotPoolslot, 填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 处理,失败或不支持时回退旧路径。
- 当前 MVP 用后台
- ✅
RenderManager增加kMultiProcessbackend 分支(与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. 验证方式(端到端)
- IPC 单元测试:多线程压测 SPSC 环形队列 + slot 池,确认无锁正确性(无丢失/重复/数据竞争,可配 TSan)。— 阶段 0 已覆盖。
- 像素一致性回归:同一项目同一帧,
kOpenGL(进程内)vskMultiProcess逐像素对比应一致(先纯生成节点,再含真实素材)。 - 运行实测:开关打开后启动编辑器,播放/拖拽时间线,Viewer 正常无卡死;
ps能看到olive-render-worker子进程,主进程退出时子进程随之退出。 - 崩溃隔离:人为让 worker 段错误(或加载会崩的 OFX 插件),确认主进程存活、WorkerPool 自动重启并恢复渲染。
- 性能:多 worker 预渲染窗口吞吐 vs 单进程基线对比。
7. 开放问题(实现时定)
- SHM slot 尺寸/数量的默认值(按硬件分档,参考
TODO.md同款问题)。 - worker 数默认值(CPU/GPU 数推导)。
- OFX 插件在多 worker 下的句柄/许可证并发是否有限制。
- worker 链接集裁剪时机:何时安全移除 Widgets/FFmpeg 依赖(依赖阶段 4 素材解耦完成)。