Files
Radio/doc/ai.md
T
2026-07-22 18:08:10 +08:00

12 KiB
Raw Blame History

下面这个就是我建议你这个 多图表后台渲染库的完整模型。

核心目标是:

元数据变化才计算
后台只算最新状态
主线程只贴图
多个图统一调度
不堆旧任务
不显示半成品
不因为 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 已经贴出去的版本。

这里不要用 OKimageReady 这种 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。

这个模型适合你的场景: 一堆图、多线程后台渲染、高频数据变化、主线程只做轻量显示