数值处理程序经常需要在线程间传递一个复合数据对象:里面既有数值和状态,也有多个数组、嵌套数组,以及自定义小对象列表。这样的数据,使用结构体、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。