Redis on the Raspberry Pi: adventures in unaligned lands

Salvatore Sanfilippo

在树莓派上运行 Redis:非对齐内存访问世界的冒险

树莓派已售出 1000 万台,拥有几乎无穷无尽的各种应用和外围设备,比如传感器和显示器,我认为完全可以说,它不仅是一个成功的产品,还成为了程序员在嵌入式领域进行实验的首选平台之一。大概凭借 Pi zero 这类产品,它也正在成为打造硬件产品的平台,从而避免了为垂直设备进行设计、制造和编写软件所带来的全部风险和成本。

嗯,我乐于认为 Redis 同样是程序员喜欢用来折腾、实验、构建新事物的平台。此外,可用于嵌入式/IoT 应用的设备常常面临一个问题:需要在设备上临时或永久地存储数据(例如传感器接收到的数据),以便进行设备端计算或将其发送到远程服务器。Redis 正在添加一种专门适合数据流和时间序列存储的“Stream”数据类型,目前规范已接近完成,实现工作将在接下来几周内开始。Redis 现有的数据结构加上新的 stream,再加上其极小的内存占用、即使在小型硬件上也能提供的不错性能(以及由此带来的低能耗),看起来与树莓派的潜在应用场景非常契合,总体上也适合小型 ARM 设备。缺失的一环显而易见:让它在树莓派上良好运行。

树莓派的众多酷炫之处之一是,它的开发环境看起来并不像几年前的嵌入式开发环境……它就是直接跑 Linux,带有你所期望的所有 Debian 风格的工具链。基本上,让 Redis 适配树莓派并不是一项艰巨的任务。一个 Linux 系统程序与树莓派之间最根本的不匹配可能是性能/资源占用上的不匹配,但由于 Redis 自身的设计,这不成问题:一个空实例的总常驻内存(Resident Set Size)只有 1MB,从内存中响应查询,因此速度足够快,也不会对闪存盘造成太大压力;当需要持久化时,它使用具有追加写入模式的 AOF。然而树莓派使用的是 ARM 处理器,这在处理非对齐访问时需要格外小心。

在这篇博客文章中,我将一边展示我为让 Redis 和树莓派更愉快地合作所做的工作,一边尝试概述如何应对那些不像 x86 平台那样透明处理非对齐访问的架构。

关于 ARM 处理器的几点知识

将 Redis 移植到 ARM 上最有意思的一点是,ARM 处理器——或者说曾经——不太喜欢非对齐内存访问。如果你一直生活在高层编程的世界里,你可能不知道这一点,但许多处理器架构在历史上都无法在不是字长整数倍的地址上加载或存储内存字。所以如果字长是 4 字节(32 位处理器的情况),你可以在地址 0x4、0x8 等处加载或存储一个字,但不能在地址 0x7 处这样做。结果有时是一个异常,有时则是一些奇怪的行为,取决于 CPU 及其具体配置。

后来 x86 处理器家族统治了世界,大家差不多都忘了这个问题(除了处理 SSE 指令之类的时候,不过现在连那些指令也有了非对齐变体)。哦对了,最初其实并不是真的“忘了”这个问题。即使 x86 处理器可以在不引发异常的情况下处理非对齐访问,这样做也会带来不小的性能损失:跨越字边界的部分读/写需要做双倍的工作。但近期的 x86 处理器有了优化,使得非对齐访问在大多数情况下和对齐访问一样快,所以如今对 x86 来说这真的不再是个问题。

直到 ARM v5 为止,ARM 都属于那种非对齐访问会导致奇怪结果的平台之一,而且实际上是非常出乎意料的结果。ARM 官方文档写道:“如果地址不是四的倍数,LDR 指令会返回一个旋转后的结果,而不是执行真正的非对齐字加载。通常,这种旋转并不是程序员所期望的。”哦,*绝对*不是程序员所期望的。不过就连最初的树莓派也配备了 ARM v6 处理器。v6 虽然会有性能损失,但能够处理字大小的非对齐访问。然而涉及多个字的指令会引发异常,以 SIGBUS 信号终止程序,或者向内核求助(我们稍后会详细讨论)。这意味着 Redis 在树莓派上不会立刻崩溃得一塌糊涂,因为 Redis 执行的大多数非对齐访问实际上都是字大小的。但是,编译器时不时会生成使用多条加载/存储指令来加速计算的代码,或者 Redis 代码本身试图通过非对齐访问加载/存储 64 位值。理论上这通常会导致崩溃,不过在这方面 Linux 能帮上一点忙。

