16. 实时系统简介

本文简要介绍了实时计算的要求和实现实时性能的方法。

作者:杰基·凯(Jackie Kay)

撰写日期:2016-01

最后修改:2016-05

本文旨在概述实时计算的各项要求和实现实时性能的各种挑战。本文还列出了构建强制执行实时兼容性的ROS 2的可选策略。

机器人系统需要进行响应。在关键任务应用程序中,系统中不到一毫秒的延迟可能会导致灾难性的故障。为满足机器人社区的需求,ROS 2核心软件组件不能妨碍实时计算的各项要求。

16.1 实时计算的定义

在对实时计算进行定义之前,需要定义其他几个关键术语:

● 确定性(Determinism):如果一个系统始终会为某个已知输入产生相同的输出,则该系统是确定性的。非确定性系统的输出具有随机变化特征。

● 截止时间(Deadline):截止时间就是必须完成某项任务的有限时间窗口。

● 服务质量(QoS,Quality of Service):指一个网络的整体性能,包括带宽、吞吐量、可用性、抖动(jitter)、延迟和错误率等因素。

实时软件会保证在正确的时间进行正确的计算。

硬实时(Hard real-time)软件系统有一组严格的截止时间,且错过一个截止时间就会认为系统失败。硬实时系统的示例包括:飞机传感器和自动驾驶系统、航天器和行星探测器。

软实时(Soft real-time)系统会试图满足截止时间要求,但如果错过了某个截止时间也不会认为系统失败。但是,在这样一个事件中,软实时系统可能会降低其服务质量以改进其响应能力。软实时系统的示例包括:用于娱乐的音频和视频传输软件(延迟是不可取的,但不是灾难性的)。

准实时(Firm real-time)系统会将截止时间之后交付的信息/计算视为无效。与软实时系统一样,准实时系统在错过某个截止时间后不会认为系统失败,并且如果错过了某个截止时间,准实时系统可能会降低服务质量(QoS)[1]。准实时系统的示例包括:财务预测系统、机器人装配线[2]。

实时计算机系统通常与低延迟系统相关联。许多实时计算的应用程序也是低延迟的应用程序(例如,自动驾驶系统必须对环境中的突然变化做出反应)。然而,实时系统并不是由低延迟来定义的,而是由确定性调度(deterministic schedule)来定义的,即:必须保证系统在某个时间内完成某项任务。因此,重要的是系统中的延迟是可测量的,且要设置各项任务的最大允许延迟。

实时计算机系统同时需要实时运行的操作系统和提供确定性执行的用户代码。非实时操作系统上的确定性用户代码和实时操作系统上的非确定性用户代码都不会产生实时性能。

实时环境的一些示例包括:

● RT_PREEMPT Linux内核补丁,该内核补丁会将Linux的调度器修改为完全可抢占[3]。

● Xenomai,一个符合POSIX标准的协同内核(或管理程序),是一个可以提供与Linux内核协作的实时内核。Linux内核会被视为实时内核调度器的空闲任务(最低优先级任务)。

● RTAI,一个协同内核(co-kernel)的替代解决方案。

● QNX Neutrino,一个符合POSIX标准、适用于关键任务系统的实时操作系统。

16.2 实时计算的最佳做法

一般来说,操作系统可以保证它为开发人员处理的任务(例如线程调度)是确定性的,但操作系统可能无法保证开发人员的代码会实时运行。因此,开发人员需要知道某个现有系统的确定性保证内容是什么,以及他(她)必须做什么事情才能在操作系统之上编写硬实时代码。

本节将会探讨在实时操作系统之上进行开发的各种策略,因为这些策略可能适用于ROS 2。这些模式专注于在基于Linux的实时操作系统上进行C/C++开发的用例(如RT_PREEMPT),但其一般概念也适用于其他平台。大多数模式都专注于操作系统中阻塞调用的解决方法,因为任何涉及阻塞一段不确定时间的操作都是不确定性的。

一种常见的模式是将实时代码分成以下三个部分:进程开始时的非实时安全部分,包括在堆(heap)上预分配内存、启动线程等;实时安全部分(通常被实现为一个循环);非实时安全的“摧毁”部分,包括根据需要释放内存等。“实时代码路径”是指该执行的中间部分即实时安全部分。

16.2.1 内存管理

正确的内存管理对于实时性能至关重要。一般来说,程序员应该避免实时代码路径中的页面错误。在页面错误期间,CPU会暂停所有计算,并将丢失的页面从磁盘加载到RAM(或缓存或寄存器)中。从磁盘加载数据是一个缓慢且不可预测的操作。但是,内存分页是必要的,否则计算机会耗尽内存。解决方案就是要避免页面错误。

