C++并发编程入门:面试官问“机器人系统为什么必须用多线程”,我答得太教科书了
上篇聊了C++17的新特性,今天进入一个在机器人开发里绕不开的话题——多线程。
面试的时候,面试官问"你在项目里用过线程吗",我说用过。面试官接着问"为什么机器人系统需要多线程?"我说"因为要并行处理"。面试官说"能具体点吗?"
我当时的回答太笼统了。今天结合实际场景把这个话题讲透。
为什么机器人必须用多线程
一个典型的移动机器人,同时要做这些事:读取传感器数据(激光雷达、IMU、编码器)、运行定位算法(SLAM)、执行路径规划、发送电机控制指令、处理通信消息。
这些任务的频率不一样。IMU可能1000Hz,激光雷达10Hz,路径规划1Hz,通信消息不定。如果全塞在一个线程里串行执行,高频任务会被低频任务拖死。
想象一下:你的路径规划正在算A,这时候IMU数据来了,但得等A算完才能处理。等了两秒,机器人的姿态已经偏了好几度。控制回路延迟这么大,机器人不走直线是必然的。
所以必须用多线程:传感器采集一个线程、定位算法一个线程、路径规划一个线程、控制回路一个线程。各线程独立运行,互不阻塞。
std::thread:最基础的线程创建
C++11引入了std::thread,创建线程非常简单:
#include <thread>
#include <iostream>
void sensorLoop() {
while (true) {
// 读取传感器数据
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
int main() {
std::thread t(sensorLoop); // 创建并启动线程
// t.join(); // 等待线程结束
t.detach(); // 或者分离,让线程独立运行
}
join()和detach()必须选一个。join是等线程跑完,detach是让线程独立运行。如果不调用任何一个,thread的析构函数会直接调std::terminate——程序崩溃。
这个坑我当年踩过。创建了一个thread对象,忘了join或detach,程序跑着跑着就abort了。debug了半小时才找到原因。
线程也可以带参数:
void processCamera(int cameraId, std::string configPath) {
// 处理特定相机的数据
}
std::thread t1(processCamera, 0, "cam_front.yaml");
std::thread t2(processCamera, 1, "cam_rear.yaml");
t1.join();
t2.join();
mutex:保护共享数据
多线程一上来就会遇到的问题:数据竞争。
class SharedMap {
std::map<string, double> landmarks_;
public:
void update(const string& name, double value) {
landmarks_[name] = value; // 多线程同时写,数据竞争!
}
double get(const string& name) {
return landmarks_[name]; // 同时读写,也是数据竞争!
}
};
两个线程同时操作map,轻则数据错乱,重则段错误。解决办法是用互斥锁(mutex):
class SharedMap {
std::map<string, double> landmarks_;
std::mutex mtx_;
public:
void update(const string& name, double value) {
std::lock_guard<std::mutex> lock(mtx_);
landmarks_[name] = value;
}
double get(const string& name) {
std::lock_guard<std::mutex> lock(mtx_);
return landmarks_[name];
}
};
lock_guard是RAII风格的锁管理——构造时加锁,析构时自动解锁。不管函数怎么退出(正常return还是抛异常),锁都能正确释放。
在机器人开发里,典型需要加锁的场景:地图数据的读写、传感器配置的更新、全局状态变量的修改。但要注意,锁的粒度越小越好——不要锁整个函数,只锁真正访问共享数据的那几行。
atomic:轻量级的线程安全
有些操作只需要保证原子性,不需要复杂的锁机制。比如一个计数器:
#include <atomic>
class FrameCounter {
std::atomic<int> count_{0};
public:
void increment() { count_++; } // 原子操作,线程安全
int get() const { return count_.load(); }
};
std::atomic保证了操作的原子性。count_++在多线程下不会出现"丢失更新"的问题。
相比mutex,atomic的优势是:无锁。它通常用硬件级别的原子指令实现(比如x86的LOCK前缀),不需要操作系统介入,性能更好。
但atomic只适合简单的操作——加减、赋值、比较交换(CAS)。复杂的数据结构(map、vector)还是得用mutex。
在机器人开发里,atomic常用于:帧计数器、状态标志位、简单的统计量。比如一个std::atomic<bool> isRunning_来控制线程的启停,比加mutex轻量得多。
面试中的并发考点
面试官考并发编程,几个经典问题:
"join和detach有什么区别?"join会阻塞当前线程,等待目标线程执行完毕;detach让目标线程独立运行,之后无法再同步它。实际开发中,ROS节点通常用detach(因为回调函数需要一直运行),而计算任务通常用join(等结果算完再继续)。
"什么是数据竞争?"两个或以上线程同时访问同一内存位置,且至少有一个是写操作。数据竞争是未定义行为——程序可能崩溃、可能数据错乱、可能"看起来正常"但在不同编译器上表现不同。
"lock_guard和unique_lock有什么区别?"lock_guard简单轻量,构造加锁、析构解锁,不能手动控制。unique_lock更灵活,可以手动lock/unlock,支持延迟加锁,也能配合条件变量使用。性能上lock_guard略优,因为编译器有更多优化空间。
再聊一个面试中必问的话题:死锁的预防和条件变量的使用。死锁产生的四个条件是互斥、持有并等待、不可抢占、循环等待。在代码层面最常见的死锁场景是两个线程分别持有两把锁,然后各自尝试获取对方持有的锁。预防死锁最简单的方法是保证所有线程按相同的顺序获取锁——比如永远先锁A再锁B,就不会出现循环等待。C++17还提供了std::lock和std::scoped_lock来同时锁定多把锁,避免死锁。条件变量(std::condition_variable)则是线程间协作的关键工具——生产者往队列里放数据后通知消费者,消费者等待通知后再取数据处理。在机器人系统里,传感器数据从采集线程传到处理线程,条件变量是最常用的同步机制。面试时能讲清楚死锁预防和条件变量的配合使用,面试官会认为你对并发编程有实战经验。
给正在准备面试的你
C++并发编程在面试里考的是"意识"。不要求你写多复杂的并发代码,但你得知道:什么时候该用线程、共享数据怎么保护、mutex和atomic各自适合什么场景。
机器人系统天然需要多线程——传感器采集、定位、规划、控制,每个模块都有自己的频率。面试的时候能把这个场景讲清楚,比背十个API更有说服力。
分享一个并发编程的调试经验:遇到多线程bug,先加日志打印线程ID和时间戳,定位是不是竞态条件导致的。GDB的多线程调试功能可以暂停所有线程、查看各线程的调用栈,非常实用。另外,编译时加上-fsanitize=thread(ThreadSanitizer),可以自动检测数据竞争,比人肉排查高效得多。
下篇开始进入阶段二:C++进阶与实战。第一篇聊RAII惯用法——C++资源管理的核心思想,也是面试区分候选人水平的关键知识点。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。 你的支持是我持续更新的最大动力。
「机器人软件开发面试·从入门到精通」连载系列 上一篇:第39篇 C++17结构化绑定与optional——让代码更优雅的现代特性 下一篇预告:第41篇 RAII惯用法——C++资源管理的核心思想
有任何问题欢迎评论区留言,我会尽量回复。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐



所有评论(0)