在 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 等实时运行平台上,还应将数据交接与线程优先级、核心隔离和实际控制周期结合起来验证。真正需要优化的不是某个单独函数的执行速度,而是整个控制数据链路的正确性、响应时间和可预测性。

好的实时架构不是让所有操作都变快,而是让关键控制路径尽可能少等待、少承担不确定工作,并且始终能够正确地获得所需数据。

Logo

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

更多推荐