与其崩溃,不如求助于内核!

Linux 内核在 ARM 处理器上运行时,能够帮助用户进程按预期工作,即使它们执行了 CPU 通常不支持的非对齐地址操作。实现方式是在内核中为此类异常注册一个处理器:内核会检查失败的操作,并在一个函数中模拟它,使最终结果就像处理器执行了该操作一样,然后恢复“肇事”进程继续运行。

如果你热衷于底层编程,这个 Linux 内核文件值得一看:http://lxr.free-electrons.com/source/arch/arm/mm/alignment.c

CPU 引发非对齐访问异常时内核的实际行为由文件 /proc/cpu/alignment 控制:

$ cat /proc/cpu/alignment
User:		0
System:		12590 (ip6_datagram_recv_common_ctl+0xc8/0xd4 [ipv6])
Skipped:	0
Half:		0
Word:		0
DWord:		0
Multi:		12590
User faults:	2 (fixup)

如你所见,对于所有被内核纠正的非对齐访问,无论是用户空间还是内核空间,都有独立的计数器。在上面的例子中,内核空间有 12590 次访问被纠正,没有用户态进程被纠正。注意“User faults”一行显示了内核的配置,即当用户空间进程执行 CPU 无法处理的非对齐访问时该怎么办:可以修复问题、发送 SIGBUS,或将事件记录到内核日志中。这是通过写入 /proc/cpu/alignment 的整数的单个位来控制的,例如要记录(除修复之外)用户空间的非对齐访问,可以使用“echo 3 > /proc/cpu/alignment”(第 1 位启用日志记录,第 2 位启用修复)。

我的感觉是,Linux 内核启用这一功能,与其说是因为内核开发者关心那些无法处理非对齐内存访问的可怜用户空间程序员,不如说是因为内核本身也并非总是能避免非对齐访问,正如你可以从“System”计数器中看到的那样。所以这是修复 Linux 在 ARM 上的移植的最简单方法,而不用检查代码中的每一处。

既然 Linux 能透明地处理这些,人们可能会想说:好吧……也许这里没什么可修的,只要把 /proc/cpu/alignment 设置成透明修复,Redis 就会照常工作。实际上情况并非如此,原因有二:

  1. 当发生非对齐访问并由内核修复时,执行速度会*非常*慢。这种速度损失远大于例如进行字大小非对齐访问时所需的第二次内存访问的开销。虽然这只发生在多条加载/存储指令的情况下,但在某些条件下 Redis 会比应有的速度慢得多,仍然令人遗憾。
  2. Linux 内核对 ARM 非对齐访问的实现并不完美。GCC 生成的某些代码包含 Linux 4.4.34 无法很好处理的指令。

一个简单的例子:

#include <stdlib.h>

int main(int argc, char **argv) {
        int count = 1000;
        char *buf = malloc(count*sizeof(double));
        double sum = 0;
        double *l = (double*) (buf+1);
        while(count--) {
                l++;
                sum += *l;
        }
        return 0;
}
$ gcc foo.c -g -ggdb
$ ./a.out
Bus error

即使我的树莓派上的内核配置已经设置为处理并修复非对齐访问,程序还是收到了 SIGBUS!让我们用 GDB 看看问题发生在哪里:

$ gdb ./a.out
(gdb) run

Program received signal SIGBUS, Bus error.
0x00010484 in main (argc=1, argv=0xbefff3b4) at foo.c:10
10			sum += *l;

正如预期的那样,问题出现在内层循环中解引用我们的非对齐 double 指针时。但我们可能想进一步查看发生了什么,检查引发异常的那条 ARM 指令:

