Qt 跨线程传递多容器嵌套结构:一次 Release 实测

数值处理程序经常需要在线程间传递一个复合数据对象:里面既有数值和状态,也有多个数组、嵌套数组,以及自定义小对象列表。这样的数据,使用结构体、QVariant 还是 JSON 传递,成本有什么不同?

本文使用独立设计的合成模型比较这些路径。所有名称、字段组合、尺寸和数据均为测试设计,不依赖业务项目、外部业务头文件或真实业务数据;它用于观察这类类型组合的性能,不对应任何真实类型的精确内存布局。

先给结论

对于这次测试的多容器嵌套数据,同进程跨线程传递、接收端要用结构体时,优先直接传结构体;接口要求 QVariant 时,优先考虑包装原结构体。为了传线程而逐字段转成 Map 或 JSON,再还原回结构体,本次实现付出的成本明显更高。

最大规模下,直接结构体传递并读取约 1.530 ms/消息 ;每次生成 QVariant 后取回结构体约 1.826 ms/消息 ;Map、QJsonObject 和 JSON 字节串的生成及还原分别约 23.238、57.420、766.472 ms/消息。

如果数据本来已经是只读 Map 或 JSON,结论会不同:可以直接分发已有表示,参考后面的预构造表。不要把"传递已有对象很便宜"理解成"从结构体转换成它也很便宜"。

环境和测试口径

  • 测试日期:2026-10-20。
  • Qt 5.12.9,x64,指定 msvc2017_64 版 Qt;应用编译器为 MSVC 2019 v142,14.29.30133。
  • qmake + nmake,Release,/O2 /MD,QT_NO_DEBUG、NDEBUG。
  • Intel Core i7-13700H,Windows 11 Pro Insider Preview 10.0.26340。
  • 主线程发送,独立 QThread 接收,显式 Qt::QueuedConnection;信号参数使用 const 引用。
  • 每批发送 16 条,通过 QSemaphore 等待该批接收完成;测量为批量总时间除以消息数。
  • 每种规模完整预热一轮,再测 5 轮;24 个场景随机排列,正文取中位数。

每条耗时包含队列调度、接收处理和批次同步。它是吞吐均摊耗时,不能视作单条消息的独立端到端延迟,也不是纯内存复制耗时。

合成模型

cpp 复制代码
struct Node {
    double u;
    double v;
    double w;
    int tag;
    bool enabled;
};
struct Trend {
    float a;
    float b;
    float c;
};
struct Route {
    double p0;
    double p1;
    double p2;
    double p3;
    double p4;
    double p5;
};
struct Orbit {
    bool enabled;
    double u;
    double v;
    double auxValue0;
};
struct Payload {
    int serial;
    int status;
    int channel;
    bool active;
    float tolerance;
    double span;
    double weight;
    double rate;
    std::array<double, 18> coefficients;
    Node anchor;
    Node extentA;
    Node extentB;
    std::array<Node, 5> markers;
    std::vector<int> labels;
    std::vector<int> positions;
    std::vector<unsigned char> mask;
    std::vector<double> samples;
    std::vector<float> levels;
    std::vector<bool> flags;
    std::vector<std::vector<double>> gridA;
    std::vector<std::vector<double>> gridB;
    std::vector<std::vector<double>> gridC;
    std::vector<std::vector<double>> gridD;
    std::vector<Node> observations;
    std::vector<Node> annotations;
    std::vector<Trend> trends;
    std::vector<Route> routes;
    std::vector<Route> alternatives;
    std::vector<Orbit> orbits;
};

这里直接传递 Payload,没有再套一个记录列表外壳。Payload 自身实测为 840 字节,不包含任何 vector 的动态内存。自定义 Node 为 32 字节;这些 sizeof 数字都不能代表完整消息的总内存。

这份模型包含 29 个顶层成员,数值定长数组、内嵌小对象、16 个 vector 成员。四组 vector<vector<double>> 的每行长度分别为 3、4、3、2;其他列表随着规模参数 n 增长。

