文章目录
- 一次「指令静默丢失」的排查
-
- 一、现象
- [二、先补一个基础:Qt 的事件循环](#二、先补一个基础:Qt 的事件循环)
-
- [2.1 传统写法:阻塞 I/O + 多线程](#2.1 传统写法:阻塞 I/O + 多线程)
- [2.2 另一种模型:事件驱动 + 非阻塞](#2.2 另一种模型:事件驱动 + 非阻塞)
- [2.3 Qt 把它包好了:`QSocketNotifier`](#2.3 Qt 把它包好了:
QSocketNotifier) - [2.4 其实你早就在用了](#2.4 其实你早就在用了)
- [2.5 判断用不用线程,看的是「会不会卡住」](#2.5 判断用不用线程,看的是「会不会卡住」)
- [2.6 一条铁律:收到通知要「排空」](#2.6 一条铁律:收到通知要「排空」)
- [三、坑在哪:socket 会「学习对端」](#三、坑在哪:socket 会「学习对端」)
-
- [3.1 UDP 有个特点:无连接](#3.1 UDP 有个特点:无连接)
- [3.2 于是很多库提供了「学习对端」](#3.2 于是很多库提供了「学习对端」)
- [3.3 于是就有了这个故障](#3.3 于是就有了这个故障)
- 四、工程方法:怎么处理这类问题
-
- [4.1 不要猜,做最小验证](#4.1 不要猜,做最小验证)
- [4.2 列全方案,比代价,而不是比"先进"](#4.2 列全方案,比代价,而不是比"先进")
- [4.3 D 方案怎么做](#4.3 D 方案怎么做)
- [4.4 但先测性能------这个方案要高频执行](#4.4 但先测性能——这个方案要高频执行)
- [4.5 为什么加在一处就够了](#4.5 为什么加在一处就够了)
- 五、最漂亮的一步:验证"行为没变"
- 六、可迁移的经验
-
- [6.1 静默失败是最贵的失败](#6.1 静默失败是最贵的失败)
- [6.2 先分清「行为契约」和「实现机制」](#6.2 先分清「行为契约」和「实现机制」)
- [6.3 有条件的结论比笼统的结论有用](#6.3 有条件的结论比笼统的结论有用)
- [6.4 优先在消费方消化上游行为](#6.4 优先在消费方消化上游行为)
- [6.5 线程不是默认答案](#6.5 线程不是默认答案)
- 七、小结
一次「指令静默丢失」的排查
一个基于 Qt 的工业上位机项目里,指令偶尔发不出去、且没有任何报错 。这篇文章从
这个现象出发,讲清三件事:Qt 的事件循环怎么替代线程 、socket 一个容易忽略的语义 、
以及面对这种问题时的工程决策方法。
文中不涉及任何具体公司或产品,代码为示意。
一、现象
系统结构大概是这样:
上位机(Qt GUI) ──UDP──▶ 对端(另一个进程/单板)
上位机按固定频率发控制指令,同时接收对端上报的数据。大部分时候正常,但偶尔会出现:
- 上位机界面显示「已连接」,一切正常
- 日志没有任何错误
- 对端收不到任何指令
重启一下又好,跑一阵子又坏。
这类问题最难查,因为它不报错。而没有报错的故障,你连"从哪开始查"都没有方向。
二、先补一个基础:Qt 的事件循环
排查这类问题,绕不开对底层 I/O 模型的理解。而这里的第一个知识点,很多人(包括我自己)
都容易忽略。
2.1 传统写法:阻塞 I/O + 多线程
在没有事件循环的环境里(比如纯 C 写 socket),读数据是这样的:
c
// 阻塞式读:没有数据就一直卡在这
recv(fd, buf, len, 0);
它会卡住。 所以要么开线程扛住这个阻塞,要么轮询------轮询又费 CPU。
于是就有了那个经典模型:一个连接一个线程。
这个模型很直观,但代价不小:
| 代价 | 说明 |
|---|---|
| 线程开销 | 创建、切换、栈内存 |
| 加锁 | 共享数据要保护,锁本身降低并发 |
| 生命周期管理 | 对象在哪个线程创建/销毁,跨线程信号怎么投递 |
| 调试 | 竞态、时序,断点不好下 |
2.2 另一种模型:事件驱动 + 非阻塞
操作系统其实早就提供了另一条路:I/O 多路复用 (Linux 的 epoll、老的 select/poll,
Windows 的 WSAEventSelect/IOCP)。
思路是:
把所有 fd 交给内核
↓
内核帮你盯着,谁也不占你的 CPU
↓
哪个 fd 有数据了,内核叫醒你
"等"这件事交给内核做,应用层就不用开线程去扛了。
2.3 Qt 把它包好了:QSocketNotifier
Qt 本身有一个事件循环(为了让 UI 工作),而它把多路复用也接了进去:
cpp
QSocketNotifier notifier(fd, QSocketNotifier::Read, parent);
connect(¬ifier, &QSocketNotifier::activated,
this, &MyClass::onDataReady);
它做的事 :把这个 fd 注册进 Qt 的事件分发器(底层就是 epoll/select),
fd 可读时发出 activated() 信号。
核心价值 :把"某个 fd 有数据了"这件事,翻译成 Qt 的一个信号。
2.4 其实你早就在用了
如果用过 QSerialPort、QTcpSocket、QUdpSocket,你用的就是这个模型:
cpp
connect(&serial, &QSerialPort::readyRead, this, &MyClass::onData);
readyRead() 就是 Qt 帮你封装好的 QSocketNotifier。
手动 new QSocketNotifier 的场景只有一种:fd 不是你自己的 ------
比如来自某个第三方库的裸 socket,Qt 不认识它,只能手动桥接。
2.5 判断用不用线程,看的是「会不会卡住」
这是我在这次排查里想清楚的一条:
这个操作会【阻塞】吗?(等网络、等外部进程、读大文件)
│
┌────┴────┐
是 否
│ │
需要线程 会【吃满 CPU】吗?
│
┌────┴────┐
是 否
│ │
需要线程 【不用线程】
注意:不是"数据量大不大"。 收 1KB 但要等 200ms 才回来(阻塞)→ 要线程;
收 100MB 但能非阻塞分片读 → 不用线程。
线程的作用是"别卡住主线程",不是"跑得更快"。 能用事件驱动解决就别开线程。
2.6 一条铁律:收到通知要「排空」
事件驱动下最常见的 bug:
cpp
void onDataReady() {
auto d = socket->read(); // ❌ 只读一次
handle(d);
}
一个通知只代表"现在可读了",缓冲区里可能积压了好几个包。
只读一次的话,剩下的包还在缓冲区里,而边沿触发下不会再发通知 ------那些包会被
推迟到下次有新数据时才处理,表现为"偶发丢包"。
正确写法是读到没有为止:
cpp
void onDataReady() {
while (true) {
auto d = socket->tryRead(); // 非阻塞
if (!d) break; // 读干净了
handle(*d);
}
}
三、坑在哪:socket 会「学习对端」
基础补完,回到问题本身。
3.1 UDP 有个特点:无连接
UDP 不像 TCP 那样维护连接状态。你每次 send 都必须说明「发给谁」。
收发数据时,内核会告诉你发送方的地址:
c
recvfrom(fd, buf, len, 0, &from_addr, &from_len);
// ↑ 内核填好"是谁发来的"
但这个地址只在这一刻有效 。下次 send 时内核不知道你要发给谁。
3.2 于是很多库提供了「学习对端」
为了省事,很多 UDP 封装库会这么设计:
从第一个收到的数据报里,记住对端地址。之后
send默认发给它。
这个设计在「客户端连服务器、服务器回客户端」的场景下非常好用------
服务端根本不需要被配置任何东西,收到谁的就回给谁。
但它有个副作用:它会覆盖你手动设置的目标地址。
3.3 于是就有了这个故障
cpp
// 初始化时设好目标
channel.setPeer(serverIp, serverPort); // 发送目标 = server:8081
// ... 运行中收到一条数据 ...
// 假设这条数据是从「另一个端口」来的(比如对端用了独立的发送 socket)
// 库内部:哦,对端是这个地址 → 悄悄改掉发送目标
channel.send(cmd); // ❌ 发到那个临时端口去了
而对端并没有在监听那个临时端口 → 数据被系统丢弃 → 对端收不到。
关键在于:这个过程完全静默。
send()返回成功(UDP 发出去就算成功)- 没有任何异常或日志
- 界面显示"连接正常"
四、工程方法:怎么处理这类问题
这部分是我觉得比技术细节更值得记的。
4.1 不要猜,做最小验证
面对「它到底会不会覆盖」这种问题,别去读文档猜、也别凭经验断言。
写个几十行的独立小程序,把场景搭出来:
A = 上位机(单 socket)
B = 对端(收指令的口)
C = 冒充"对端从另一个端口回包"
步骤:
1. A 发指令 → B 收到 ← 验证基本收发
2. C 发一条数据给 A ← 触发"学习"
3. A 再发指令 → 看谁收到 ← 关键
实测输出:
== Case 1: 对端从【自己监听的端口】回包 ==
A 再发一条 : B收到=1 C收到=0 → 目标未变 ✅
== Case 2: 对端从【另一个端口】回包 ==
A 再发一条 : B收到=0 C收到=1
→ 目标被覆盖,B 再也收不到指令 ❌
这下结论就从"猜"变成了"分情况"------而不是含糊的"可能有问题"。
这个 POC 还纠正了一个误判 :原始代码注释里把这个问题写成"必须分两条通道",
把条件说绝对了。实测表明只在特定条件下出问题。
4.2 列全方案,比代价,而不是比"先进"
知道了问题,别急着改。先把可能的方案摆出来:
| 方案 | 改哪 | 代价 |
|---|---|---|
| A. 要求对端改用单 socket | 对端侧纪律 | 依赖对方,以后被改坏就崩 |
| B. 改上游库(去掉学习行为) | 公共库 | ⚠️ 协议级改动,影响所有使用方 |
| C. 保持原方案(双通道规避) | 无 | 与需求不符 |
| D. 消费方自行纠正 | 只改自己 | 几乎为零 |
我选了 D。 理由:
- A 不可控 ------ 依赖别人守纪律,是长期隐患
- B 影响面太大 ------ 那是个公共库,"学习对端"在嵌入式侧是刚需 (单板不知道上位机在哪,
只能从收到的包学)。为了一个使用方的便利去改公共行为,不划算 - D 成本最低,且不依赖任何人
这条思路可以推广 :遇到上游行为的坑,先想"我能不能在自己这一侧消化",
而不是第一时间去改上游。
4.3 D 方案怎么做
思路 :既然它会被改错,那我每次用之前重新确认一遍。
cpp
void sendSealed(frame) {
// 单通道防线:每次发送前重设目标,把被"学习"覆盖的地址纠正回来
channel.setPeer(serverIp, serverPort);
channel.send(frame);
}
"你改错了没关系,我发之前再写一遍。"
4.4 但先测性能------这个方案要高频执行
指令以 30 Hz 发送,如果每次重设要花几百微秒,就不能这么干。
再跑一个小 POC 测开销:
setPeer(IP 字面量):
单次 0.02 微秒
30 Hz 下每帧预算 33333 微秒
占用比例 0.0001%
结论:可以忽略。 方案可行。
这三个 POC 加起来不到 200 行代码,但它们把"我觉得可以"变成了"实测可行",
并且给出了性能数字。 这是工程判断该有的样子。
4.5 为什么加在一处就够了
改动只加了一行,因为所有发送路径都汇聚在一个函数:
sendManualControl() ─┐
sendCommand() ─┼─▶ sendPkt() ─▶ sendSealed() ─▶ send()
sendHeartbeat() ─┤ ↑
CommandAck 重发 ─┘ 【唯一出口】
在漏斗的出口加一行,覆盖全部发送。 不用去每个 sendXxx 里各加一遍。
设计启示 :把"所有出向流量"收拢到一个出口,本身就有价值------
加日志、加统计、加保护,都只需要改一处。
五、最漂亮的一步:验证"行为没变"
改完之后,怎么知道没搞坏?
项目里正好有一条针对这个场景的回归测试:
断言:数据从【其他端口】来时,指令仍必须到达【配置的目标】
它同时约束新旧两种实现:
| 实现 | 怎么满足它 |
|---|---|
| 旧的(双通道) | 发送走独立 socket,不会被覆盖 → 自然满足 |
| 新的(单通道 + 重设) | 发送前重设 → 也满足 |
测试通过 = 行为契约没变。
这是这次改动里我觉得最有价值的一点:
原来那条测试根本不用改。
因为我们换的是实现机制 (从"分通道避免覆盖"到"发送前纠正覆盖"),
而对外行为完全一致------指令始终到达配置的目标。
这也说明好的回归测试应该断言「行为」,而不是「实现」。
如果那条测试写的是"必须有两个 socket",这次改动就得先改测试,
而改测试会让"是否退化"变得说不清。
六、可迁移的经验
6.1 静默失败是最贵的失败
这个问题从头到尾没有任何报错。UDP 发出去就算成功,没人会告诉你对方没收到。
凡是**"发出去就当成功"的接口(UDP、消息队列投递、日志异步刷盘......),
都要默认它们会静默失败**,并为此设计检测手段(回执、序号、心跳、超时重传)。
6.2 先分清「行为契约」和「实现机制」
- 行为契约:指令必须到达配置的目标 ------ 这是不能变的
- 实现机制:用两个 socket 还是一个 ------ 这可以换
换机制时,只要契约不变,回归测试就是你的安全网。
6.3 有条件的结论比笼统的结论有用
原始注释写的是"必须分两条通道"。实测下来是:
- 对端从同一端口回 → 单通道没问题
- 对端从别的端口回 → 单通道会坏
"分情况"的结论,才能支撑决策。 笼统的"有风险",只能让人保守选择。
6.4 优先在消费方消化上游行为
改上游影响面大、协调成本高、还可能影响别的使用方。
如果消费方能用很小的代价消化掉,通常是更划算的选择。
代价是什么?这次是一行调用 + 0.02 微秒。
6.5 线程不是默认答案
回到开头:如果用阻塞 I/O + 线程,这个项目里的通信层会多出线程、锁、
跨线程信号------而实际上一个事件循环就够了。
"要不要开线程"的判断依据是"会不会阻塞 / 会不会吃满 CPU" ,
不是"这是网络操作"。
七、小结
| 环节 | 关键点 |
|---|---|
| 理解 I/O 模型 | Qt 事件循环 + QSocketNotifier;能用事件驱动就别开线程 |
| 定位问题 | 写最小 POC 复现,把"猜"变成"分情况" |
| 决策 | 列全方案比代价;选"只改自己、不依赖别人"的那条 |
| 验证可行性 | 连性能一起测,不能只说"可以" |
| 落地 | 找唯一的汇聚点,一处改动覆盖全部 |
| 回归验证 | 靠断言"行为"的回归测试,证明契约没变 |
整理下来,我自己的收获其实不在那个 socket 的坑,而在于:
面对一个"没有报错"的问题,怎么从"补基础 → 写 POC → 比方案 → 测性能 → 验回归"
一步步走下来,把它变成一个可以说清楚的结论。