(gdb) x/i $pc
=> 0x10484 <main+100>:	vldr	d6, [r11, #-20]	; 0xffffffec

VLDR 指令用于从内存位置加载扩展寄存器,用于浮点运算。出于某种原因,Linux 内核的非对齐访问修正实现无法处理这条指令(我猜这个实现只是不够完整)。“dmesg”命令确实显示该指令未被修复非对齐访问的函数识别:

[317778.925569] Alignment trap: not handling instruction ed937b00 at [<00010480>]
[317778.925610] Unhandled fault: alignment exception (0x011) at 0x01cb8011

所以,既然树莓派上的默认 C 编译器可能生成默认 Linux 内核无法处理的代码,我真的希望 Redis 即使在内核配置为不修复非对齐访问的情况下也能毫无问题地运行。这意味着 Redis 在 ARM 上应该只执行字大小的非对齐访问,也就是 CPU 能够透明处理的唯一一类。

修复 bug

鉴于 ARM 能很好地处理大多数非对齐内存访问,Redis 看起来已经在树莓派上基本可以工作了。尤其是因为默认情况下内核被配置为修复许多不受支持的非对齐访问。即使禁用了对齐修复,它表面上仍能正常工作。然而运行测试暴露了不同的崩溃,尤其是在位操作和哈希函数这类明显的地方。

现在 Redis 做的第一件事是在编译到不支持非对齐访问的架构上时定义 USE_ALIGNED_ACCESS。然后只需修改代码,避免执行非对齐访问的快速路径,或者用 memcpy() 操作替换指针解引用。你可能会认为使用 memcpy() 比解引用指针慢得多,但实际情况要好得多:对于固定大小的 memcpy 调用,比如 memcpy(src,dst,sizeof(uint64_t)),编译器足够聪明,不会调用函数。它实际上会生成最快的一组指令来完成任务,即使地址没有对齐。例如,在 x86 处理器上,这样的函数调用实际上会被翻译成单条 MOV 指令。

经过这些修复之后,Redis 和我的两台树莓派——一台最初的 model B 和一台快得多的 Pi 3——开始成为好朋友:所有测试都通过了,只有一个是关于在崩溃报告中生成调用堆栈的(不过我也打算把它修好),另外集成测试偶尔会因为树莓派设置主从架构太慢而出现一些失败。不过在这一点上,我对正确性的胃口被吊起来了,我还想要更多的对齐问题。

更进一步:SPARC

在我为 ARM 修复 Redis 的同时,Github 仓库里有一个并行开启的 issue,是关于让 Redis 在 Solaris/SPARC 上良好运行的。SPARC 可不像 ARM 那么温和,它无法处理*任何*非对齐访问。对此我记得很清楚,因为在我学 C 语言的头几年里,我买了一台非常老的 SPARC station 4:大端序,同时又不能处理任何类型的非对齐访问,它让我对程序移植有了一些切身体会。遗憾的是,拿到它几个月后我把伏特加洒在了上面,主板彻底烧毁了,但它至今还在我父母家里放着。

Solaris/SPARC 处理非对齐访问的方式比 Linux/ARM 更复杂:32 位非对齐访问总是由内核修复,而 64 位非对齐访问则根据编译选项通过注册用户态陷阱来处理。Sun Studio C 编译器有专门的选项可以非常精确地控制相关行为,甚至还有便于检测和修复此类非对齐访问的工具。

如果说非字大小的非对齐访问在 Redis 中很少见,那么你可能会以为字大小的非对齐访问无处不在。但实际上并非如此,因为直到 Redis 3.0 我都会不时用一台 OpenBSD/SPARC 机器测试和修复 Redis。所以最大的问题是键的哈希函数。原来的 Redis 字符串库 SDS 有固定大小的头部,因此在哈希键时访问总是对齐的。从 Redis 3.2 开始,SDS 头部大小可变,情况就不同了。此外,自从几年前我最后一次在 SPARC 上测试 Redis 以来,这里那里又积累了一些新的非对齐访问。

为了修复哈希函数,我还切换到了 SipHash,所以这也是针对 HashDoS 攻击的一个安全修复。不过值得注意的是,我目前使用的是减少了 C 和 D 轮数的 SipHash 变体:SipHash1-2。这样做是为了避免一次原本相当可观的速度回退,不过据我所知针对 SipHash1-2 应该没有实际的攻击手段,而且无论如何它肯定比我们之前使用的 Murmurhash2 更安全——后者在这方面的弱点大到可以生成与种子无关的碰撞。

我使用的 SipHash 实现是参考实现,稍作修改以简化代码并提供大小写不敏感的变体。它的设计能够处理非对齐访问,并且与字节序无关。这也许是我第一次见到写得如此之好的哈希函数参考实现……

其他 SPARC 修复工作因为一位热心的 Redis 用户向我提供了 Solaris/SPARC 访问权限而大大简化。在修复非对齐访问的过程中,我还尝试修复了在 Solaris/SPARC 上构建和测试 Redis 的问题,所以总体而言这也是一次很好的可移植性改进练习。这项任务完成后,Redis 终于做到了“对齐安全”,至少对独立版代码而言是这样。Cluster 方面还有更多工作要做。

Redis 在树莓派上的性能

好了,回到树莓派 :-) Redis 在这种小硬件上到底有多快?由于市面上不止一种树莓派型号,这个问题有多个答案。Redis 在 Pi 3 上快得惊人。我的基准测试是通过环回接口进行的,因为在树莓派上使用 Redis 的场景主要是本地程序向它写数据,或者把它用作 IPC 以及云-边缘信息交换的消息总线(这里的“云”指设备的中央服务器,“边缘”指设备的本地部署)。不过通过以太网端口访问时它同样表现良好。