数据成员 合成内容
labels / positions / mask / levels / flags 各 n 项,分别覆盖整数、字节、float、位压缩 bool
samples 2n 个 double
gridA / gridB / gridC / gridD 各 n 行,分别包含 3、4、3、2 个 double
observations / annotations 各 n/4 个 Node
trends / routes / alternatives / orbits 各 n/16 个自定义小对象
coefficients 18 个 double,包含 NaN、正无穷、负无穷

本模型没有 QString 或其他隐式共享成员。标准容器的复制需要复制它们保存的元素;嵌套 vector 的复制还涉及许多独立的小块内存分配。vector<bool> 按其特有的位压缩表示处理。

表示方式和浮点语义

路径 每次生成表示时的操作 接收端还原
结构体 直接发送 Payload 已经可用;还原列额外复制出本地 Payload
QVariant(结构体) QVariant::fromValue(payload) value<Payload>()
QVariantMap 按字段和数组元素生成 Map / List 逐字段重建 Payload
QJsonObject 先生成上述 Map,再 QJsonObject::fromVariantMap() toVariantMap() 后重建 Payload
QVariant(QJsonObject) 对上述 JSON 对象再包 QVariant 取出 JSON 对象并重建 Payload
JSON 字节串 上述 JSON 对象经 QJsonDocument::Compact 编码 解析文本,再重建 Payload

这里 JSON 路径包含 QVariantMap 中间步骤。 这是本实现选择的统一字段映射方式,便于校验所有嵌套成员;它也增加了转换工作。因此,表格比较的是本文的完整转换实现,不能解读为 JSON 格式本身的最低成本,也不能拿它与其他编码实现做无条件比较。

字节数组编码为数值数组,不采用 Base64;嵌套 vector 编码为嵌套列表。Map 与 JSON 的统一映射均将 NaN、正无穷和负无穷分别编码为字符串 NaN、+Inf、-Inf,接收端显式恢复。这里没有把非有限值默默当作 null。

这种标记是本模型的协议约定,不是标准 JSON 数字。测试只验证所用数值和非有限状态,不保证 NaN 的二进制载荷或负零符号保持不变。

数据规模

n 紧凑 JSON 字节数 每场景每轮消息数
64 8,622 256
512 69,402 64
4096 597,928 16

"端到端"在每次发送时从 Payload 重新生成表示;"预构造"在计时前生成一次,再反复发送同一个只读表示。两者均反复发送相同逻辑数据。

"不还原"只读取表示形式的轻量属性;"还原"构造完整 Payload 并正常销毁。前者不包含读取所有业务字段或真实业务处理。直接结构体路径的"还原"列是额外复制本地对象,不是必须执行的反序列化步骤。

每次发送均转换

以下单位均为 ms/消息,越小越好。

n = 64

方式 接收后不还原 接收后还原 / 本地副本
结构体 0.057 0.067
QVariant(结构体) 0.061 0.062
QVariantMap 0.347 0.447
QJsonObject 0.879 0.898
QVariant(QJsonObject) 0.773 0.967
JSON 字节串 1.920 1.946

n = 512

方式 接收后不还原 接收后还原 / 本地副本
结构体 0.224 0.287
QVariant(结构体) 0.217 0.315
QVariantMap 2.285 2.392
QJsonObject 6.294 6.584
QVariant(QJsonObject) 6.268 6.588
JSON 字节串 14.967 15.887

n = 4096

方式 接收后不还原 接收后还原 / 本地副本
结构体 1.530 2.213
QVariant(结构体) 1.553 1.826
QVariantMap 21.318 23.238
QJsonObject 57.313 57.420
QVariant(QJsonObject) 56.919 58.943
JSON 字节串 771.036 766.472

提前构造好,再传递

以下单位为 μs/消息。这张表不包含首次创建表示的成本;较大的还原列仍然包含完整的数据重建。

n = 64

方式 接收后不还原 接收后还原 / 本地副本
结构体 72.485 67.354
QVariant(结构体) 15.882 27.866
QVariantMap 13.860 76.777
QJsonObject 10.527 524.038
QVariant(QJsonObject) 13.452 474.463
JSON 字节串 21.054 1167.056

