688 lines
12 KiB
Markdown
688 lines
12 KiB
Markdown
下面这个就是我建议你这个 **多图表后台渲染库**的完整模型。
|
||
|
||
核心目标是:
|
||
|
||
```text
|
||
元数据变化才计算
|
||
后台只算最新状态
|
||
主线程只贴图
|
||
多个图统一调度
|
||
不堆旧任务
|
||
不显示半成品
|
||
不因为 paintEvent 无意义重算
|
||
```
|
||
|
||
---
|
||
|
||
# 1. 总体管线
|
||
|
||
每个图都是这个流程:
|
||
|
||
```text
|
||
主线程修改元数据
|
||
↓
|
||
meta_version++
|
||
↓
|
||
调度器定时扫描
|
||
↓
|
||
判断这个图是否需要提交 CPU 任务
|
||
↓
|
||
生成元数据快照 Render_Input
|
||
↓
|
||
扔进 CPU 线程池
|
||
↓
|
||
CPU 线程生成 Pixel_Frame
|
||
↓
|
||
发布到三缓冲 ready
|
||
↓
|
||
主线程 update 合并
|
||
↓
|
||
paintEvent 消费 ready,贴 front
|
||
```
|
||
|
||
也就是:
|
||
|
||
```text
|
||
Meta -> Render_Input -> CPU Render -> Pixel_Frame -> paintEvent drawImage
|
||
```
|
||
|
||
每种具体图只需要实现:
|
||
|
||
```text
|
||
元数据 -> 像素图
|
||
```
|
||
|
||
线程控制、版本控制、三缓冲、刷新合并都放到通用框架里。
|
||
|
||
---
|
||
|
||
# 2. 每个图不要自己开线程
|
||
|
||
一堆图不能每个图一个线程、一个 timer。
|
||
|
||
应该是:
|
||
|
||
```text
|
||
一个 Render_Scheduler
|
||
一个 CPU Thread_Pool
|
||
多个 Chart_Node
|
||
```
|
||
|
||
结构类似:
|
||
|
||
```text
|
||
Render_Scheduler
|
||
Chart_Node 1
|
||
Chart_Node 2
|
||
Chart_Node 3
|
||
...
|
||
Thread_Pool
|
||
```
|
||
|
||
调度器负责:
|
||
|
||
```text
|
||
扫描哪些图需要重算
|
||
限制每轮提交数量
|
||
限制单图提交频率
|
||
跳过不可见图
|
||
合并 update 区域
|
||
控制 CPU 线程池压力
|
||
```
|
||
|
||
具体图只负责:
|
||
|
||
```text
|
||
准备元数据
|
||
执行 CPU 绘制算法
|
||
提供 dirty rect / 绘制区域
|
||
```
|
||
|
||
---
|
||
|
||
# 3. 每个图的版本号
|
||
|
||
每个图建议维护这些版本:
|
||
|
||
```cpp
|
||
struct Chart_Versions {
|
||
std::uint64_t meta = 0;
|
||
std::uint64_t submitted = 0;
|
||
std::uint64_t running = 0;
|
||
std::uint64_t rendered = 0;
|
||
std::uint64_t requested_paint = 0;
|
||
std::uint64_t painted = 0;
|
||
};
|
||
```
|
||
|
||
语义:
|
||
|
||
```text
|
||
meta
|
||
主线程最新元数据版本。
|
||
|
||
submitted
|
||
已经提交给 CPU 线程池的版本。
|
||
|
||
running
|
||
当前 CPU 正在计算的版本。
|
||
|
||
rendered
|
||
后台已经生成像素图的版本。
|
||
|
||
requested_paint
|
||
已经请求主线程刷新的版本。
|
||
|
||
painted
|
||
paintEvent 已经贴出去的版本。
|
||
```
|
||
|
||
这里不要用 `OK`、`imageReady` 这种 bool。
|
||
|
||
bool 只能表示:
|
||
|
||
```text
|
||
有 / 没有
|
||
```
|
||
|
||
版本号能表示:
|
||
|
||
```text
|
||
是哪一版
|
||
新不新
|
||
有没有提交
|
||
有没有生成
|
||
有没有请求 update
|
||
有没有真正 paint
|
||
```
|
||
|
||
---
|
||
|
||
# 4. 元数据层:双缓冲或者快照
|
||
|
||
元数据是用户操作和数据输入修改的东西,比如:
|
||
|
||
```text
|
||
坐标范围
|
||
颜色表
|
||
缩放
|
||
图尺寸
|
||
数据源
|
||
采样参数
|
||
显示开关
|
||
曲线样式
|
||
```
|
||
|
||
主线程修改元数据:
|
||
|
||
```cpp
|
||
void Chart_Node::mark_meta_changed() {
|
||
++versions.meta;
|
||
}
|
||
```
|
||
|
||
调度器提交任务时生成快照:
|
||
|
||
```cpp
|
||
Render_Input Chart_Node::make_render_input() {
|
||
Render_Input input;
|
||
input.meta = edit_meta;
|
||
input.version = versions.meta;
|
||
return input;
|
||
}
|
||
```
|
||
|
||
如果元数据很小,直接拷贝快照。
|
||
|
||
如果元数据很大,用双缓冲或者共享不可变快照。
|
||
|
||
这一层一般不需要三缓冲。
|
||
|
||
原因是元数据中间版本可以丢,只要后台下一轮拿最新版本即可。
|
||
|
||
---
|
||
|
||
# 5. 任务层:single-flight
|
||
|
||
每个图同一时间最多一个后台任务。
|
||
|
||
不要这样:
|
||
|
||
```text
|
||
version 100 提交
|
||
version 101 提交
|
||
version 102 提交
|
||
version 103 提交
|
||
```
|
||
|
||
这样会造成旧任务堵新任务。
|
||
|
||
应该这样:
|
||
|
||
```text
|
||
一个图最多一个任务在飞
|
||
任务运行期间 meta 变了,只记录版本变化
|
||
任务完成后调度器下一轮再提交最新版本
|
||
```
|
||
|
||
任务状态:
|
||
|
||
```cpp
|
||
enum class Render_Job_State {
|
||
Idle,
|
||
Queued,
|
||
Running
|
||
};
|
||
```
|
||
|
||
提交条件:
|
||
|
||
```cpp
|
||
bool Chart_Node::need_submit(std::uint64_t now_ms) const {
|
||
if (!visible) {
|
||
return false;
|
||
}
|
||
if (!valid_size) {
|
||
return false;
|
||
}
|
||
if (job_state != Render_Job_State::Idle) {
|
||
return false;
|
||
}
|
||
if (versions.meta == versions.submitted) {
|
||
return false;
|
||
}
|
||
if (now_ms - last_submit_time < min_render_interval) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
```
|
||
|
||
核心原则:
|
||
|
||
```text
|
||
版本没变,不算。
|
||
图不可见,不算。
|
||
尺寸无效,不算。
|
||
已经有任务在跑,不算。
|
||
刚算过,间隔太短,不算。
|
||
```
|
||
|
||
---
|
||
|
||
# 6. 像素结果层:三缓冲
|
||
|
||
像素层建议三缓冲:
|
||
|
||
```cpp
|
||
struct Pixel_Frame {
|
||
std::uint64_t version = 0;
|
||
QImage image;
|
||
};
|
||
struct Pixel_Buffers {
|
||
Pixel_Frame front;
|
||
Pixel_Frame ready;
|
||
Pixel_Frame working;
|
||
};
|
||
```
|
||
|
||
三张图语义:
|
||
|
||
```text
|
||
front
|
||
主线程 paintEvent 当前稳定显示的图。
|
||
|
||
ready
|
||
后台已经生成好,等待主线程消费的图。
|
||
|
||
working
|
||
后台线程正在写入的图。
|
||
```
|
||
|
||
三缓冲解决的是:
|
||
|
||
```text
|
||
主线程读 front
|
||
后台写 working
|
||
ready 作为交接区
|
||
```
|
||
|
||
它不是为了替代版本号,而是为了控制图像内存所有权。
|
||
|
||
---
|
||
|
||
# 7. 后台任务完成后的发布规则
|
||
|
||
后台线程生成完 `working` 后,不直接碰 `front`。
|
||
|
||
只发布到 `ready`。
|
||
|
||
如果 `ready` 还没被主线程消费,新 `working` 又生成完了:
|
||
|
||
```text
|
||
旧 ready 丢掉
|
||
新 working 覆盖 ready
|
||
front 继续稳定显示
|
||
```
|
||
|
||
图表库不需要按帧播放旧图,应该追最新状态。
|
||
|
||
发布逻辑:
|
||
|
||
```cpp
|
||
void Chart_Node::publish_working_frame(Pixel_Frame frame) {
|
||
bool need_update = false;
|
||
{
|
||
std::scoped_lock lock(pixel_lock);
|
||
if (frame.version <= pixels.ready.version) {
|
||
return;
|
||
}
|
||
pixels.working = std::move(frame);
|
||
pixels.ready.swap(pixels.working);
|
||
versions.rendered = pixels.ready.version;
|
||
if (versions.rendered != versions.requested_paint) {
|
||
versions.requested_paint = versions.rendered;
|
||
need_update = true;
|
||
}
|
||
job_state = Render_Job_State::Idle;
|
||
}
|
||
if (need_update) {
|
||
scheduler->request_update(this);
|
||
}
|
||
}
|
||
```
|
||
|
||
状态变化示例:
|
||
|
||
```text
|
||
发布前:
|
||
front = 100
|
||
ready = 101
|
||
working = 102
|
||
|
||
发布后:
|
||
front = 100
|
||
ready = 102
|
||
working = 101
|
||
```
|
||
|
||
旧 `ready = 101` 被丢掉,变成新的空闲 working 槽,后面会被覆盖。
|
||
|
||
---
|
||
|
||
# 8. 已经开始生成的 working 要不要停
|
||
|
||
不要停。
|
||
|
||
规则是:
|
||
|
||
```text
|
||
已经开始生成的 working:继续生成完。
|
||
还没开始的新任务:由调度器根据版本、间隔、状态决定是否提交。
|
||
```
|
||
|
||
原因:
|
||
|
||
```text
|
||
CPU 任务中途取消复杂。
|
||
生成完的新版本可以覆盖旧 ready。
|
||
single-flight 已经保证不会无限堆任务。
|
||
```
|
||
|
||
所以正确策略是:
|
||
|
||
```text
|
||
working 已经开始:继续。
|
||
ready 没消费:不等。
|
||
新 working 完成:覆盖旧 ready。
|
||
下一轮是否继续算:由调度器判断。
|
||
```
|
||
|
||
---
|
||
|
||
# 9. 主线程 paintEvent 消费规则
|
||
|
||
主线程 `paintEvent()` 前先尝试消费 `ready`:
|
||
|
||
```cpp
|
||
void Chart_Node::prepare_paint() {
|
||
std::scoped_lock lock(pixel_lock);
|
||
if (pixels.ready.version <= pixels.front.version) {
|
||
return;
|
||
}
|
||
pixels.front.swap(pixels.ready);
|
||
versions.painted = pixels.front.version;
|
||
}
|
||
```
|
||
|
||
然后只贴 `front`:
|
||
|
||
```cpp
|
||
void Chart_Node::paint(QPainter& painter) {
|
||
prepare_paint();
|
||
painter.drawImage(rect, pixels.front.image);
|
||
}
|
||
```
|
||
|
||
`paintEvent()` 不做:
|
||
|
||
```text
|
||
元数据计算
|
||
颜色映射
|
||
衰减计算
|
||
坐标采样
|
||
大数组遍历
|
||
后台等待
|
||
```
|
||
|
||
它只做:
|
||
|
||
```text
|
||
ready -> front
|
||
drawImage(front)
|
||
```
|
||
|
||
如果没有新图,也照样画旧 `front`。
|
||
|
||
因为 Qt 可能因为窗口遮挡、缩放、系统刷新等原因主动触发 `paintEvent()`,不能假设每次 paint 都有新数据。
|
||
|
||
---
|
||
|
||
# 10. update 合并
|
||
|
||
后台发布新图以后,不要每个图都直接 `update()` 整个窗口。
|
||
|
||
应该由调度器合并刷新区域:
|
||
|
||
```text
|
||
图 A 有新图
|
||
图 B 有新图
|
||
图 C 有新图
|
||
↓
|
||
合并 dirty rect
|
||
↓
|
||
主线程 update(total_dirty_rect)
|
||
```
|
||
|
||
可以加一个 pending 标志,避免重复投递刷新请求:
|
||
|
||
```cpp
|
||
void Render_Scheduler::request_update(Chart_Node* chart) {
|
||
dirty_region += chart->rect();
|
||
if (paint_request_pending.exchange(true)) {
|
||
return;
|
||
}
|
||
QMetaObject::invokeMethod(widget, [this] {
|
||
paint_request_pending.store(false);
|
||
QWidget* w = widget;
|
||
QRegion region = std::move(dirty_region);
|
||
dirty_region = QRegion();
|
||
w->update(region);
|
||
}, Qt::QueuedConnection);
|
||
}
|
||
```
|
||
|
||
这样多个图连续完成渲染,也只触发一次合并后的主线程刷新。
|
||
|
||
---
|
||
|
||
# 11. 调度器 tick 流程
|
||
|
||
调度器定时扫描所有图:
|
||
|
||
```cpp
|
||
void Render_Scheduler::tick() {
|
||
std::uint64_t now_ms = current_time_ms();
|
||
int submitted_count = 0;
|
||
for (Chart_Node* chart : charts) {
|
||
if (submitted_count >= max_submit_per_tick) {
|
||
break;
|
||
}
|
||
if (!chart->need_submit(now_ms)) {
|
||
continue;
|
||
}
|
||
chart->submit_render(thread_pool, now_ms);
|
||
++submitted_count;
|
||
}
|
||
}
|
||
```
|
||
|
||
提交任务:
|
||
|
||
```cpp
|
||
void Chart_Node::submit_render(Thread_Pool& pool, std::uint64_t now_ms) {
|
||
Render_Input input;
|
||
{
|
||
std::scoped_lock lock(meta_lock);
|
||
input = make_render_input();
|
||
versions.submitted = input.version;
|
||
versions.running = input.version;
|
||
job_state = Render_Job_State::Queued;
|
||
last_submit_time = now_ms;
|
||
}
|
||
pool.enqueue([this, input = std::move(input)]() mutable {
|
||
run_render_task(std::move(input));
|
||
});
|
||
}
|
||
```
|
||
|
||
CPU 任务:
|
||
|
||
```cpp
|
||
void Chart_Node::run_render_task(Render_Input input) {
|
||
{
|
||
std::scoped_lock lock(job_lock);
|
||
job_state = Render_Job_State::Running;
|
||
}
|
||
Pixel_Frame frame;
|
||
frame.version = input.version;
|
||
frame.image = render_image(input);
|
||
publish_working_frame(std::move(frame));
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
# 12. 过期任务结果怎么处理
|
||
|
||
假设:
|
||
|
||
```text
|
||
提交 version 100
|
||
CPU 正在算
|
||
用户已经改到 version 110
|
||
version 100 算完
|
||
```
|
||
|
||
推荐默认策略:
|
||
|
||
```text
|
||
允许发布 version 100
|
||
但任务完成后,如果 meta 还是新于 submitted,下一轮马上提交最新 version 110
|
||
```
|
||
|
||
这样用户不会长时间看不到任何结果。
|
||
|
||
但是不排队算:
|
||
|
||
```text
|
||
101、102、103、104...
|
||
```
|
||
|
||
只算当前正在跑的 100,然后下一轮追最新的 110。
|
||
|
||
也就是:
|
||
|
||
```text
|
||
旧结果可以短暂显示
|
||
旧任务不排队
|
||
新任务追最新
|
||
```
|
||
|
||
如果某些图对实时一致性要求特别高,也可以配置成:
|
||
|
||
```text
|
||
任务完成时发现 version < meta_version,直接丢弃结果
|
||
```
|
||
|
||
但默认不建议这么激进,否则用户拖动时可能一直看不到更新。
|
||
|
||
---
|
||
|
||
# 13. 最终状态机
|
||
|
||
每个图的任务状态:
|
||
|
||
```text
|
||
Idle
|
||
↓ submit
|
||
Queued
|
||
↓ thread starts
|
||
Running
|
||
↓ publish
|
||
Idle
|
||
```
|
||
|
||
版本流向:
|
||
|
||
```text
|
||
meta_version
|
||
↓ submit
|
||
submitted_version
|
||
↓ run
|
||
running_version
|
||
↓ publish
|
||
rendered_version
|
||
↓ request update
|
||
requested_paint_version
|
||
↓ paintEvent
|
||
painted_version
|
||
```
|
||
|
||
像素缓冲流向:
|
||
|
||
```text
|
||
CPU writes working
|
||
↓ publish
|
||
working <-> ready
|
||
↓ paintEvent
|
||
ready <-> front
|
||
↓ drawImage(front)
|
||
```
|
||
|
||
如果 ready 没消费,新 working 又来了:
|
||
|
||
```text
|
||
working <-> ready
|
||
旧 ready 丢掉
|
||
front 不动
|
||
```
|
||
|
||
如果 meta 没变:
|
||
|
||
```text
|
||
不提交任务
|
||
不生成图
|
||
不 request update
|
||
```
|
||
|
||
如果没有新 ready:
|
||
|
||
```text
|
||
paintEvent 只画旧 front
|
||
```
|
||
|
||
---
|
||
|
||
# 14. 最终设计原则
|
||
|
||
整个模型可以定成这几条:
|
||
|
||
```text
|
||
1. 每个图有自己的元数据版本。
|
||
2. 每个图同一时间最多一个 CPU 渲染任务。
|
||
3. 调度器统一扫描所有图,不让每个图自建线程和 timer。
|
||
4. 元数据层用快照或者双缓冲。
|
||
5. 像素结果层用三缓冲。
|
||
6. 三缓冲只管图像内存所有权,不替代版本号。
|
||
7. ready 只保存最新待显示图,不做帧队列。
|
||
8. 旧 ready 没被消费时,新 working 直接覆盖它。
|
||
9. 已经开始的 working 继续算完。
|
||
10. 是否继续提交新任务由版本号、single-flight、限速、可见性共同决定。
|
||
11. 主线程 paintEvent 只贴 front,不做重计算。
|
||
12. update 由调度器合并,不让每个图疯狂 update。
|
||
```
|
||
|
||
一句话总结:
|
||
|
||
```text
|
||
元数据版本号决定要不要算;
|
||
single-flight 决定不要堆任务;
|
||
CPU 线程池负责把 Meta 算成 Pixel;
|
||
三缓冲负责 Pixel 的安全交接;
|
||
调度器负责多图公平和 update 合并;
|
||
paintEvent 只负责贴最新 front。
|
||
```
|
||
|
||
这个模型适合你的场景: **一堆图、多线程后台渲染、高频数据变化、主线程只做轻量显示**。
|