这是系列第三篇。前两篇聊了"为什么自研"和"共享内存通信",这篇聊调度------有了数据通道之后,如何让模块高效地跑起来。
1. 调度问题:从操作系统视角看自动驾驶
自动驾驶系统本质上是一个实时数据处理管道:
传感器 → 预处理 → 感知 → 跟踪 → 预测 → 规划 → 控制
10Hz 10Hz 10Hz 10Hz 10Hz 10Hz 100Hz
调度要解决的核心问题是:在有限的 CPU 核心上,如何安排这些模块的执行顺序和时机,使得端到端延迟最小?
1.1 三种调度模型
| 模型 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 一模块一线程 | 每个模块独立线程 | 实现简单 | 线程数多、切换开销大、难以协调 |
| 线程池 + 任务队列 | 所有模块作为任务提交到线程池 | 线程数可控 | 模块间等待需要条件变量,上下文切换开销 |
| 协程调度 | M 个协程运行在 N 个线程上 | 轻量切换、可挂起/恢复 | 需要协程库支持 |
HyperFlow 选择协程调度。
2. 协程调度:为什么选 marl?
2.1 什么是 marl?
marl 是 Google 开源的 C++ 协程调度库,最初为 Vulkan/Dawn 项目设计。它的核心特性:
- M:N 调度:M 个协程(Fiber)运行在 N 个 OS 线程上
- 抢占式 :基于
marl::Scheduler的协作式调度 - 轻量:协程切换只需保存/恢复寄存器,约 100ns
- 零依赖:纯 C++ 实现,不依赖 OS 特性
2.2 为什么不用其他的?
| 方案 | 问题 |
|---|---|
std::thread + 条件变量 |
每个模块一个线程,10 个模块就 10 个线程,调度不可控 |
boost::asio + strand |
可行但学习曲线陡,且无法方便地挂起/恢复 |
libco(微信) |
只支持 Linux x86_64,ARM 支持不好 |
goroutine(Go) |
需要 GC,不适合嵌入式实时场景 |
marl 的优势在于:轻量、跨平台、API 简洁、协程切换快。
2.3 marl 的核心 API
cpp
// 初始化调度器
marl::Scheduler scheduler;
scheduler.setWorkerThreadCount(4); // 4 个工作线程
scheduler.bind();
// 提交任务
scheduler.enqueue([] {
LOG(INFO) << "Hello from fiber!";
});
// 等待/唤醒
marl::Event event;
scheduler.enqueue([&] {
event.wait(); // 挂起当前协程,让出 CPU
LOG(INFO) << "Woken up!";
});
event.signal(); // 唤醒等待的协程
scheduler.unbind();
3. HyperFlow 的调度架构
3.1 整体架构
┌──────────────────────────────────────────────────────┐
│ marl::Scheduler │
│ (4 个工作线程) │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Fiber A │ │ Fiber B │ │ Fiber C │ │ Fiber D │ │
│ │Module1 │ │Module2 │ │Module3 │ │Module4 │ │
│ │定时100ms│ │事件驱动 │ │定时 50ms│ │混合模式 │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ Worker1 Worker2 Worker3 Worker4 │
└──────────────────────────────────────────────────────┘
每个 Module 运行在一个 marl::Fiber 中,由 marl::Scheduler 调度。
3.2 模块的运行主循环
cpp
ModuleImpl::run() {
// Init 阶段已在主线程完成
// Start
module_->Start();
while (running_) {
if (interval_ > 0) {
// 定时模式:等待 interval 毫秒
marl::Event::wait_for(std::chrono::milliseconds(interval_));
} else {
// 事件驱动模式:等待 Trigger 唤醒
event_.wait(); // marl::Event,挂起协程
}
// 执行 Step
run_step();
}
// Stop
module_->Stop();
}
3.3 三种调度模式
模式一:定时调度(interval > 0)
json
{ "name": "lidar_driver", "interval": 100 }
时间轴: 0ms 100ms 200ms 300ms 400ms
│ │ │ │ │
Step: [执行] [执行] [执行] [执行] [执行]
适用于传感器驱动等需要固定周期的模块。
模式二:事件驱动(interval = -1,listen trigger)
json
{ "name": "perception", "listen": "lidar_driver.data_ready" }
时间轴: 0ms 105ms 210ms
│ │ │
│ lidar_driver │ lidar_driver │
│ Notify() │ Notify() │
│ │ │ │ │
│ ▼ │ ▼ │
Step: [等待...] [执行] [等待...] [执行]
适用于依赖上游数据的算法模块,避免空转浪费 CPU。
模式三:混合模式(Wait 带超时)
cpp
void Step() override {
if (Wait(100)) {
// 被 Trigger 唤醒
ProcessNewData();
} else {
// 超时,执行心跳逻辑
CheckHealth();
}
}
既响应事件,又有超时保护。适用于需要心跳检测的场景。
4. Trigger 机制:模块间的协作
4.1 NotifyTrigger 与 ListenTrigger
Module A (Producer) Module B (Consumer)
│ │
│ Step() { │ Wait() { ... }
│ Write("data", ...); │ │
│ Notify("data_ready"); ──────► │ ▼ 被唤醒
│ } │ Step() {
│ │ Read("data", ...);
│ │ }
▼ ▼
NotifyTrigger:
- 由写者模块拥有
Notify()递增计数器,向所有监听进程发送 POSIX 信号- 支持
GetCount()查看通知次数
ListenTrigger:
- 由读者模块绑定
Listen(module)注册监听模块WakeUp()唤醒所有监听模块(调用Module::Wakeup())
4.2 跨进程触发
Process A Process B
│ │
│ Module1.Notify("data_ready") │
│ │ │
│ ▼ │
│ NotifyTrigger │
│ │ Signal(pid_B, trigger_id) │
│ └──────────────────────────────────►│
│ │ TriggerManager
│ │ │ on_signal(trigger_id)
│ │ ▼
│ │ ListenTrigger.WakeUp()
│ │ │
│ │ ▼
│ │ Module3.Wakeup()
│ │ │ event_.signal()
│ │ ▼
│ │ Module3.Step() → Read("data")
关键细节:
NotifyTrigger维护一个pid_count,记录有多少进程在监听Signal::Send(pid, data)向指定进程发送实时信号- 接收端的
TriggerManager在独立线程中sigwait,收到信号后查找 Trigger 表并唤醒对应模块
4.3 Trigger 的命名约定
module_name.trigger_name
例如:
lidar_driver.data_ready
fusion.result_ready
control.cmd_sent
配置中的 listen 字段格式为 source_module.trigger_name:
json
{
"name": "perception",
"listen": "lidar_driver.data_ready"
}
5. 模块生命周期与插件架构
5.1 Module 生命周期
Init(config) → Start() → [Step() × N] → Stop() → Destroy()
主线程 任务线程 任务线程 任务线程 主线程
| 方法 | 线程 | 说明 | 是否必须实现 |
|---|---|---|---|
Init(config) |
主线程 | 解析配置、初始化资源 | 是 |
Start() |
任务线程 | 启动后回调 | 否(有默认实现) |
Step() |
任务线程 | 周期执行的业务逻辑 | 是 |
Stop() |
任务线程 | 停止回调 | 否 |
Destroy() |
主线程 | 资源释放 | 否 |
为什么 Init 和 Destroy 在主线程?
Init时需要创建共享内存、绑定 DataQueue,这些操作需要全局协调Destroy时需要释放资源、关闭共享内存,必须等所有模块 Stop 之后
5.2 插件加载与模块注册
cpp
// 在插件 .so 中定义模块
class MyPerception : public Module {
public:
using Module::Module;
std::error_code Init(const Json::Value &config) override {
// 解析配置
return std::error_code();
}
void Step() override {
// 读取点云 → 运行推理 → 写出结果
const PointCloud* cloud = nullptr;
Get("lidar_points", cloud);
// ...
Write("detection_result", result);
Notify("detection_ready");
}
};
// 注册模块
REGISTER_MODULE(MyPerception, "MyPerception");
注册机制:
REGISTER_MODULE宏在.so加载时(dlopen)的静态初始化阶段执行- 将
"MyPerception"→MyPerception::Create的映射注册到全局工厂 ModuleManager根据配置中的type字段从工厂创建模块实例
cpp
// REGISTER_MODULE 展开后等价于:
struct MyPerceptionRegister {
MyPerceptionRegister() {
ModuleRegister::Register<MyPerception>("MyPerception");
}
};
static MyPerceptionRegister g_register;
5.3 Pimpl 模式
Module 使用 Pimpl(Pointer to Implementation)模式隐藏内部实现:
cpp
class Module {
public:
// 公共接口
std::error_code Write(const std::string &data_name, const void *data, uint32_t size);
std::error_code Read(const std::string &data_name, void *buf, ...);
void Notify(const std::string &trigger_name);
bool Wait(uint32_t ms);
private:
std::unique_ptr<ModuleImpl> impl_; // 隐藏实现细节
};
ModuleImpl 持有运行时状态:
cpp
class ModuleImpl {
std::atomic<bool> running_;
std::string name_;
int interval_;
uint32_t max_exec_time_;
uint16_t module_id_;
bool enable_snapshot_;
bool fillback_;
marl::Event event_; // 协程等待/唤醒
std::unordered_map<std::string, std::shared_ptr<NotifyTrigger>> notify_triggers_;
std::unordered_map<std::string, std::shared_ptr<DataQueue>> data_queues_;
DataSnapshot snapshot_;
};
Pimpl 的好处:
- Module 的头文件不需要暴露 marl、DataQueue 等实现细节
- 修改 ModuleImpl 不需要重新编译依赖 Module 的代码
- 编译时间大幅减少
6. 执行超时监控
自动驾驶系统中,模块执行超时可能导致安全风险。HyperFlow 内建了超时监控:
cpp
ModuleImpl::run_step() {
auto start = utils::GetTimestampUs();
module_->Step(); // 执行用户逻辑
auto elapsed = utils::GetTimestampUs() - start;
stat_.exec_time.Update(elapsed);
if (elapsed > max_exec_time_ * 1000) {
LOG(WARNING) << "Module " << name_
<< " execution timeout: " << elapsed << "us"
<< " > " << max_exec_time_ * 1000 << "us";
}
}
通过 Telnet 可以查看模块的执行统计:
$ telnet localhost 8800
> cd modules/perception
> exec_stat
name: perception
tick_count: 12345
avg_exec_time: 234 us
max_exec_time: 567 us
min_exec_time: 120 us
7. 与 CyberRT 调度的对比
| 维度 | HyperFlow | CyberRT |
|---|---|---|
| 协程库 | marl | Croutine |
| 调度策略 | 定时 / 事件驱动 / 混合 | Classic / Choreography |
| 优先级 | 不支持 | Choreography 支持 |
| CPU 亲和 | 不支持 | Choreography 支持 |
| DAG 感知 | 不支持 | 支持(通过 DAG 配置) |
| 模块间协作 | Trigger (Notify/Listen) | Channel + Event |
| 跨进程通知 | POSIX 实时信号 | SHM Transport |
| 线程模型 | marl M:N | Croutine M:N + 调度线程 |
CyberRT 的 Choreography 策略确实更强------它可以根据优先级和 CPU 亲和性安排协程到特定核心,对延迟敏感的模块(如控制)给更高优先级。
HyperFlow 的取舍:优先级和 CPU 亲和是"好东西",但实现复杂度高,且大多数场景下通过合理的 interval 配置 + 事件驱动就能满足延迟要求。如果后续确实需要,可以在此基础上扩展。
8. 踩坑总结
- marl 的 bind/unbind :每个线程使用 marl 前必须
scheduler.bind(),否则marl::Event::wait()会死锁 - 协程中不能调用阻塞操作 :
sleep()、mutex::lock()等会阻塞 OS 线程,导致同一线程上的其他协程也无法运行。应该使用marl::Event::wait_for()代替sleep() - 信号处理线程安全 :
sigwait必须在sigaction之前设置,否则信号可能被默认处理器捕获 - Trigger 的命名冲突 :不同模块的
notify名称不能重复,否则TriggerManager会混淆 - Step 中的长耗时操作 :如果
Step()执行时间超过interval,模块会自动跳过下一个调度周期,不会堆积
9. 小结
调度层是自动驾驶中间件的"心脏"。HyperFlow 的设计选择了轻量 + 灵活的路线:
- marl 协程:M:N 调度,轻量切换
- 双模式调度:定时 + 事件驱动,覆盖绝大多数场景
- Trigger 机制:简洁的模块间协作,支持跨进程
- 插件架构:模块与框架解耦,可独立编译
这不是唯一的方案,CyberRT 的优先级调度在复杂场景下更优。但对于大多数自动驾驶项目来说,HyperFlow 的方案已经足够,且实现和维护成本低得多。
下一篇,我们将聊自动驾驶算法调试的核心基础设施------数据录制与回灌。
下期预告:《从零搭建自动驾驶中间件(四):数据录制与回灌------算法调试的核心基础设施》