n = 512

方式 接收后不还原 接收后还原 / 本地副本
结构体 236.914 271.755
QVariant(结构体) 19.712 128.830
QVariantMap 17.920 1119.306
QJsonObject 18.748 4159.611
QVariant(QJsonObject) 19.223 5218.177
JSON 字节串 20.047 8284.942

n = 4096

方式 接收后不还原 接收后还原 / 本地副本
结构体 1467.513 2621.331
QVariant(结构体) 31.119 1197.000
QVariantMap 25.425 13658.444
QJsonObject 62.938 44712.662
QVariant(QJsonObject) 61.119 41244.406
JSON 字节串 222.469 78165.025

结论:这类数据该怎么传

1. 接收端需要结构体,直接传结构体是本次测试支持的默认选择

最大规模下,直接传 Payload 并读取为 1.530 ms/消息 。它已经把可用的结构体送到了接收槽,无需再做反序列化。表格中的 2.213 ms/消息 对应额外复制本地 Payload,这一步并非每个业务都需要。

这不表示结构体复制没有成本。多个 std::vector 会复制各自的数据,嵌套 vector 还涉及许多小块分配。但在这份实现里,增加字段映射、JSON 编码和还原步骤,并没有换来端到端性能收益。

2. QVariant 可以保留,逐字段转 Map 要有实际需要

每次 QVariant::fromValue(payload) 后,在接收端 value<Payload>(),中位耗时为 1.826 ms/消息 ;逐字段转 QVariantMap 再还原则为 23.238 ms/消息。

因此,接口需要 QVariant,不等于必须把结构体拆成 QVariantMap。如果接口只负责转发数据,包装自定义类型就能保留原类型;需要按字段名访问或修改数据时,再承担字段映射的成本。

这里也不能根据 QVariant(结构体) 的中位数低于"结构体额外本地副本"一列,就断言 QVariant 总是更快。两条路径的测量范围存在重叠,且直接结构体本来无需额外复制。稳定的结论是:包装原类型和逐字段转换,是两种不同量级的工作。

3. JSON 应用于确实需要 JSON 的边界

本实现中,QJsonObject 的生成及还原约 57.420 ms/消息 ,再包 QVariant 后约 58.943 ms/消息 ,编码成 JSON 字节串并还原约 766.472 ms/消息。

所以,给 JSON 再套 QVariant,没有消除原本的转换工作。仅为同进程跨线程传递而执行"结构体 → JSON → 结构体",本次测试不支持这种性能优化思路。

这些数字包含本文实现的 Map 中间表示和完整编解码流程,不能当成 JSON 格式的理论下限,也不能据此认定所有 JSON 实现都同样慢。业务确实需要 JSON 输出时,应把转换放在需要该表示的边界,再测具体实现。

4. 已有只读表示,直接分发,不必来回转换

最大规模预构造、不还原时,QVariant(结构体) 约 31.119 μs/消息 ,QJsonObject 约 62.938 μs/消息 ;直接传含 std::vector 的 Payload 约 1467.513 μs/消息。这些结果体现了共享已有表示与复制容器元素的区别。

但预构造 QJsonObject 一旦要还原成 Payload,耗时就达到 44.713 ms/消息。因此,只有下游能直接使用已有表示时,预构造表中的低成本才有对应的业务意义。

按使用场景选择

使用场景 本次测试支持的选择
同进程、双方类型明确、接收端使用 Payload 直接传 Payload,避免不需要的本地副本
通用接口要求 QVariant,但不需要拆字段 QVariant 包装原 Payload
下游确实需要按字段名访问数据 按接口需要使用 QVariantMap,并计入映射成本
数据已经是只读 JSON,下游也消费 JSON 直接分发已有 JSON 表示
存储或接口明确要求 JSON 文本 在对应边界编码,单独评估编解码开销

本次测试能支持的是这些场景选择。它没有比较其他共享所有权方案,也不能证明直接按值传递是所有设计中的最快方案。

波动、检查和边界