动态内存分配会导致较差的实时性能。调用malloc/new和free/delete函数可能会导致页面错误。此外,堆分配和释放内存块的方式会导致内存碎片,从而导致读取和写入性能不佳,因为操作系统可能必须在不确定的时间内扫描空闲内存块。

(1)锁定内存,故障前内存栈(Lock memory, prefault stack)

if (mlockall(MCL_CURRENT|MCL_FUTURE) == -1) {
  perror("mlockall failed");
  exit(-2);
}
unsigned char dummy[MAX_SAFE_STACK];

memset(dummy, 0, MAX_SAFE_STACK);

函数mlockall()是一个Linux系统调用,用于将进程的虚拟地址空间锁定到RAM中,以防止该进程将要存取的内存被分页到交换空间中。

在一个线程生命周期开始时运行上面这段代码可以确保在该线程运行过程中不会发生页面错误。函数mlockall()会锁定该线程的内存栈(stack)。函数memset()调用会将该内存栈的每一个内存块预加载到缓存中,这样在存取该内存栈时就不会发生页面错误[3]。

(2)分配动态内存池(Allocate dynamic memory pool)

if (mlockall(MCL_CURRENT | MCL_FUTURE))
  perror("mlockall failed:");

/* Turn off malloc trimming.*/
mallopt(M_TRIM_THRESHOLD, -1);

/* Turn off mmap usage. */
mallopt(M_MMAP_MAX, 0);

page_size = sysconf(_SC_PAGESIZE);
buffer = malloc(SOMESIZE);

for (i=0; i < SOMESIZE; i+=page_size) {
  buffer[i] = 0;
}
free(buffer);

本节的引言中指出动态内存分配通常不是实时安全的。而本代码段则说明了如何让动态内存分配变成实时安全(大部分)。该代码段会将虚拟地址空间锁定为固定大小,不允许通过sbrk()将释放的内存返回给内核,并禁用mmap()。这样可以有效地将内存堆(heap)中的内存池锁定到RAM中,从而防止由于malloc()和free()而导致的页面错误[3]。

这种方法的优点为:

● 可以使用malloc/new、free/delete函数,甚至可使用STL容器

其缺点包括:

● 对平台/实现的依赖

● 必须准确预测进程的内存大小界限!

● 因此使用STL容器是危险的(无限的内存大小)

● 在实践中,仅适用于内存占用较小的进程

(3)自定义实时安全内存分配器(allocators)

大多数操作系统上的默认内存分配器并未针对实时安全性进行优化。但是,还有另一种策略是“避免动态内存分配”规则的一个例外。对替代动态内存分配器的研究是一个丰富的研究课题 [8]。

一种这样的替代分配器就是TLSF(两级分离式拟合,Two-Level Segregate Fit)。该分配器也被称为O(1)分配器,因为TLSF之下 malloc、free和align操作的时间成本都具有一个固定的上限。该分配器会产生低级碎片。TLSF的缺点是它不是线程安全的,并且它的当前实现是特定于架构的:它假定系统可以进行4字节对齐访问。

此方法的优点包括:

● 可以在运行时或编译时未知内存边界的程序中安全地分配内存

● 优势因分配器的选择而异

其缺点包括:

● 由于其使用不广泛,自定义内存分配器的实现可能未经良好的测试

● 额外的依赖,可能会增加代码复杂度

● 缺点因分配器的选择而异

(4)全局变量和(静态)数组

全局变量在进程开始时会进行预分配,因此对全局变量进行赋值和访问都是实时安全的。但是,这种策略具有使用全局变量的许多缺点。

(5)指针和vtable访问的缓存友好性

归因于vtable开销访问,具有多个继承层次的类可能不是实时安全的。在执行某个继承的函数时,程序需要访问该函数中使用的数据、该类的vtable以及该函数的指令,它们都存储在内存的不同部分,而且可能一起或者也可能不是一起存储在缓存中[5]。

通常,具有较差缓存局部性的C++模式不太适合于实时环境。另一种这样的模式就是不透明指针习语(PIMPL),它便于 ABI兼容性和加快编译时间。然而,在对象的内存位置和其私有数据指针之间跳动会导致缓存“溢出”,因为它会加载一个内存块,然后为PIMPLized对象中的几乎每个函数都加载另一个不相关的内存块。

(6)异常

