上篇聊了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++资源管理的核心思想

有任何问题欢迎评论区留言,我会尽量回复。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