最大规模端到端场景的 5 次测量如下,单位为 ms/消息。全部慢轮均保留,没有剔除。

方式 还原 最小值 中位数 最大值
结构体 0 1.401 1.530 1.886
结构体 1 1.254 2.213 3.477
QVariant(结构体) 0 1.315 1.553 2.679
QVariant(结构体) 1 1.704 1.826 3.379
QVariantMap 0 20.356 21.318 27.197
QVariantMap 1 21.541 23.238 25.544
QJsonObject 0 53.074 57.313 73.833
QJsonObject 1 55.601 57.420 75.932
QVariant(QJsonObject) 0 54.237 56.919 63.255
QVariant(QJsonObject) 1 58.037 58.943 67.162
JSON 字节串 0 747.975 771.036 835.248
JSON 字节串 1 743.361 766.472 889.151

检查覆盖空容器的 Map/JSON 对象往返,以及三个非空测试规模中各路径的全字段往返,同时检查 NaN 和正负无穷状态。全字段比较将对象重新映射为包含全部成员的规范 Map;这是语义校验,不是内存字节比较。

程序还检查接收槽确实运行在工作线程,逐场景累计接收校验和,并在超时时终止。校验使用 qFatal,在 Release 下仍然有效。汇总时核对了 360 条测量、72 个分组、每组 5 次以及程序成功完成的日志。

本轮未固定 CPU 核心、线程优先级、功耗或系统负载;最大组每轮只有一个 16 条批次。不同场景独立计时,还原列偶尔低于不还原列也可能是波动,不能解释为还原减少工作。接近的结果不足以给出稳定排名。

未测真实业务类型、真实数据尺寸分布、多个发送线程、接收端修改、无界队列积压或真实业务处理。替代模型只能说明这些类型组合的成本特征,不能预测某个外部类型的精确耗时。不同数据模型和转换实现之间,不宜据此计算严格的复杂度增长倍数。

关键代码

下面摘录类型注册、跨线程连接、转换和计时部分。为突出测量路径,省略逐字段的 pack/unpack、测试数据生成、其他同类信号连接和外层重复测量;这些片段用于解释实现,不是可单独编译的完整程序。

注册类型并显式使用队列连接

cpp 复制代码
Q_DECLARE_METATYPE(Payload)

class Sender : public QObject {
    Q_OBJECT
signals:
    void structure(const Payload &p);
    void bytes(const QByteArray &p);
    void json(const QJsonObject &p);
    void variant(const QVariant &p);
};

// 在创建 QCoreApplication 后执行。
qRegisterMetaType<Payload>();
qRegisterMetaType<QJsonObject>();
Sender sender;
QThread thread;
auto receiver = new Receiver;
receiver->moveToThread(&thread);
QObject::connect(&thread, &QThread::finished, receiver, &QObject::deleteLater);
QObject::connect(&sender, &Sender::structure,
                 receiver, &Receiver::structure, Qt::QueuedConnection);
// bytes、json、variant 同样连接到对应接收函数,均使用 QueuedConnection。
thread.start();
// 全部测量结束后执行 thread.quit(); thread.wait();

const 引用是信号和槽的接口形式,不表示跨线程借用发送端对象。队列仍需保存参数;Payload 内部的标准容器复制与共享已有 Qt 表示的成本不同。

JSON 转换明确经过 Map

cpp 复制代码
static QVariantMap toMap(const Payload &p) { return pack(p).toMap(); }
static Payload fromMap(const QVariantMap &v) {
    Payload p;
    unpack(v, p);
    return p;
}
static QJsonObject toJson(const Payload &p) {
    return QJsonObject::fromVariantMap(toMap(p));
}
static Payload fromJson(const QJsonObject &v) {
    return fromMap(v.toVariantMap());
}

这里的 pack/unpack 逐字段处理全部成员,数组递归转换成列表,非有限浮点数按前述字符串约定往返。它们的工作包含在端到端计时内,不能用空函数替代来复现本文数字。

六条发送路径放在同一个计时循环中

