树莓派上的 Redis:非对齐世界的历险
原文由 Salvatore Sanfilippo 于 发布,订阅该博客
在售出 1000 万台、并拥有几乎无穷无尽的各类应用和传感器、显示器等外围设备之后,我认为可以说,树莓派不仅仅是取得了成功,它已经成为程序员在嵌入式领域进行尝试的首选平台之一。尤其是随着 Pi Zero 这类产品的出现,它也正在成为打造硬件产品的平台,让人们无需承担为垂直设备设计、制造和编写软件所带来的全部风险与成本。
我也愿意认为,Redis 同样是程序员喜欢用来折腾、试验和创造新事物的平台。此外,可用于嵌入式/物联网应用的设备,常常面临需要在设备本地临时或永久存储数据的问题,例如将传感器接收到的数据存下来,以便在设备端进行计算或发送到远程服务器。Redis 正在新增一种专门适用于数据流和时序存储的“Stream”数据类型,目前规范已接近完成,接下来的几周内就将开始实现。Redis 现有的数据结构加上新的 Stream,再配合极小的内存占用,以及即便在小型硬件上也能提供的不错性能(从而带来较低的能耗),看起来与树莓派的潜在应用场景非常契合,总体上也很适合小型的 ARM 设备。唯一缺失的、也是显而易见的一环就是:要在 Pi 上良好运行。
Pi 诸多酷炫之处的一点在于,它的开发环境并不像几年前的那些嵌入式开发环境……它直接运行 Linux,拥有你期望的整套类 Debian 工具链。基本上,要让 Redis 适配到 Pi 上并不是什么艰巨的任务。Linux 系统程序与 Pi 之间最根本的潜在不匹配,是性能/占用方面的不匹配,但由于 Redis 本身的设计,这根本不是问题:一个空实例常驻内存仅占用 1MB,所有查询都在内存中完成,因此速度足够快,也不会过度消耗闪存,而需要持久化时,它使用 AOF,写入模式是纯追加。然而 Pi 搭载的是 ARM 处理器,这就需要在处理非对齐访问时多加小心。
在这篇博文中,我会在展示自己如何让 Redis 与树莓派更好地协同工作的同时,尽量概述一下如何应对那些不像 x86 平台那样能透明处理非对齐访问的架构。
关于 ARM 处理器的几点知识
将 Redis 移植到 ARM 最有意思的地方在于,ARM 处理器是——或者说曾经是……嗯,并不太喜欢非对齐的内存访问。如果你一直从事高级语言编程,可能不太了解这一点,但历史上许多处理器架构都无法在非字长倍数的地址上加载或存储字。例如,如果字长是 4 字节(对于 32 位处理器而言),你可以在地址 0x4、0x8 等处加载或存储一个字,却不能在地址 0x7 上这么做。结果有时是触发异常,有时则会出现奇怪的行为,具体取决于 CPU 及其确切配置。
随后 x86 处理器家族统治了世界,大家也就渐渐忘了这个问题(除了在处理 SEE 指令等情况时,但如今就连这些指令也有了非对齐版本)。唔,一开始人们也并非真的忘了这个问题。即便 x86 处理器能够在不抛异常的情况下处理非对齐访问,这样做也会带来不小的性能损失:在跨字边界的部分读写需要做双倍的工作。但后来的 x86 处理器加入了优化,使得非对齐访问在大多数情况下与对齐访问一样快,所以如今对 x86 来说,这真的已经不是问题了。
而 ARM 直到 v5 都是那种会对非对齐访问产生奇怪结果的平台,而且结果往往出乎意料。根据 ARM 官方文档的说法:“如果地址不是 4 的倍数,LDR 指令会返回一个旋转后的结果,而不是执行真正的非对齐字加载。一般来说,这种旋转并非程序员所期望的结果。”呃,*绝对*不是程序员所期望的。不过,最初的树莓派搭载的已经是 ARM v6 处理器。v6 虽然会有一定的性能损失,但已经能够处理字长大小的非对齐访问。然而,涉及多字的指令仍然会抛出异常,导致程序收到总线信号而终止,或是向内核求助(我们稍后会更详细地看到)。这意味着 Redis 在 Pi 上并不会立刻一塌糊涂地崩溃,因为 Redis 所执行的大多数非对齐访问实际上都是字长大小的。但编译器有时会为了加速计算而生成使用多重加载/存储指令的代码,或者 Redis 代码本身会尝试从非对齐地址加载/存储 64 位值。理论上这通常会导致崩溃,但在 Linux 的帮助下情况有所不同。
与其崩溃,不如求助内核!
在 ARM 处理器上运行时,Linux 内核能够帮助用户进程像预期那样工作,即便它们在 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”计数器上看到的那样。所以,相比去逐一检查代码中每一个地方,这成了修复 ARM 上 Linux 移植最简单的方法。
既然 Linux 会透明地处理,有人可能会想,唔……也许这里根本没什么需要修复的,只要把 /proc/cpu/alignment 设为透明修复,Redis 就能按预期工作。实际上并非如此,原因有二:
- 当执行一次非对齐访问并由内核修复时,执行速度会变得*非常*慢。这种速度损失远大于例如执行非对齐字长访问时所需的那一次额外内存访问。虽然这只发生在多重加载和存储指令的情况下,但在某些条件下 Redis 会比实际需要的慢得多,这仍然很可惜。
- 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即便我的 Pi 上的内核配置已设为处理并修复非对齐访问,程序仍然收到了 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] ; 0xffffffecVLDR 指令用于从内存位置加载扩展寄存器,用于浮点运算。出于某种原因,Linux 内核对非对齐访问的修正实现无法处理这条指令(我想是因为实现本身还不够完整)。“dmesg”命令确实会显示修复非对齐访问的函数未能识别该指令:
[317778.925569] Alignment trap: not handling instruction ed937b00 at [<00010480>]
[317778.925610] Unhandled fault: alignment exception (0x011) at 0x01cb8011所以,既然 Pi 上的默认 C 编译器可能会生成默认 Linux 内核无法处理的代码,我非常希望 Redis 即便在内核被配置为不修复非对齐访问的情况下也能无障碍运行。这意味着在 ARM 上的 Redis 应该只执行字长大小的非对齐访问,这是 CPU 能够透明处理的唯一一种。
修复缺陷
鉴于 ARM 能较好地处理大多数非对齐内存访问,Redis 在 Pi 上表面上看起来已经能跑了,尤其是因为默认情况下内核被配置为会修复许多不支持的非对齐访问。即便禁用了对齐修复,它表面上也还能工作。然而运行测试后却暴露出不同的崩溃,尤其是在位操作和哈希函数这类显而易见的环节。
现在 Redis 首先做的是,在不支持非对齐访问的架构上编译时定义 USE_ALIGNED_ACCESS。然后只需修复代码,避免走那些会执行非对齐访问的快速路径,或将指针解引用替换为 memcpy() 操作。你可能会认为使用 memcpy() 比直接解引用指针慢得多,但实际情况要好得多:对于像 memcpy(src,dst,sizeof(uint64_t)) 这样大小固定的 memcpy 调用,编译器足够聪明,不会真的去调用函数。它实际上会生成一组最快的指令,即使地址未对齐也能完成任务。例如,在 x86 处理器上,这个函数调用实际上会被翻译成单条 MOV 指令。
完成这些修复后,Redis 与我的两台树莓派——一台是初代 B 型,一台是快得多的 Pi 3——开始相处得非常融洽:所有测试都通过了,只有一个关于在崩溃报告中生成调用栈的测试除外(不过我也会修复它),另外由于 Pi 在搭建主从架构时速度较慢,集成测试中偶尔会出现几次失败。不过此时我对正确性的胃口被吊了起来,我还想要更多对齐方面的问题。
更进一步:SPARC
在我修复 Redis 对 ARM 的支持时,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 终于做到了“对齐安全”。在集群方面还有更多工作要做。
树莓派上 Redis 的性能
好了,回到 Pi :-) 在如此小巧的硬件上 Redis 能跑多快呢?由于市面上有不止一款 Pi 型号,这个问题有多个答案。Pi 3 上的 Redis 速度惊人地快。我的基准测试是通过回环接口进行的,因为在 Pi 上的 Redis 主要面向本地程序向其写入数据,或将其用作消息总线,用于进程间通信以及云端与边缘之间的信息交换(这里的云指设备的中心服务器,边缘指设备的本地部署)。不过通过以太网端口访问时它的表现也很好。
在 Pi 3 上,我得到如下数据:
测试 1:500 万次写入,100 万个键(键之间均匀分布)。无持久化,无流水线:28000 次操作/秒。
测试 2:与测试 1 相同,但使用流水线,每组 8 个操作:80000 次操作/秒。
测试 3:与测试 1 相同,但启用 AOF,每秒 fsync 一次:23000 次操作/秒
测试 4:与测试 3 相同,但在进行 AOF 重写期间:21000 次操作/秒
基本上,Pi 3 上的 Redis 对于任何用例来说都足够快了。要知道 Redis 基本上是单线程的,在重写 AOF 日志时会是双线程,因为还有一个后台进程,所以你可以期待在 Pi 上同时运行其他进程的情况下仍能达到上述性能。归根结底,这些数字并不意味着我们已经让 Pi 饱和了。
而对于初代 B 型来说,情况就*大不相同*了,这些数字要低得多,比如非流水线时约 2000 次操作/秒,使用流水线时约 15000 次操作/秒。如此巨大的差距似乎暗示了对 write 和 read 这类需要上下文切换的系统调用处理效率非常低。不过对于大多数应用来说,这些数字仍然足够好,因为 Redis 大多数时候并不会服务于外部客户端,而且在需要进行高负载数据记录时,流水线往往很容易实现。
不过目前除了 Pi 3 之外,我还没有拿到最有意思的待测设备之一,也就是 Pi Zero。看看它能跑出怎样的数据将会很有趣。它的表现应该会比我现在用的 B 型要好。
Pi 的延续
我喜欢 Redis 在 Pi 上良好运行的一点是,我对树莓派——尤其是借助 Pi Zero 这样的产品——有望成为物联网产品的首选平台感到兴奋。我的意思是,甚至是面向最终用户的成品。一想到如果有时间我会在硬件领域做些什么,我就停不下来:传感器、显示器、GPIO 接口以及极低的价格,使得相比过去,以更简单的方式创建一个硬件创业公司成为可能,我喜欢全世界的极客们现在能够打造出各种智能设备的想法。我想或多或少地参与其中,为 Pi(未来还有 Android 和其他基于 ARM 的系统)提供良好的 Redis 体验。Redis 具备低资源需求、纯追加操作以及适合日志记录和设备端数据分析的数据模型的良好组合,能够基于历史事件采取行动,所以我真的相信它能在这一领域发挥作用。
因此从现在起,树莓派对我而言就是 Redis 的主要目标平台之一,就像最初 Linux 服务器被定为 Redis 的“标准”一样。在接下来的几周里,我会继续进行修复,这些都会进入 Redis 4.0。同时,我会在 Redis 官网上新增一个章节,提供关于 Redis 与 Pi 的所有信息:不同设备上的基准测试、最佳实践等等。也许将来我还能发布概念验证的“代理”,以便将 Redis 用作物联网设备与云之间的数据总线,让设备只需将数据记录到 Redis 中,由代理在与外界的连接可用时负责将数据搬运到云端,同时拉取设备要执行的指令并回传响应。当 Redis 4.2 中的 Stream 数据结构可用时,这会变得更加有趣。
我很乐意听听你认为 Redis 能在哪些嵌入式场景中发挥作用,以及我可以做些什么来在这方面把它做得更好。
随机一篇博客
评论
登录后参与讨论