3. C++并发编程基础
在嵌入式系统中,多核 MCU 与 RTOS 的普及让“并发”不再是桌面程序的专属话题。C++11 之后,标准库提供了一套跨平台的线程与同步工具,使我们可以用统一语法在 Linux 网关、Cortex-A 应用处理器甚至支持 C++ 的 RTOS 上编写并发代码。本节聚焦四个最常用的工具:std::thread、std::mutex、std::lock_guard 与 std::condition_variable,理解它们各自的职责与配合方式。
一、核心原理:线程、互斥与条件等待
std::thread 是线程的“载体”,构造时传入可调用对象即启动一个新线程,join() 等待其结束,detach() 让其独立运行。线程之间共享同一进程的地址空间,因此对共享数据的读写必须受控,否则会出现数据竞争(data race),其行为是未定义的。
std::mutex 是互斥量,提供 lock() 与 unlock() 两个基本操作,保证同一时刻只有一个线程进入临界区。但手动加解锁容易在异常或提前 return 时漏掉 unlock(),于是引入 RAII 风格的 std::lock_guard:构造即加锁,析构即解锁,作用域结束自动释放,是嵌入式代码中最推荐的加锁方式。
std::condition_variable 解决的是“等待某个条件成立”的问题。它必须与一个 mutex 配合使用:等待方在持有锁的情况下检查条件,不满足则调用 wait() 原子地释放锁并阻塞;通知方修改共享状态后调用 notify_one() 或 notify_all() 唤醒等待者。这种“锁 + 条件变量 + 谓词”的组合,是生产者-消费者模型的标准写法。
二、方法流程:从创建线程到安全通信
下面以“采集线程写入缓冲区、处理线程读取缓冲区”为例,展示标准库工具的典型用法。注意 wait 使用谓词版本,可避免虚假唤醒带来的误判。
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
std::mutex mtx;
std::condition_variable cv;
std::queue<int> buffer;
bool done = false;
void producer() {
for (int i = 0; i < 5; ++i) {
{
std::lock_guard<std::mutex> lk(mtx);
buffer.push(i);
} // 作用域结束自动解锁
cv.notify_one(); // 唤醒等待的消费者
}
{
std::lock_guard<std::mutex> lk(mtx);
done = true;
}
cv.notify_all();
}
void consumer() {
while (true) {
std::unique_lock<std::mutex> lk(mtx);
cv.wait(lk, [] { return !buffer.empty() || done; });
while (!buffer.empty()) {
int v = buffer.front();
buffer.pop();
lk.unlock(); // 处理耗时操作时先释放锁
// ... 处理 v ...
lk.lock();
}
if (done) break;
}
}
int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
return 0;
}
流程可归纳为四步:创建线程 → 加锁保护共享状态 → 条件等待或通知 → join 回收线程。其中 std::unique_lock 比 lock_guard 更灵活,因为 condition_variable::wait 需要在等待期间解锁、唤醒后重新加锁,只有 unique_lock 支持这种中途解锁。
三、案例场景:嵌入式数据采集与处理
假设一个环境监测节点,ADC 中断或定时器线程以 100 Hz 采集温湿度,主处理线程负责滤波、打包并通过串口上报。若两者直接读写同一全局数组,会出现“读到半更新数据”的问题。使用上述模式后:采集线程只负责在锁内写入环形缓冲区并通知;处理线程在条件变量上等待,被唤醒后批量取走数据,处理时释放锁,避免阻塞采集。这样既保证了数据一致性,又降低了锁竞争。
| 工具 | 职责 | 典型用法 | 注意事项 |
|---|---|---|---|
| std::thread | 创建与管理线程 | 构造传入函数对象,join 回收 | 析构前必须 join 或 detach |
| std::mutex | 互斥保护临界区 | lock / unlock 成对出现 | 不可重复加锁,避免死锁 |
| std::lock_guard | RAII 自动加解锁 | 作用域内构造即加锁 | 不支持中途解锁 |
| std::unique_lock | 灵活锁管理 | 配合 condition_variable 使用 | 开销略高于 lock_guard |
| std::condition_variable | 条件等待与唤醒 | wait(lock, 谓词) / notify_one | 必须与 mutex 配合,防虚假唤醒 |
四、误区与复盘
误区一:忘记 join 或 detach。 std::thread 析构时若仍处于 joinable 状态,会直接调用 std::terminate 终止程序。嵌入式设备上表现为意外复位,排查困难。
误区二:用 lock_guard 配合 condition_variable。 wait 需要中途解锁,lock_guard 无法做到,必须使用 unique_lock。
误区三:notify 前未修改共享状态或未持锁。 若在锁外修改共享变量,等待方可能看到不一致状态;正确做法是“锁内改状态,锁外或锁内 notify 均可”。
误区四:在临界区内做耗时操作。 如串口打印、延时、文件写入,会长时间持锁,导致其他线程饥饿。应缩小临界区,把耗时处理移到锁外。
复盘建议: 在嵌入式项目中,优先使用 lock_guard 处理简单临界区;需要条件等待时改用 unique_lock。所有共享状态必须明确“由哪个 mutex 保护”,并在代码注释中标注。若目标平台不支持完整 C++ 标准库线程,可评估 FreeRTOS 任务与队列,但接口设计思想与本节一致:隔离共享、明确同步、避免忙等。
掌握这四个工具后,你已经可以搭建基本的生产者-消费者、任务间通信与资源保护结构。后续更复杂的原子操作、内存序与无锁队列,都建立在对互斥与条件等待的正确理解之上。