在 Pi3 上,我得到以下数字:

测试 1:500 万次写入,100 万个键(均匀分布)。无持久化,无 pipelining。28000 ops/sec。
测试 2:同测试 1,但使用每组 8 个操作的 pipelining:80000 ops/sec。
测试 3:同测试 1,但启用 AOF,fsync 1 秒:23000 ops/sec
测试 4:同测试 3,但正在进行 AOF 重写:21000 ops/sec

基本上,Redis 在 Pi 3 上的速度对任何用例来说都足够快了。考虑到 Redis 大部分时间是单线程的,只有在重写 AOF 日志时才是双线程(因为有另一个后台进程),所以即使树莓派上同时还有其他进程在运行,你也可以期待上述性能。底线是:这些数字并不意味着我们已经榨干了树莓派。

而在最初的 model B 上情况就*截然不同*了,那些数字低得多,比如非 pipelining 时约 2000 ops/sec,使用 pipelining 时约 15000 ops/sec。如此巨大的差距似乎暗示着像 write 和 read 这样需要上下文切换的系统调用的处理效率非常低。不过对于大多数应用来说,这些数字仍然足够好,因为大多数时候 Redis 并不需要服务外部客户端,而且当需要进行高负载数据日志记录时,pipelining 通常很容易实现。

不过我现在手头还没有另一个最有趣的(除了 Pi 3 之外的)设备可供测试,那就是 Pi zero。看看它能交出什么样的数字将会很有意思。它们应该比我正在使用的 Model B 要好。

Pi 的延续性

我喜欢 Redis 在树莓派上良好运行的一点是,我很期待借助 Pi zero 这样的产品,树莓派有望成为 IoT 产品的首选平台。我是指甚至是面向最终用户的成品。我忍不住想,如果有时间的话,我想在硬件领域做些什么:传感器、显示器、GPIO 端口,再加上极其低廉的价格,使得创办一家硬件公司比过去简单得多,我喜欢这样的想法:全世界的黑客现在都可以发布各种类型的智能设备。我想在一定程度上参与其中,哪怕只是边缘性的,为树莓派提供良好的 Redis 体验(未来还包括 Android 和其他基于 ARM 的系统)。Redis 兼具低资源需求、追加式操作以及既适合日志记录又适合设备内数据分析的数据模型,可以基于历史事件采取行动,所以我真心相信它能在这个领域发挥作用。

所以从现在起,树莓派对我来说就是 Redis 的主要目标平台之一,就像 Linux 服务器最初被定为 Redis 的“标准”一样。接下来的几周我会继续进行修复,它们都将进入 Redis 4.0。与此同时,我会在 Redis 官方网站上撰写一个新的板块,收录关于 Redis 与树莓派的所有信息:不同设备上的基准测试、最佳实践等等。也许将来我还能发布概念验证性质的“agent”,将 Redis 用作 IoT 设备与云端之间的数据总线,让设备只需把数据记录到 Redis 中,由 agent 负责在与外界的链路可用时把数据搬到云端,同时获取供设备执行的命令并发送回应答。当 Redis 4.2 提供 stream 数据结构后,这将变得更加有趣。

我很乐意听到你认为 Redis 可以在哪些嵌入式场景中发挥作用,以及我可以做些什么让它在这方面变得更好。