处理异常会导致很大的性能损失。运行中遇到异常往往会将大量内存推入软件堆栈,而内存在实时编程中通常是一种有限的资源。但是如果异常使用得当,则它们不应该成为实时程序开发人员的顾虑(因为异常表明程序中的一个位置具有未定义的行为,且其调试是不可或缺的)[6]。

(7)了解你的问题所在

不同的程序具有不同的内存需求,因此内存管理策略因应用程序而异。存在以下三种情形:

● 编译时已知所需内存大小

○ 示例:发布一条固定大小的消息。

○ 解决方案:用固定大小的对象进行内存栈分配。

● 在实时执行之前、运行时已知所需内存大小

○ 示例:发布一条在命令行上指定其大小的消息。

○ 一旦知道所需内存大小,就会在内存堆上预分配可变大小的对象,然后执行实时代码。

● 实时计算所需内存大小

○ 示例:由机器人传感器接收到的一条消息来决定它发布的消息大小。

○ 存在以下多种解决方案:

■ 对象池(Object pools

 TLSF O(1)内存分配

■ 使用内存栈分配,且如果分配的内存超过限度则会失败

16.2.2 设备I/O

与物理设备交互(磁盘I/O、打印输出到屏幕等)可能会在实时代码路径中引入不可接受的延迟,因为进程通常会被迫等待缓慢的物理现象。而且,诸如fopen()等许多I/O调用还会导致页面错误。

要将磁盘读/写放在程序开头或结尾,位于实时(RT)代码路径之外。

启动(Spin up)未在实时中调度的线程以将输出打印到屏幕。

16.2.3 多线程编程和同步

实时计算需求会改变多线程编程的典型范式。程序执行不能异步阻塞,且线程必须确定性地进行调度。实时操作系统会执行这个调度要求,但开发人员仍会陷入各种陷阱。本节提供了避开这些陷阱的指导方针。

(1)线程创建指南

在程序开始时创建线程。这样会将线程分配的不确定性开销限制在进程中某个定义的点。

使用FIFO(先进先出,first-in-first-out)、RB(轮询,Round Robin)或Deadline调度器(scheduler)创建高优先级(但不是99)线程(请参阅POSIX的sched API)。

(2)避免优先级反转(Avoid priority inversion)

优先级反转会发生在具有抢占式任务调度器的系统上并会导致死锁。下面这种情形会发生优先级反转:某个低优先级任务获得了一个锁,且随后被一个中等优先级任务抢占,然后一个高优先级任务获得了该低优先级任务持有的锁。

这三个任务会卡在一个三角形中:高优先级任务被低优先级任务所阻塞,低优先级任务因被更高优先级任务抢占而被中优先级任务所阻塞,而中优先级任务也被更高优先级的任务所阻塞。

以下是优先级反转的一些解决方案:

● 不要用会阻塞的同步原语(blocking synchronization primitives)

● 对持有锁的任务禁用抢占功能(会导致抖动)

● 提高持有锁的任务的优先级

● 使用优先级继承:拥有锁的任务继承试图获取该锁的任务的优先级

● 使用无锁数据结构和算法

(3)计时开枪(Timing shots)

一种实时同步技术是当线程计算其下一次“开枪(shot)”(即该线程下一个执行周期的开始)时。例如,如果一个线程需要每10毫秒更新一次,并且该线程必须完成一个需要3-6毫秒的操作,则该线程应该获取该操作前的时间,执行该操作,然后基于该操作后测量的时间等待剩余的7-4毫秒。

开发人员最重要的考虑是在等待时使用一个高精度计时器,例如Linux平台上的nanosleep。否则,系统就会经历漂移。

(4)自旋锁(Spinlocks)

自旋锁易于导致时钟漂移(clock drift)。开发人员应避免实现自己的自旋锁。RT Preempt补丁用互斥锁(mutexes)替换了系统内核的大部分自旋锁,但这一点可能无法在所有平台上得到保证。

(5)避免分叉(fork)

fork不是实时安全的,因为它是使用写时复制(copy-on-write)技术实现的。这意味着当一个分叉进程修改一个内存页面时,它会获得该内存页面其自己的副本。这会导致页面错误!

实时编程中应避免页面错误,因此应避免Linux的fork以及调用fork的程序。

16.3 测试和性能基准测试

16.3.1 cyclictest

cyclictest是一个简单的用于测量实时环境抖动的Linux命令行工具。它将多个线程、各线程的优先级以及调度器类型作为输入。它会启动n个定期休眠的线程(也可以从命令行指定休眠周期)[7]。

对于每个线程,cyclictest会测量线程应该唤醒和实际唤醒之间的时间。这种延迟的可变性是系统中的调度抖动。如果系统中正在运行具有非确定性阻塞行为的进程,则平均延迟会增长到一个很大的数字(以毫秒为单位),因为调度器无法满足程序中配置的周期性休眠线程的截止时间。

16.3.2 用于测试的测量代码

测量某个程序中调度抖动的一种更精确的方法是测量现有代码的周期性实时更新循环来记录调度抖动。

可以在此处找到用于实时代码测量的最小软件库的建议头文件:rttest.h

16.3.3 页面错误

Linux系统的getrusage调用会返回与实时性能相关的许多资源使用事件的统计信息,例如次要和主要页面错误、交换分区(swaps)和内存块I/O。该调用可以检索整个进程或某个线程的这些统计信息。因此,测量代码以检测这些事件很简单。特别是,getrusage应在代码的实时部分之前和之后进行调用,且应该比较这些结果,因为getrusage会收集有关线程/进程的整个持续时间的统计信息。

收集这些统计数据可以指明哪些事件会导致前述方法测量的延迟/调度抖动。

16.4 ROS 2的设计指导方针

16.4.1 实现策略

通过明智地应用本文中提出的性能模式和基准(benchmarking)测试,用C/C++语言实现实时代码是可行的。ROS 2如何实现实时兼容性的问题仍然存在。

ROS节点生命周期的创建(setup)阶段和摧毁(teardown)阶段不具有实时安全性是可以接受的。但是,与ROS接口的交互,尤其是在进程内(intra-process)环境中的接口交互应该是实时安全的,因为这些动作可能是在某个进程的实时代码路径上。

现有ROS 2代码与未来ROS 2代码的实时“硬化”可能策略有以下几种:

● 为软件堆栈创建一个配置选项以在“实时友好”模式下运行。

○ 此策略的优点是:

■ 允许用户在实时和非实时模式之间动态切换。

○ 此策略的缺点是:

■ 重构的开销。实时代码与现有代码的集成可能会很棘手。

● 实现一个新的、以实时计算思路进行设计的实时软件堆栈(如rclrt、rmwrt等)。

○ 此策略的优点包括:

■ 更易于设计和维护。

■ 实时代码与现有代码“隔离”。可以针对实时应用全面优化软件库。

○ 此策略的缺点包括:

■ 需要编写和维护更多软件包。

■ 对用户来说可能更不方便。

● 为软件堆栈中的某个点提供实时安全选项,并实现实时安全语言封装器(如rclrt或rclc等)。

○ 此策略的优点包括:

■ 现有代码旨在使这种重构相当容易

■ 用户可为rcl/rmw提供内存分配策略以保证确定性操作

■ 同步发生在语言/操作系统特定层之上,因此重构rcl/rmw更加容易

■ 可能更容易支持使用不同封装器的多个嵌入式平台

○ 此策略的缺点包括:

■ 需要更多代码以测试实时安全性

■ 为用户-开发人员提供的更多灵活性可能意味着更高的复杂性

第三个选项最吸引人,因为该策略可以用最少的工作量获得最多的好处。

16.5 参考资源(Sources)

1. 斯蒂芬·彼得斯,实时系统演示(Stefan Petters, Presentation on Real-Time Systems

2. Stack Overflow,硬实时、软实时和准实时之间的区别(Stack Overflow, Differences between hard real-time, soft real-time, and firm real-time

3. 实时Linux维基网页(Real-Time Linux Wiki

4. 维基百科,实时操作系统(Real-time operating system, Wikipedia

5. 斯科特·沙门,如何使C++更加实时友好(Scott Salmon, How to make C++ more real-time friendly

6. Stack Overflow,实时环境中仍然不希望出现异常吗?(Stack Overflow, Are Exceptions still undesirable in Realtime environment?

7. 帕维尔·莫瑞克,实时Linux操作系统下的任务抖动测量(Pavel Moryc, Task jitter measurement under RTLinux operating system

8. 阿方斯·克雷斯波、伊斯梅尔·里波尔、米格尔·马斯马诺,嵌入式实时系统的动态内存管理(Alfons Crespo, Ismael Ripoll and Miguel Masmano, Dynamic Memory Management for Embedded Real-Time Systems

9. 米格尔·马斯马诺,TLSF:一种新的实时系统动态内存分配器(Miguel Masmno, TLSF: A New Dynamic Memory Allocator for Real-Time Systems

*英语原文网址:design.ros2.org/article