Linux/C++多线程实例学习十字路口车辆调度
前言
十字路口是讲并发时最容易被接受的比喻:四个方向的车流共享一块不能同时占用的区域,谁先进入、谁必须等待、什么时候放行,正好对应线程、互斥量、条件变量和调度策略。很多教材用它来讲「锁」,但讲着讲着就把比喻当成了实现:以为给路口加一把 std::mutex 就完事,以为信号灯一亮所有车就自动排队,以为线程调度器会替我们保证公平。
真实情况要冷静得多。操作系统调度的是线程,不是「车辆」;std::mutex 只保证互斥,不保证先来先服务;条件变量只负责唤醒,不负责排队顺序;而「哪两股车流可以同时通行」这件事,是业务规则,标准库不会替你判断。把这些混在一起,程序要么退化成串行,要么出现饥饿,要么在多个锁之间绕成死锁。
本文用 Linux 上的 std::thread 写一个可推演的路口调度模型:先把路口抽象成共享状态与冲突关系,再用一把互斥量加一个条件变量实现放行,最后讨论公平性、超时与死锁规避。示例只做逻辑演示,不涉及真实交通控制,也不承诺任何实时性。
一、把路口抽象成并发实体
并发建模的第一步不是写锁,而是列清楚「谁共享什么」。路口的本质是一组互相冲突的通行请求争夺同一块空间。把每个方向的车看成一个线程,把「正在路口内」的车辆集合看成共享状态,问题就变成了:一个线程在进入临界区之前,必须确认当前集合里没有与自己冲突的成员。
| 现实对象 | 并发概念 | 标准库设施 |
|---|
| 一辆车 | 一个执行流 | std::thread |
| 路口中心区域 | 临界区 | 由互斥量保护的共享数据 |
| 红绿灯 / 放行判断 | 等待条件 | std::condition_variable |
| 正在通行的车辆集合 | 共享状态 | std::vector 加锁访问 |
| 车辆通过所需时间 | 临界区停留时间 | std::this_thread::sleep_for |
| 路口通行能力 | 并发度 | 冲突矩阵决定的并行上限 |
冲突关系是业务核心。真正的十字路口要考虑直行、左转、右转、掉头、行人相位、非机动车,规则非常复杂。为了让示例可以逐行推演,本文采用一个极简模型:同一方向的车必须串行;任何涉及左转的请求与其它所有方向冲突;不同方向的直行可以同时进行。这个模型显然不是真实交通规则,但它保留了并发调度最关键的结构:能否进入临界区,取决于当前集合的内容,而不是取决于线程自己。
这也解释了一个常见误解:不少人把信号灯理解成「一个全局布尔变量,谁抢到谁走」。信号灯其实是一个状态机,放行条件是「当前相位与请求匹配」,而不是「锁空着」。如果只用一个布尔量,就会把本可以并行的两股直行车流硬生生串行化,吞吐量白白掉一半。
二、用一把锁和一个条件变量实现放行
最直接的实现是「单锁 + 条件变量」:所有对共享集合的读写都在同一把互斥量下完成,进入前用 wait 等待谓词成立,离开时修改集合再 notify_all。
这里有几个必须讲准的语义点:
std::condition_variable::wait(lock, pred)会在持有锁的情况下求值谓词;谓词为假时原子地释放锁并阻塞,被唤醒后重新加锁再求值。它天然处理虚假唤醒(spurious wakeup),比手写while (!pred) cv.wait(lock);更不容易写错。
notify_all唤醒所有等待者,被唤醒的线程要重新竞争锁,因此可能「惊群」,但正确性不受影响。notify_one只唤醒一个,若被唤醒的线程谓词仍为假,其余线程不会被顺带唤醒,可能造成明明能通行却无人被唤醒的停滞。
- 修改共享状态必须在持锁时进行;
notify本身可以放在锁外,避免被唤醒的线程立刻又阻塞在锁上(这只影响效率,不影响正确性)。
- 谓词捕获的是共享集合,必须保证它在锁内被读取——这也是为什么谓词写在
wait调用处、由wait负责加锁求值。
单锁方案的优点是绝不会死锁:全程只有一把锁,没有加锁顺序问题。缺点是并行度受限——即使两股车流互不冲突,它们也要排队竞争同一把锁。对教学模型来说这是可接受的;若要提高并行度,就得按冲突域拆分锁,而一旦拆分,就必须规定统一的加锁顺序,否则两辆车各持一把锁再互相等待,就是典型的死锁。
三、公平性、饥饿与超时
互斥量和条件变量都不保证公平。标准对唤醒顺序没有规定,具体实现可能偏向某些线程。极端情况下,某条车道上的车可能连续被插队,这就是饥饿(starvation)。教学示例里线程数少、临界区短,不容易观察到,但设计真实系统时必须正视。
常见的补救思路有三种。第一种是显式排队:给每个请求发一个单调递增的票号,谓词里除了判断冲突,还要求「没有更早的冲突请求在等」,从而近似先来先服务。第二种是限制等待时间:用 wait_for 配合超时,超时后重新评估或降级处理,避免无限期阻塞。第三种是把调度权交给一个专门的分发线程,工作线程只提交请求,由分发线程按队列顺序决策,这样公平性由队列保证,代价是多一次线程间通信。
需要强调的是,wait_for 返回 std::cv_status::timeout 不代表谓词为假,也可能是虚假唤醒或通知丢失的窗口;正确写法仍是「醒来后重新检查谓词」,即使用带谓词的 wait_for 重载,它返回的是谓词结果。超时只是给调用方一个重新决策的机会,不是对共享状态的保证。
四、完整示例
下面的代码把所有车辆建模为线程,用一个 Intersection 对象管理当前通行集合。为了让输出可读,进入和离开时各打印一行;真实项目应改用日志库并避免在锁内做 I/O。
// 适用于 C++17
// Linux 编译:g++ -std=c++17 -pthread intersection.cpp -o intersection
#include <chrono>
#include <condition_variable>
#include <functional>
#include <iostream>
#include <mutex>
#include <random>
#include <thread>
#include <vector>
enum class Dir { North, East, South, West };
struct Request {
Dir from;
bool left_turn; // true 表示左转,false 表示直行
};
// 极简冲突模型:同向串行;任一左转与所有方向冲突;异向直行可并行
bool conflicts(const Request& a, const Request& b) {
if (a.from == b.from) return true;
if (a.left_turn || b.left_turn) return true;
return false;
}
class Intersection {
public:
void enter(const Request& r, int car_id) {
std::unique_lock<std::mutex> lock(m_);
cv_.wait(lock, [&] {
for (const auto& other : active_) {
if (conflicts(r, other)) return false;
}
return true;
});
active_.push_back(r);
std::cout << "car " << car_id << " enters\n";
}
void leave(const Request& r, int car_id) {
{
std::lock_guard<std::mutex> lock(m_);
for (auto it = active_.begin(); it != active_.end(); ++it) {
if (it->from == r.from && it->left_turn == r.left_turn) {
active_.erase(it);
break;
}
}
std::cout << "car " << car_id << " leaves\n";
}
cv_.notify_all(); // 状态变了,唤醒所有等待者重新评估
}
private:
std::mutex m_;
std::condition_variable cv_;
std::vector<Request> active_;
};
void car(Intersection* road, int id, Dir from, bool left_turn, unsigned seed) {
std::mt19937 gen(seed);
std::uniform_int_distribution<int> delay(50, 200);
Request req{from, left_turn};
std::this_thread::sleep_for(std::chrono::milliseconds(delay(gen)));
road->enter(req, id); // 可能阻塞,直到不冲突
std::this_thread::sleep_for(std::chrono::milliseconds(delay(gen)));
road->leave(req, id);
}
int main() {
Intersection road;
std::vector<std::thread> cars;
const Dir dirs[4] = {Dir::North, Dir::East, Dir::South, Dir::West};
for (int i = 0; i < 8; ++i) {
cars.emplace_back(car, &road, i, dirs[i % 4], (i % 3) == 0,
static_cast<unsigned>(i + 1));
}
for (auto& t : cars) t.join();
std::cout << "all cars passed\n";
return 0;
}代码要点逐条说明:
std::ref没有被使用,线程函数直接接收Intersection*,避免了std::thread对参数做 decay 拷贝时把对象复制一份的陷阱。
leave里先在一对花括号内加锁修改集合,出了作用域再notify_all;这样被唤醒的线程不会立刻撞上仍被持有的锁。
std::mt19937的种子来自车辆编号,保证每个线程有独立的随机序列,<random>的对象不是线程安全的,不能共享同一个引擎。
main中 8 个线程全部join,没有分离线程。若std::thread对象在仍可 join 时被析构,标准规定调用std::terminate。
- 该实现假定每个请求在
active_中最多出现一次,这与「同向串行」的冲突规则一致;若放开同向并行,leave的匹配逻辑必须带上唯一编号。
常见坑点
❌ 用一个全局 bool green 表示放行,多个线程直接读写。
✅ 用互斥量保护状态,并用条件变量等待「当前无冲突」这一谓词;裸布尔量在没有同步的情况下读写属于数据竞争,是未定义行为。
❌ 在 wait 外层写 if (!ready) cv.wait(lock);,只判断一次。
✅ 用带谓词的 wait(lock, pred),或写成 while (!pred()) cv.wait(lock);,以应对虚假唤醒。
❌ 持锁调用 std::cout、写文件或发起网络请求。
✅ 只在锁内修改共享状态,把 I/O 移到锁外;临界区越长,其它车辆等待越久。
❌ 为了并行给每个方向各配一把锁,加锁顺序随意。
✅ 要么保持单锁,要么规定全局一致的加锁顺序,或用 std::scoped_lock(C++17)一次锁住多个互斥量。
❌ 线程函数按值接收 Intersection,以为线程操作的是同一个路口。
✅ 传指针或 std::reference_wrapper,明确共享的是同一个对象;按值传会复制,各线程各玩各的。
❌ 认为 notify_one 一定比 notify_all 好,随手替换。
✅ 当被唤醒者的谓词可能仍为假时,notify_one 可能漏掉真正能通行的线程;状态变化影响多个等待者时用 notify_all。
❌ 线程对象析构前既不 join 也不 detach。
✅ 保证每个 std::thread 在析构前被 join 或 detach,通常用 RAII 包装或统一在末尾 join。
总结
| 主题 | 正确认识 | 常见错误 |
|---|
| 临界区 | 由业务冲突关系定义 | 把整个路口无脑串行 |
| 互斥量 | 只保证互斥,不保证公平 | 指望锁实现先来先服务 |
| 条件变量 | 负责等待与唤醒,谓词负责正确性 | 用 if 代替循环判断 |
| 通知方式 | 状态变化范围决定 notify_one/all | 一律用 notify_one |
| 并行度 | 由冲突矩阵决定 | 一把大锁掩盖设计问题 |
| 实时性 | 标准库不提供实时调度保证 | 把模拟当成交通控制系统 |
十字路口调度的价值不在于模拟交通,而在于逼你把「共享状态、进入条件、退出通知、公平性」四件事分开想清楚。单锁加条件变量的版本最容易写对,也最容易讲清楚;等到确实需要更高并行度时,再按冲突域拆锁,并为多锁设计统一的获取顺序。标准库给你的是互斥与等待的原语,调度策略永远得由业务自己定义。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)