视觉算法岗位面试复盘
C++/Qt 视觉算法岗位面试复盘
本次面试未录用。题目集中在 C++、Qt 多线程、PLC 高频采集、数据一致性、数据库、CI/CD 和开源协议。它们并不是孤立知识点,核心考察的是能否设计一条可靠的数据链路:
1 | 设备采集 -> 请求调度 -> 数据处理 -> 数据存储 -> UI展示 |
回答这类问题时,先说结论,再说明线程边界、数据结构、失败策略和验证指标。
一、C++17 有什么新特性,用过什么
适合面试的回答
我常用的 C++17 特性包括:
- 结构化绑定:拆分
pair、tuple或键值结果。 if constexpr:在模板中进行编译期分支。std::optional:表示可能不存在的解析结果或配置项。std::variant:表示多种可能的数据类型。std::string_view:只读字符串视图,减少复制。std::filesystem:处理配置、日志和归档文件路径。std::scoped_lock、std::shared_mutex:进行 RAII 加锁和读写锁保护。- 折叠表达式、
std::apply:用于通用模板处理。
在设备协议解析中,可以用 std::optional 表示解析失败,用结构化绑定读取结果,用 string_view 避免不必要的字符串拷贝;配置和日志目录可以使用 std::filesystem。
只回答真正使用过的特性,不要把 C++20 的 std::jthread、协程说成 C++17 特性。
二、多线程数据冲突怎么办
数据竞争的条件是:多个线程访问同一块内存,至少一个线程写入,并且没有同步机制。
处理顺序:
- 优先采用线程所有权,数据只由一个线程修改,线程之间通过信号槽传递快照。
- 简单计数器、标志位使用
std::atomic。 - 复合数据和容器使用
QMutex、QReadWriteLock或 C++ 标准库锁。 - 多生产者、多消费者场景使用有界线程安全队列。
- 使用 RAII 锁,例如
QMutexLocker、std::lock_guard、std::scoped_lock。
不要把“所有地方都加锁”当作线程安全设计。锁的范围应尽量小,持锁时不能执行网络、数据库或长时间计算。
三、Qt 中多线程启动步骤
推荐使用 QObject + QThread,而不是把业务逻辑全部写进 QThread::run()。
1 | 创建无父对象的 Worker |
典型步骤:
- 创建
QThread。 - 创建没有父对象的 Worker。
- 调用
moveToThread()。 - 把
QThread::started连接到 Worker 的初始化槽。 - 使用跨线程信号槽发送操作请求。
- Worker 完成时发送结果信号。
- 停止时先通知 Worker 停止,再退出线程并
wait()。 - 用
thread.finished -> worker.deleteLater管理 Worker 生命周期。
跨线程不能直接调用 QModbusTcpClient 的函数。客户端应该在所属线程创建,也只能在该线程使用。
四、怎么保证线程安全
核心方法是明确所有权:
| 数据 | 推荐所有者 | 是否需要锁 |
|---|---|---|
| 每台 PLC 的客户端 | 对应 Worker 线程 | 不需要,禁止跨线程调用 |
| 每台 PLC 的读写队列 | 对应 Worker 线程 | 不需要 |
| 每台 PLC 的解析缓存 | 对应 Worker 线程 | 不需要 |
| UI 控件 | 主线程 | 不需要 |
| 全局请求数、失败数 | 多线程 | std::atomic |
| 共享快照容器 | 多线程读写 | QReadWriteLock,或改成单线程所有权 |
| 数据库任务队列 | 多个采集者、一个写库者 | QMutex + QWaitCondition |
原子变量适合独立值:
1 | std::atomic_uint64_t totalRequests{0}; |
原子变量不能保证多个字段组成的快照一致。例如压力、温度、时间戳必须同时属于同一采样周期,应该复制完整的不可变快照,或用锁保护整个快照。
五、100 台 PLC 同时读取,Qt 采用什么策略
不建议简单创建100个永久轮询线程。
如果通信库是异步接口,可以使用少量 I/O 线程管理多个 ModbusWorker:
1 | 2~4 个 I/O 线程 |
如果通信库是阻塞接口,则使用固定大小线程池或分组线程。每个设备仍然要有独立的超时、重试、在线状态、序列号和统计信息。
采集策略:
- 长连接,避免每次轮询重新连接。
- 合并连续寄存器,减少请求数。
- 错开设备轮询时间,避免同时发送100个请求。
- 同一连接上保持请求顺序,避免并发使用非线程安全客户端。
- 慢设备不能阻塞其他设备。
- 采集结果进入有界队列或快照存储。
- UI只读取最新快照,不直接参与设备通信。
如果需要严格的同一时刻数据,PC轮询无法保证,需要 PLC 统一触发、时钟同步和采样时间戳。
六、需要快速频率读取数据怎么办
先确认目标频率是“设备数据刷新频率”“通信轮询频率”还是“控制执行频率”,三者不能混淆。
优化方向:
- 使用设备支持的异步、订阅或主动上报机制。
- 保持连接,批量读取连续寄存器。
- 使用二进制协议和预分配缓冲区。
- 减少字符串转换、日志和频繁内存分配。
- 采集、计算、存储和 UI 分开。
- 用
QElapsedTimer测量周期、平均延迟、P95/P99 延迟和抖动。 - 使用截止时间调度,避免简单连续
msleep(1)造成漂移。
普通 QTimer、Windows线程和 Qt Widgets 不能提供硬实时的1ms保证。
七、Qt 跟不上设备读取频率怎么办
这是生产速度大于消费速度的问题,需要明确过载策略,而不是无限堆积队列。
- 只关心最新状态:覆盖旧快照。
- 历史数据不能丢:使用有界环形缓冲区和批量落盘。
- UI使用降采样数据,例如最大值、最小值、平均值。
- 计算很慢:使用
QThreadPool,结果必须带序号。 - 队列超过阈值:限流、丢弃低优先级数据并报警。
- 数据库变慢:采集与写库解耦,批量写入。
先分别测量采集、解析、计算、数据库和 UI 耗时,不能只凭感觉判断瓶颈。
八、读取后还要处理,数据对不上怎么办
异步处理可能乱序返回,不能用“谁最后返回谁覆盖”的方式更新数据。每条采样消息都应携带设备编号、采样时间和序列号:
1 | struct Sample { |
处理结果必须继续携带相同的 deviceId + sequence。更新时:
- 丢弃比当前序号更旧的结果。
- 检测序号跳变,统计丢包。
- 检测重复序号。
- 多设备融合时按采样时间窗口匹配,而不是按到达顺序匹配。
- 处理输入使用不可变快照,不能读取持续变化的全局变量。
九、数据能读取但 UI 卡顿怎么办
设备可以1000Hz采集,但 UI 没有必要1000Hz重绘。
推荐:
1 | 采集线程:按设备频率读取 |
Qt Widgets 中使用 QTimer 控制 UI 更新周期:
1 | QTimer *timer = new QTimer(this); |
update() 是异步请求重绘,Qt会合并重复的重绘请求;repaint() 是同步重绘,高频调用容易阻塞主线程。不要每个采样点发送一个 UI 信号,也不要在主线程执行数据库、文件和复杂计算。
当前项目中的 timerUpdateUI 只控制 updateUI(),不控制 Modbus 采集频率。采集信号、CSV和HTTP信号仍可能高频进入主线程,需要额外进行快照合并或限频。
十、机械臂1000Hz、PLC500Hz,5N时退回
500Hz 的 PLC 每2ms更新一次数据,机械臂1000Hz每1ms执行一次。再叠加采样、通信、PLC扫描、软件处理和执行器响应延迟,等当前力值达到5N再退回必然会超调。
面试官提出的斜率预测是有效的延迟补偿方法:
1 | k = 最近一段力曲线的斜率 |
当 F_predict >= 5N 时提前发送退回信号。
更成熟的方案:
- 用最近多个采样点做线性拟合,而不是只用相邻两个点。
- 通过滤波减少噪声,但滤波窗口不能过大。
- 根据力增长速度、机械臂速度和制动距离动态提前触发。
- 接触前采用快速接近、低速接触的两段式策略。
- 机械臂控制器运行1kHz本地状态机,PLC作为外环或流程控制。
- 使用时间戳补偿数据已经产生的延迟。
- 对安全动作增加硬件限位、安全PLC或硬件比较器。
可以使用更复杂的状态观测器、Smith预测器或MPC,但软件预测不能消除不可控的网络抖动,也不能替代硬实时安全链路。
十一、设备采集数据特别多,数据库如何优化
数据库优化要同时考虑“写得进去”和“查得出来”。推荐架构:
1 | 采集线程 -> 有界队列 -> 批量写库线程 -> 原始表/聚合表 -> 查询服务/UI |
写入优化
- 不要每条数据单独开启事务。
- 使用预编译语句、多行插入或数据库批量接口。
- 一个数据库连接由写库线程独占。
- 控制批量大小和提交周期,例如按条数或几十毫秒提交。
- 队列有上限,数据库变慢时执行限流或丢弃低价值数据。
查询优化
- 只查询需要的列,避免
SELECT *。 - 按设备和时间范围查询。
- 建立符合实际查询的联合索引,例如
(device_id, timestamp)。 - 按天或按月进行时间分区。
- 最近数据保存原始精度,历史数据使用秒级或分钟级聚合。
- 曲线查询按屏幕分辨率降采样,不要一次返回百万个点。
- 深度翻页使用游标或主键翻页,避免深度
OFFSET。 - 使用
EXPLAIN检查是否发生全表扫描。
游标翻页的本质是记录上一页最后一条的排序位置:
1 | SELECT * |
id < 901 不是取第901条,而是从上一页最后一条记录之后继续读取。前端只保存并传回 next_cursor,不计算偏移量。
数据生命周期
- 热数据保留在线快速查询。
- 旧数据压缩或迁移到历史表。
- 很久以前的数据归档到文件或对象存储。
- 根据业务要求设置保留周期。
十二、CI/CD 了解吗
CI/CD 是持续集成、持续交付和持续部署。
CI
每次提交代码后自动执行:
- 拉取代码。
- 安装或缓存依赖。
- 编译 Debug/Release。
- 运行单元测试和集成测试。
- 执行静态检查、格式检查和安全扫描。
- 保存测试报告和构建产物。
CD
- 持续交付:构建出可发布版本,由人工确认上线。
- 持续部署:通过自动化流程直接部署到目标环境。
Qt项目可以在流水线中配置:
- Windows、Linux等构建矩阵。
- Qt版本和编译器版本固定。
- CMake/qmake自动构建。
- Qt Test测试。
- AddressSanitizer、ThreadSanitizer或静态分析。
- 打包依赖库、配置文件和安装程序。
- 版本号、Git提交号和构建时间写入产物。
面试时可以说:我理解CI/CD的重点不是“自动打包”本身,而是让每次提交可重复编译、可测试、可追溯、可回滚。
十三、软件中的开源协议
使用开源代码前要看对应项目的 LICENSE、NOTICE 和第三方依赖清单,不能只看项目名称。
| 协议类型 | 主要特点 |
|---|---|
| MIT/BSD | 宽松,可商用和闭源,通常需要保留版权和许可声明 |
| Apache-2.0 | 宽松,包含专利授权和专利终止条款,需要保留NOTICE等声明 |
| GPL | 强Copyleft,分发衍生作品时通常需要按GPL提供源代码 |
| LGPL | 比GPL宽松,通常允许动态链接闭源使用,但要满足重新链接、声明等义务;静态链接要求更严格 |
| AGPL | 在GPL基础上关注网络服务场景,通过网络提供服务也可能触发源代码提供义务 |
还要关注:
- 开源协议是否允许商业使用。
- 是否要求公开修改后的源代码。
- 是否需要保留版权和NOTICE。
- 静态链接和动态链接的区别。
- 第三方依赖是否带有传染性协议。
- Qt具体版本使用的是LGPL、GPL还是商业授权。
面试回答可以是:我会逐个确认依赖的许可证,保存许可证和NOTICE,维护第三方清单;商业产品中优先选择合规的MIT、BSD、Apache或满足条件的LGPL方案,GPL/AGPL依赖需要在引入前评估分发方式和源代码义务。
十四、结合现有 ModbusWorker 的代码复盘
当前单Worker的线程封闭思路是可以保留的,但扩展到20台PLC前必须处理这些问题:
readRequestCount不能是文件级static,必须是每个Worker的成员。- PLC地址、端口、Unit ID必须进入
PlcConfig,不能硬编码。 - 所有结果信号携带
deviceId,否则20台设备的数据无法区分。 - 读队列和写队列分离,写队列使用FIFO,不能用
prepend()导致写命令倒序。 - 读取失败不能直接计入完整周期,需要成功掩码、序列号和数据质量标记。
- 读请求不能无限快速循环,需要轮询周期、超时和退避策略。
- 队列要有容量上限,写命令需要超时、确认和失败反馈。
- UI只接收最新快照,不能让每次采样都触发控件刷新。
- 数据库和CSV写入不能放在I/O线程中。
- 关闭程序时先停止Worker,再退出线程并等待,确保客户端和未完成请求安全释放。
十五、最终口述模板
遇到这类综合题,可以用下面的顺序回答:
我的设计会把采集、处理、存储和UI分层。每个PLC有独立的连接状态、请求队列、序列号和快照,异步通信通过少量I/O线程管理。线程之间使用信号槽或有界队列传递不可变数据,简单统计使用原子变量,复合数据使用锁或线程所有权。采集速度大于处理速度时采用批处理、限流、降采样和丢弃策略。所有数据携带设备编号、时间戳和序列号,避免异步处理后数据错位。UI只按固定频率显示最新快照。对于1kHz机械臂和500Hz PLC的力控问题,我会测量总延迟,根据力曲线斜率预测未来力值并提前触发,同时把硬实时闭环和安全保护放在实时控制器或硬件侧。数据库采用批量写入、时间分区、联合索引、聚合和游标分页。代码通过CI/CD自动编译、测试、检查和打包,并按许可证要求管理第三方依赖。
这段回答的重点不是堆砌名词,而是说明数据如何流动、哪里可能阻塞、哪里可能竞争、失败后如何恢复,以及如何验证系统确实可靠。
失利总结
基础知识不够扎实,开发流程规范不够了解。
失败是人生的主旋律,但是如何面对失败,把我们分成了不同的人