cpp 复制代码
// 预构造表示在计时之前生成;端到端模式在每次 emit 时重新生成。
const auto j = toJson(p);
const auto b = QJsonDocument(j).toJson(QJsonDocument::Compact);
const QVariant vs = QVariant::fromValue(p);
const QVariant vm = toMap(p);
const QVariant vj = QVariant::fromValue(j);

QElapsedTimer timer;
timer.start();
for (int offset = 0; offset < messages; offset += 16) {
    receiver->remaining = 16;
    for (int n = 0; n < 16; ++n) {
        switch (kind) {
        case 0: emit sender.structure(p); break;
        case 1:
            emit sender.bytes(endToEnd
                ? QJsonDocument(toJson(p)).toJson(QJsonDocument::Compact) : b);
            break;
        case 2: emit sender.json(endToEnd ? toJson(p) : j); break;
        case 3: emit sender.variant(endToEnd ? QVariant::fromValue(p) : vs); break;
        case 4: emit sender.variant(endToEnd ? QVariant(toMap(p)) : vm); break;
        case 5:
            emit sender.variant(endToEnd ? QVariant::fromValue(toJson(p)) : vj);
            break;
        }
    }
    if (!receiver->done.tryAcquire(1, 120000))
        qFatal("Receiver timeout");
}
const double nsPerMessage = double(timer.nsecsElapsed()) / messages;

messages 在本次测试中分别为 256、64、16,均为 16 的整数倍。接收端在每条消息处理完后递减 remaining,批次结束才释放信号量,因此计时包含等待接收完成的时间,而不只是发送入队。

接收端的"还原"究竟做什么

下面是 Receiver 的两个接收函数和批次完成逻辑。done 为 QSemaphore,remaining 为批次剩余消息数,restore 控制是否还原;consume 读取结构体中的少量属性并累计校验和。

cpp 复制代码
void finish() {
    if (--remaining == 0)
        done.release();
}
void structure(const Payload &p) {
    if (restore) {
        Payload copy = p;
        consume(copy);
    } else {
        consume(p);
    }
    finish();
}
void bytes(const QByteArray &p) {
    if (restore)
        consume(fromJson(QJsonDocument::fromJson(p).object()));
    else
        checksum += p.size();
    finish();
}

其他接收路径在还原时分别调用 value<Payload>()、fromMap(p.toMap())、fromJson(p) 或 fromJson(p.value<QJsonObject>());不还原时只读取表示的轻量属性。每条路径最终都调用 finish,外层在每个场景后检查累计校验和。

这些代码也说明了两列的区别:结构体到达槽后已经可用,额外本地复制是可选步骤;文本和逐字段表示则需要重建,才能得到同样可用的 Payload。

相关推荐
qz_Serene1 小时前
C++:string(底层,常用接口,具体题目,模拟实现)
开发语言·c++
念何架构之路1 小时前
go-grpc逻辑架构图
开发语言·后端·golang
SEO_juper2 小时前
AI 爬虫 robots.txt 检查工具:你的站是不是把 AI 挡在门外了
开发语言·人工智能·爬虫·python·外贸独立站
扶尔魔ocy2 小时前
【QT android】模仿行车记录仪案例
android·qt
木圭的AI时代指南2 小时前
实测 MiniMax M3.1 Flash:同一道 Three.js 大题,两个 agent 差出 4.6 倍
开发语言·javascript·ecmascript
星恒讯工业路由器2 小时前
工业路由器在中小 IoT 场景的选型与适配实践:从标准品到定制化的技术路径
开发语言·网络·物联网·php·工业路由器选型·工业网关odm·链路冗余切换
OKkankan2 小时前
python 进阶(三):面向对象进阶——从“会写 class”到设计可维护的后端对象
开发语言·数据结构·python
魔都吴所谓2 小时前
【Python】 多线程 & 多进程&协程 常见题 + 应用场景整理
开发语言·python
king13598745502 小时前
LiteRT Tensor API:C++ 编写端侧模型图,XNNPACK、GPU 与 NPU 怎么选?
开发语言·c++·人工智能·chatgpt·架构·aigc