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