机器人控制中的锁、无锁队列与双缓冲如何选择?从数据一致性到执行时间可预测性
在 ROS 2 与 EtherCAT 组成的机器人控制系统中,有一个看似普通、实际上非常关键的问题:上层线程计算出的目标位置,应该如何安全地交给底层实时控制线程?
例如,一台机械臂需要以 1 ms 的周期读取关节目标、获取编码器反馈、计算控制输出并更新 EtherCAT PDO。与此同时,ROS 2 回调可能正在接收新的轨迹,状态估计线程正在更新关节状态,诊断线程还需要读取驱动器的运行信息。
这些线程可能同时访问同一份数据。如果没有同步机制,就可能发生数据竞争;如果使用互斥锁,又可能让实时控制线程等待其他线程释放资源;如果改用无锁队列或双缓冲,则需要重新考虑数据一致性、缓存容量、内存顺序和数据新鲜度。
因此,实时控制系统中的数据交接不能简单地归结为“锁慢、无锁快”。真正需要回答的是:在什么数据访问模式下,应该使用什么同步机制,才能同时满足线程安全、实时性和控制逻辑的要求?
本文结合 ROS 2、EtherCAT 和望获OS实时应用的常见设计场景,从互斥锁、单生产者单消费者队列、双缓冲和原子操作四种思路出发,讨论如何为机器人控制系统选择合适的数据交接方案。
一、为什么机器人实时控制中的数据交接如此重要?
1. 多线程共享数据,不只是“读写冲突”这么简单
在机器人控制程序中,常见的共享数据包括:
-
关节目标位置、速度和力矩。
-
编码器反馈和驱动器状态。
-
轨迹采样点与插补结果。
-
机器人运行模式和控制参数。
-
EtherCAT 从站状态、错误码与通信诊断信息。
-
上层 ROS 2 节点下发的控制指令。
假设 ROS 2 回调正在更新六轴机械臂的目标位置,而 EtherCAT 实时线程同时读取这些数据。
如果没有适当的同步机制,实时线程可能读取到一部分新值和一部分旧值。例如,前几个关节已经更新,后几个关节还没有更新,最终得到一组并不对应同一时刻的目标。
这类问题不一定导致程序崩溃,却可能影响控制逻辑的正确性。
在 C++ 中,如果多个线程并发访问同一内存位置,且至少一个线程执行写操作,并且不存在适当的同步关系,还可能构成数据竞争,导致未定义行为。因此,不能仅仅因为变量是整数、浮点数或结构体,就认为并发读写一定安全。
2. 为什么实时线程尤其不希望等待数据?
假设 EtherCAT 控制线程的周期为 1 ms。它在每个周期内需要完成反馈读取、控制计算和过程数据更新。
如果线程在获取目标数据时,需要等待 ROS 2 回调释放互斥锁,那么等待时间就会占用当前周期的时间预算。
即使控制算法本身只需要一小段时间执行,锁等待也可能使整个周期任务延迟完成。
例如:
| 控制环节 | 示例耗时 |
|---|---|
| 读取反馈数据 | 80 μs |
| 获取目标数据 | 120 μs |
| 控制算法计算 | 200 μs |
| 更新过程数据 | 100 μs |
| 其他处理 | 100 μs |
| 合计 | 600 μs |
以上数字仅用于说明时间预算的计算方式,并非任何实际平台的测试结果。
如果其中的“获取目标数据”包含不可预测的等待时间,整个任务的完成时刻就可能出现波动。即使平均耗时很低,也不能据此证明最坏情况下能够按时完成。
因此,对于高频实时任务,数据交接方案不仅要保证数据正确,还应尽量让关键路径的执行时间具有可分析性。
3. 先明确数据交接的语义,再选择技术方案
选择同步机制之前,建议先回答三个问题。
第一,消费者需要什么数据?
-
只需要最新的目标值。
-
必须按照顺序处理每一条轨迹采样。
-
需要读取一组相互一致的状态数据。
-
需要保证每个故障事件都被处理。
第二,允许丢弃旧数据吗?
如果控制线程只需要最新目标位置,较早的目标可能已经失去价值。如果消费者需要执行完整轨迹或记录故障事件,就不能随意丢弃中间数据。
第三,生产者与消费者的数量是多少?
一个上层线程向一个控制线程传递数据,与多个 ROS 2 回调同时向同一个控制线程传递数据,具有不同的同步要求。
这三个问题会直接影响互斥锁、队列和双缓冲的选择。没有任何一种数据结构能够在所有场景中同时做到最简单、最低延迟和最容易证明正确。
二、互斥锁:实现简单,但必须控制实时线程的等待风险
互斥锁是保护共享数据最直接的方式之一。它可以确保同一时刻只有一个线程进入受保护的临界区,适合多个线程需要访问同一份可变数据的场景。
1. 什么时候使用互斥锁比较合适?
例如,一个机器人状态管理模块维护一份包含运行模式、故障码和配置版本的状态结构体。多个普通线程需要修改这些字段,而诊断线程需要读取一致的状态。
使用互斥锁可以将这些字段作为一个整体进行保护,减少并发访问造成的数据不一致。
示意代码如下:
C++
#include <mutex>
struct RobotState {
int mode;
int fault_code;
unsigned int config_version;
};
std::mutex state_mutex;
RobotState state{};
// 写线程
void update_state(const RobotState& next)
{
std::lock_guard<std::mutex> lock(state_mutex);
state = next;
}
// 读线程
RobotState read_state()
{
std::lock_guard<std::mutex> lock(state_mutex);
return state;
}
这段代码用于说明互斥锁保护共享结构体的基本方法。实际工程中还需要处理状态初始化、数据有效性、错误恢复和对象生命周期等问题。
如果这份状态数据只由低频诊断线程和普通管理线程使用,互斥锁通常是一种容易理解和维护的选择。
但如果 read_state() 位于 EtherCAT 周期控制线程的关键路径中,就必须进一步分析它可能等待多久,以及锁的持有者是否会执行不确定耗时的工作。
2. 为什么不能把所有共享数据都用一把大锁保护?
一种看起来简单的设计,是将机器人所有状态、目标位置、轨迹信息和诊断数据都放在一个大结构体中,并用一把互斥锁统一保护。
这种设计虽然容易实现,但可能导致不必要的竞争。
例如,诊断线程只需要读取故障码,却可能因为另一个线程正在更新轨迹数据而等待同一把锁。若实时控制线程也使用这把锁,那么与控制无关的工作就可能影响周期执行。
更合理的方式,是根据数据的所有权、访问频率和一致性要求拆分共享资源。
不过,拆分锁也不是越多越好。过多的锁会增加锁顺序管理、死锁排查和维护的复杂度。关键是找到合理的保护边界,而不是单纯追求锁的数量最少或最多。
3. 如何让互斥锁更适合实时应用?
如果实时线程确实需要使用互斥锁,可以重点关注以下原则:
-
临界区只执行必要的内存读写。
-
不在持锁期间执行文件 I/O、网络请求或阻塞式等待。
-
避免在临界区中执行复杂计算。
-
明确锁的获取顺序,减少嵌套锁。
-
根据目标平台能力评估适当的优先级继承协议。
-
测量实际锁等待时间,而不是只看代码行数。
对于支持 POSIX 优先级继承协议的互斥锁,可以考虑 PTHREAD_PRIO_INHERIT,以缓解某些优先级反转问题。但它不能消除所有阻塞,也无法让无界临界区自动变成有界临界区。
互斥锁并不是实时系统的禁区。真正需要避免的是,让关键周期线程的执行时间依赖于其他线程不可预测的行为。
三、无锁队列:适合连续数据传递,但要先确认生产者与消费者模型
当机器人控制系统需要连续传递目标位置、轨迹采样点或传感器数据时,可以考虑使用队列。
队列与普通共享变量的区别在于,它可以表达数据的先后顺序,并通过固定容量和明确的满载策略控制数据积压。
如果采用合适的实现方式,单生产者单消费者(SPSC)无锁队列可以减少线程之间对互斥锁的依赖。
1. 什么情况下适合使用 SPSC 队列?
SPSC 队列适用于只有一个生产者线程和一个消费者线程的场景。
例如:
-
轨迹生成线程生产轨迹采样点。
-
EtherCAT 实时线程按周期消费采样点。
如果轨迹必须按顺序执行,队列可以保存一系列待执行的采样点,让控制线程按顺序获取。
与只保存最新值的方案相比,队列更适合需要保留连续数据的业务。
但如果多个 ROS 2 回调都可能写入同一队列,那么它就不再是单生产者模型。此时需要使用经过验证的多生产者实现,或者在非实时侧先完成数据汇聚,再由单一生产者写入 SPSC 队列。
不能因为某个队列被称为“无锁队列”,就默认它支持任意数量的生产者和消费者。
2. 固定容量队列为什么对实时系统有价值?
如果实时线程需要在每个周期内读取数据,使用固定容量队列有利于控制内存使用量,并避免在关键路径中进行动态内存分配。
固定容量队列也能明确数据积压的上限。
但队列容量并不是越大越好。
如果生产速度长期高于消费速度,即使队列很大,积压仍然可能持续增长到容量上限。此时系统必须明确如何处理满队列情况。
常见策略包括:
-
拒绝新数据并报告溢出。
-
在允许丢弃旧数据的场景中保留最新值。
-
在非实时侧进行限流或调整生产速度。
-
对必须完整执行的轨迹,停止接受新的轨迹,直到缓冲区恢复可用。
具体策略取决于数据语义。对于必须完整执行的运动轨迹,随意覆盖旧采样点可能破坏轨迹连续性;对于只需要最新设定值的状态更新,保留最新值则可能更合理。
3. 一个简化的 SPSC 队列示意
下面的代码用于展示单生产者与单消费者队列的基本思路,不是经过完整验证的生产级无锁队列实现。
C++
#include <array>
#include <atomic>
#include <cstddef>
template <typename T, std::size_t N>
class SpscQueue {
public:
bool push(const T& value)
{
const auto head = head_.load(std::memory_order_relaxed);
const auto next = (head + 1) % N;
if (next == tail_.load(std::memory_order_acquire)) {
return false; // 队列已满
}
buffer_[head] = value;
head_.store(next, std::memory_order_release);
return true;
}
bool pop(T& value)
{
const auto tail = tail_.load(std::memory_order_relaxed);
if (tail == head_.load(std::memory_order_acquire)) {
return false; // 队列为空
}
value = buffer_[tail];
tail_.store((tail + 1) % N, std::memory_order_release);
return true;
}
private:
std::array<T, N> buffer_{};
std::atomic<std::size_t> head_{0};
std::atomic<std::size_t> tail_{0};
};
这段示意代码采用环形缓冲区结构,通过原子索引发布数据。由于实现采用“空一格”来区分队列为空与队列已满,实际可用容量为 N - 1,而不是 N。
此外,真正用于实时控制前,还需要确认以下事项:
-
N是否大于 1,且容量是否满足业务要求。 -
生产者和消费者是否严格各只有一个。
-
T的复制和赋值是否具有适当的执行时间特征。 -
原子类型在目标处理器和编译器实现上是否 lock-free。
-
缓存行竞争是否影响性能。
-
队列溢出后系统如何处理。
-
数据在停止、重启和异常恢复时如何管理。
尤其需要注意:C++ 的 std::atomic 并不保证所有类型和所有平台上的操作都一定是无锁的。应根据目标实现检查 is_lock_free() 等能力,并通过测试验证实际行为。
这段代码的意义在于展示数据发布与消费的基本结构,而不是保证它适用于所有望获rtLinux 目标设备。
4. 为什么无锁队列也不能自动保证实时性?
无锁队列能够减少某些锁竞争,但它仍然会执行数据复制、原子操作和内存访问。
如果数据结构设计不合理,或者队列操作涉及复杂的对象构造、内存分配和非确定性工作,关键路径仍可能出现明显波动。
同时,无锁并不等于 wait-free。某些无锁算法可以保证系统整体取得进展,但不一定保证每一个线程都能在固定步骤内完成操作。
对于严格的周期控制任务,应该优先选择结构简单、操作路径可分析、容量固定且经过目标平台测试的数据交接实现。
四、双缓冲:只需要最新一份数据时,可以减少不必要的等待
机器人控制系统并不总是需要按顺序消费每条数据。
例如,ROS 2 上层线程可能不断更新目标位置,而底层控制线程每个周期只需要获得一组有效的最新目标。
在这种情况下,如果每一条旧目标都被放入队列,控制线程可能需要消费过期数据,反而增加延迟。
双缓冲是一种常见的思路:准备两个数据缓冲区,一个用于发布新数据,另一个用于消费者读取,从而减少生产者与消费者直接争用同一份数据的机会。
1. 双缓冲适合什么场景?
双缓冲可以用于:
-
最新目标位置和速度的发布。
-
机器人运行参数快照。
-
经过统一计算的控制设定值。
-
一组需要保持内部一致性的状态数据。
例如,上层线程计算出六轴目标位置后,可以将六个关节的目标作为一个完整结构体发布,而不是分别更新六个独立变量。
这样,消费者获得的目标更容易保持一致。
不过,双缓冲的关键不是“有两个数组”,而是如何安全地协调生产者写入、数据发布和消费者读取。
2. 双缓冲需要保证什么?
假设系统使用两个缓冲区 A 和 B。
生产者在准备新数据时,不能修改消费者正在读取的那一份数据;消费者也不能在生产者正在写入时读取尚未完成的数据。
因此,需要有明确的发布机制,保证消费者只读取已经完整写入的版本。
常见的设计方向包括:
-
使用原子索引或版本信息发布当前有效缓冲区。
-
在发布前完成所有字段的写入。
-
明确生产者何时可以重新使用旧缓冲区。
-
确保消费者读取期间,生产者不会覆盖正在读取的数据。
最后一点非常重要:只用一个原子索引切换缓冲区,并不一定足以保证双缓冲安全。
如果生产者切换到另一个缓冲区后,又迅速开始覆盖旧缓冲区,而消费者还没有完成读取,就可能产生数据竞争。
因此,双缓冲实现必须包含完整的所有权或同步规则,而不是仅仅切换一个索引。
3. 为什么不能直接使用一个原子索引就结束?
考虑下面这个概念性设计:
C++
// 概念示意,不可直接用于生产环境
buffers[write_index] = next_data;
published_index.store(write_index);
write_index = 1 - write_index;
这里虽然通过原子变量发布了索引,但仍然存在一个核心问题:生产者什么时候可以安全地重新写入旧缓冲区?
如果消费者尚未完成读取,生产者就重新使用同一缓冲区,那么双方仍然可能同时访问同一份数据。
这也是为什么双缓冲在单生产者、单消费者场景中看起来很简单,但在实际并发实现中仍然需要仔细设计。
可选的解决思路包括使用明确的缓冲区所有权协议、版本校验和重试机制,或选择已经经过充分验证的实时数据交接库。
不同方案在内存占用、重试次数和最坏执行时间方面存在差异。对于硬实时关键路径,必须考虑消费者是否可能反复重试,以及这种重试是否存在明确上界。
4. 双缓冲与队列的关键区别
| 对比维度 | 双缓冲 | 队列 |
|---|---|---|
| 主要目标 | 发布一份完整、较新的数据快照 | 保存有顺序的数据项 |
| 是否适合只取最新值 | 通常适合 | 可以实现,但可能需要丢弃旧数据 |
| 是否保留中间数据 | 通常不保证 | 可以保留,直到容量耗尽 |
| 主要风险 | 缓冲区所有权与并发读写 | 队列满载、积压和消费延迟 |
| 适用场景 | 目标值、状态快照 | 轨迹采样、事件流 |
如果实时控制线程只需要最新目标,双缓冲或经过验证的快照机制可能更合适。如果必须按顺序处理所有轨迹点,则应优先考虑具有明确容量与溢出策略的队列。
五、如何为望获rtLinux 实时应用选择方案?从数据语义到测试验证
在望获rtLinux 等实时运行平台上选择数据交接机制,建议按照“数据语义—线程模型—执行时间—正确性验证”的顺序进行,而不是单纯比较某个方案的理论性能。
1. 先按业务需求建立选择规则
下面是一份适合初步架构设计的参考表。
| 数据交接需求 | 优先评估的方案 | 重点关注 |
|---|---|---|
| 低频、非关键状态共享 | 互斥锁 | 正确性、维护成本 |
| 单线程向单线程连续传递轨迹点 | SPSC 固定容量队列 | 队列满载、数据顺序 |
| 只需要最新目标或完整状态快照 | 双缓冲或经过验证的快照机制 | 一致性、缓冲区所有权 |
| 单个标志位或简单计数器 | 原子变量 | 内存顺序、操作是否 lock-free |
| 多生产者向单个控制线程汇聚数据 | 先汇聚后发布,或经过验证的多生产者结构 | 竞争、复杂度、可分析性 |
| 故障、急停等关键事件 | 明确的事件传递与安全响应机制 | 不丢失、优先级、故障处理时限 |
这张表只是方案筛选的起点。实际设计还需要结合数据更新频率、线程数量、是否允许丢弃数据,以及控制安全要求进行评估。
特别是故障和急停相关信息,不能仅仅因为它们也是一份共享数据,就套用普通目标位置的更新策略。它们通常需要单独定义检测、传递、响应和故障恢复流程。
2. 避免把复杂的业务逻辑放进数据交接路径
无论选择哪一种同步机制,都应尽量避免在实时控制线程中执行与当前周期无关的工作。
例如,控制线程从队列取出轨迹采样点后,可以进行必要的插补与控制计算,但不应在同一条关键路径中执行复杂日志格式化、动态内存分配、网络通信或长时间等待。
对于参数更新,可以由上层线程完成配置解析、范围检查和数据准备,再将已经验证的参数快照交给底层线程。
这样可以减少关键线程承担的非确定性工作。
3. 线程隔离与数据交接必须配合设计
即使底层 EtherCAT 控制线程已经绑定到专用 CPU,如果它仍然需要等待 ROS 2 回调释放互斥锁,核心隔离也无法消除这类应用层资源阻塞。
同样,即使使用了无锁队列,如果生产者在另一个 CPU 上产生大量内存访问,仍然可能通过共享缓存和内存带宽对控制任务产生影响。
因此,实时数据交接需要与线程优先级、CPU 亲和性、核心隔离和中断分布一起考虑。
对于望获rtLinux 的具体项目,应在目标硬件、实际内核配置和真实应用负载下验证整体设计,而不是把某一种同步原语视为实时性的充分条件。
4. 建立可以复现的数据交接测试
建议至少测试以下场景:
正常负载: 按预期频率更新目标数据,观察控制周期和数据一致性。
突发负载: 提高上层目标更新频率,观察队列积压、数据覆盖和消费者延迟。
生产者过载: 让生产速度暂时高于消费速度,验证队列满载或数据覆盖策略是否符合设计要求。
并发压力: 增加 ROS 2 回调和状态更新任务,观察实时线程是否出现等待。
异常恢复: 模拟生产者退出、数据无效或通信异常,检查控制线程如何处理旧数据和失效状态。
需要记录的指标包括:
-
数据交接操作的执行时间。
-
关键线程的等待时间。
-
控制周期执行时间与超期次数。
-
队列的最大积压和溢出次数。
-
数据版本、时间戳和数据新鲜度。
-
异常情况下的恢复行为。
如果使用无锁队列或双缓冲,还应通过并发测试和代码审查验证其内存模型、缓冲区所有权及数据生命周期。性能测试不能替代线程安全正确性验证。
5. 不要只比较平均耗时
在实时控制中,平均耗时较低并不一定代表方案更合适。
一个方案平均执行时间很短,但在某些并发条件下需要反复重试,可能仍然不适合严格的周期任务。另一个方案的平均耗时稍高,但执行路径简单、最坏阻塞容易分析,反而可能更适合关键控制线程。
因此,比较方案时,应结合执行时间分布、观测最大值、压力场景、超期次数和数据正确性进行综合评估。
观测到的最大值也不等于数学意义上的最坏情况执行时间。测试只能覆盖特定的硬件、软件配置和负载范围,不能替代对算法上界、资源依赖和异常路径的分析。
总结:不要为了“无锁”而无锁,要围绕实时数据语义做设计
在机器人控制系统中,互斥锁、无锁队列和双缓冲分别适合不同的数据交接场景。
互斥锁适合需要明确保护共享数据的情况,但必须关注临界区长度与阻塞风险;SPSC 队列适合单生产者、单消费者之间有序传递连续数据,但必须定义容量与溢出策略;双缓冲适合发布最新的完整数据快照,但必须保证缓冲区所有权和并发读写安全。
对于 ROS 2 + EtherCAT 系统,更合理的工程思路是:先确定实时线程需要什么数据,再确定数据是否允许丢弃、是否必须有序、生产者和消费者分别是谁,最后选择可验证的同步机制。
在望获rtLinux 等实时运行平台上,还应将数据交接与线程优先级、核心隔离和实际控制周期结合起来验证。真正需要优化的不是某个单独函数的执行速度,而是整个控制数据链路的正确性、响应时间和可预测性。
好的实时架构不是让所有操作都变快,而是让关键控制路径尽可能少等待、少承担不确定工作,并且始终能够正确地获得所需数据。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐



所有评论(0)