0%

[翻译] Redis on the Raspberry Pi: adventures in unaligned lands

原文地址

在售出1000万台设备,实际上是诸如传感器和显示器这样无数不同的应用和辅助设备后,可以说树莓派不仅仅是取得了成功,它还成为了一种程序员最喜爱的嵌入式实验平台。像是Pi zero这样的产品也在成为创造硬件产品的平台,且不会引入设计、构建、为车载设备写软件等方面的风险和开销。

同样地,我也认同Redis是一个程序员乐于去冒险、实验、构建新事物的平台。而且,能用于嵌入式/物联网应用的设备,通常会有暂时或长期存储数据的需求,像是从传感器接收到的数据,需要在这台设备上运算的数据,或是要发往远程服务器的数据。Redis正在加入一种Stream数据类型,非常适合流式数据和时间序列存储,撰写本文时(2017年初)这个特性快要完成了,后续工作会在接下来几周内开始。Redis现存的数据结构,以及新增的Stream类型,以及它较小的内存使用,以及它即使在小型硬件(低功耗)上也能提供相当不错的性能,都让Redis看起来非常适合应用在树莓派,进而是其它小型ARM设备上。中间缺失的部分也很明显:在树莓派上把Redis跑起来。

树莓派的一个很酷的特点就是,它的开发环境不像过去的嵌入式系统那样,它上面跑的就是正常的Linux,还包括各种Debian系的工具。简单地说在树莓派上适配Redis不算很困难。Linux程序移植到树莓派上最常见的问题就是性能或内存占用不匹配,但在Redis上这不是问题,因为它本身就被设计为:空实例只占用1MB内存,且查询请求会走内存,因此它足够快,也不会给闪存太高的压力,而且在需要持久化时,它只会用AOF(Append Only File)。但树莓派上用的是ARM处理器,意味着我们要小心处理未对齐的内存访问。

本文会展示我为了让Redis能愉快地跑在树莓派上都做了什么,我会试着给出一个如何应对那些不能透明地处理非对齐内存访问的平台上(不像x86)的概述。

关于ARM处理器

将Redis移植到ARM的过程中,最有趣的事情就是,ARM处理器不太喜欢未对齐的内存访问。如果你一直在用高阶语言,你可能不太了解内存对齐。历史上许多处理器都不能读写地址不是字长整数倍的内存数据。所以如果字长是4字节(32位处理器下),你可以读写0x4、0x8等等地址的数据,但0x7就不行。如果你真这么做了,取决你的CPU和配置,有时程序会抛异常,有时是奇怪的行为。

之后x86统治了世界,大家几乎忘了这件事(除非要用SSE指令时,不过这些指令也有不需要对齐的版本)。当然,一开始大家没有真的忘记这件事,因为x86处理器可以读写未对齐的内存而不出错,但会带来性能损失:跨字长边界的一次读写可能被拆成两次读写。但近期的x86优化将非对齐访问的性能损失降到了最低,几乎与对齐访问相当,现在非对齐访问对x86来说真的不是事儿了。

直到ARMv5,ARM平台上非对齐访问还是会引发奇怪的非预期行为,文档是这么说的:“如果地址不是4的整数倍,LDR指令会返回一个旋转后的结果,而不是真的跨字长读取。通常这个结果不符合程序员的预期。”旋转当然不是程序员预期的。初代树莓派用的已经是ARMv6了,它能处理非对齐访问,当然会带来性能损失。但它在处理未对齐的多倍字长的指令时会抛异常,因总线错误而终止程序,或求助于内核(后面会详述)。这意味着Redis在树莓派上运行的时候不会马上挂掉,因为Redis大多数访问都恰好是一个字长。但随着时间推移,编译器产生了多倍字长的读写指令以加速运算,或是Redis自己也会试着读写未对齐的64位数据。理论程序会崩溃,当然Linux还能帮上点忙。

不要崩溃,求助内核!

当Linux内核运行在ARM上时,它能帮助进程在访问未对齐内存时也能得到预期的结果,即使CPU本身都不支持。方法是在内核态注册这类异常的handler:内核会检查失败的指令,再通过handler来模拟它运行,从而得到预期的结果,也能让犯错的进程恢复运行。

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

内核在CPU抛非对齐访问错误时的行为受到/proc/cpu/alignment的控制:

1
2
3
4
5
6
7
8
9
$ 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位是允许修正。

我的感觉是,这个功能不只是内核开发者担心用户空间的程序员没办法处理非对齐访问,而是内核自己也不能保证都是对齐访问(看“System”计数器)。所以打开这个功能是解决Linux程序移植ARM的最简单方法,而不是检查每一块代码,确保所有访问都对齐。

有人可能会受到这个功能的诱惑,说,那就没什么要做的了呗,只要我们把/proc/cpu/alignment设置对,Redis就能按预期那样工作了。但其实不然,看下面2个原因:

  1. 当发生非对齐访问,内核修正时,整个执行速度会非常慢,要比底层拆成多次对齐访问还要慢得多。即使它只在非对齐的多倍字长的读写指令中发生,也会导致此时Redis速度远低于预期。
  2. 内核模拟ARM非对齐访问的实现不太完美,在Linux4.4.34版本中,它无法处理某些GCC编译出的指令。

下面是一个普通的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ #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;
}
1
2
3
$ gcc foo.c -g -ggdb
$ ./a.out
Bus error

即使我的树莓派配置成修正非对齐访问的错误,我的程序仍然收到了SIGBUS信号!我们通过GDB看下错误是哪里发生的:

1
2
3
4
5
6
$ 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指令:

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

VLDR指令是将内存地址对应的数据加载到一个扩展寄存器中,用于浮点运算。因某些原因,Linux内核关于修正非对齐访问错误的代码不能处理这条指令(我猜就是没实现完全)。“dmesg”命令可以显示这条指令无法被handler识别,进而修正:

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

所以,如果树莓派默认的C编译器可能会生成Linux内核默认不能处理的指令,我就真的想让Redis在关掉内核配置时也能正常运行。这就意味着ARM上跑的Redis保证只访问按字长对齐的内存,只有这样CPU才能正确处理。

修复bug

既然树莓派上的ARM能处理大多数的非对齐访问,Redis似乎已经可以在大多数情况下工作了。尤其是内核被默认配置为修正很多非对齐访问的错误时。即使这种修正被关掉了,Redis表面上也能工作。但此时测试会跑出很多进程崩溃的情况,尤其是在位运算和哈希函数中。

Redis要做的第一件事是在编译到不支持非对齐访问的平台时定义USE_ALIGNED_ACCESS宏。之后就是修改一堆代码以避免关键路径上的非对齐访问,或将指针解引用替换为memcpy()。你可能觉得用memcpy()要比直接解引用慢,但事实上前者要更好:对于固定大小的内存拷贝,如memcpy(src, dst, sizeof(uint64_t)),编译器会智能地将函数访问替换为一组能处理非对齐访问的最快的指令。例如,在x86处理器上,这次函数调用会被翻译成单次MOV指令。

在做完这些修复工作后,Redis和我的两个树莓派(一个是初代模型B,一个是快得多的Pi 3)开始成了好朋友:所有测试都通过了,但其中一个在崩溃报告中生成了调用栈(当然我正要修复这个问题),随着时间推移,集成测试有了一些错误,原因是树莓派在启动master和slave时太慢了。但此时我对正确性的欲望受到了刺激,我想解决更多对齐的问题。

多走一步:SPARC

就在我修复ARM上Redis的问题时,Github仓库中还有些issue是关于如何让Rdis正常运行在Solaris/SPARC上。SPARC不像ARM那样友善,它无法处理任何非对齐访问。我记得很清楚,在我第一年学习C语言时,我买了一个非常旧的SPARC 4工作站:大端,且无法处理任何非对齐访问。它带给我一些程序移植相关的观点。羞愧的是几个月后我不小心将伏特加洒到了上面,烧坏了主板,但它现在还在我父母的房子中。

在Solaris/SPARC上处理非对齐访问要比Linux/ARM上复杂得多:根据编译选项,内核总是能处理32位的非对齐访问,而64位的非对齐访问可以通过注册一个用户态陷阱来处理。Sun工作站的C编译器有特定选项来精确控制这类行为,甚至还有能简单发现并修复这类访问的工具。

如果Redis中很少有不等于字长的非对齐访问,你就可以预期其中到处都是等于字长的非对齐访问。但从我在OpenBSD/SPARC上用Redis3.0开始测试和修复后,随着时间推移,情况不是这样了。最大的问题是哈希函数,原版的Redis字符串库,称为SDS,有着固定大小的头,所以在哈希key的时候总是对齐的。但从Redis3.2开始SDS头不再是固定大小了。此外自我在一年前在SPARC上测试Redis后,又出现了其它非对齐访问的情况。

为了修复哈希函数的问题,我转而用了SipHash,也为了避免HashDos攻击。但值得一提的是我现在用的是C和D轮数量减少的SipHash变种:SipHash1-2。这是为了避免其它方面不正常的性能回退。但据我所知还没有实质上对SipHash1-2的攻击,总之要比我们之前用的Murmurhash2更安全————考虑到有可能产生种子碰撞时它太弱了。

我在使用的SipHash实现参见文后链接,我简化了一些代码,且将其改为大小写不敏感。它被设计为能处理非对齐访问,且与字节序无关。我第一次看到一个哈希函数的参考实现能写得这么好。

因为一位好心的Redis用户为我提供了一个Solaris/SPARC系统的访问权限,SPARC上的剩余修正工作简化了很多。在修正未对齐访问的过程中,我还尝试了修正Solaris/SPARC上Redis的构建和测试,一般来说这也是一项很好的移植性提升练习。这项任务完成后,Redis至少就其独立代码而言终于是“对齐安全”的了。

树莓派+Redis的性能

回到树莓派上。在其上Redis能跑多快?既然树莓派的硬件不只一种,这个问题的答案也不只一个。在Pi 3上Redis令人吃惊的快。我的测试是通过loopback做的,因为树莓派上的Redis主要是针对本地程序,或作为IPC(进程间通信)和云与端之间信息交互的消息总线(这里说的云是指应用的中心服务器,端是指应用的本地实例),但当通过以太网访问时,Redis跑的依然很快。

Pi 3上的性能数字:

  • 测试1:一百万key(均匀分布)上的五百次写。关闭持久化,关闭流水线。28000次/秒。
  • 测试2:类似于测试1,但每8个操作一组通过流水线。80000次/秒。
  • 测试3:类似于测试1,但有只追加持久化(AOF),每秒一次fsync。23000次/秒。
  • 测试4:类似于测试3,但过程中有覆盖写。21000次/秒。

基本上Pi 3上的Redis的速度可以满足任何使用场景。考虑到Redis主要是单线程使用,或在启用AOF日志时双线程使用(有个背景线程),你可以预期在达到以上性能的同时,其它进程依然能在树莓派上运行。也就是说:上面的数字并没有榨干树莓派。

而在初代模型B上情况不一样,Redis的速度要慢得多,例如关闭流水线时只有2000次/秒,打开流水线时15000次/秒。这之间的巨大差别看起来指向了需要上下文切换的系统操作————如读和写————效率太低。但这个数字对大多数应用也足够用了,因为Redis大多数时间不是用来服务外部实例的,也因为当有需要做高负载数据记录时,我们可以简单地打开流水线。

但此时我还没在我最感兴趣的设备(除了Pi 3)上做测试,那就是Pi zero。看一下它能跑多快还是挺有趣的,应该会比我现在用的模型B要好。

树莓派的未来(原文是continuity,但我倾向于翻译成未来)

我乐于让Redis很好地跑在树莓派上的原因之一,是我很兴奋看到像Pi zero这样的树莓派逐渐成为物联网开发平台。我指的是针对最终用户的成品(原文是“I mean even finished products intended for the final user”,不太会翻译)。我抑制不住地去想如果我有时间的话,能在硬件领域做些什么:传感器,显示器,GPIO端口,以及它们能极大简化组建硬件创业公司的低廉价格,以及我爱的“全世界的骇客们现在能创造出不同的智能应用”的想法。我想参与进来,即使稍微做一点,比如为树莓派提供很棒的Redis体验(未来还包括Android和其它基于ARM的系统)。Redis很好地结合了低资源需求、只追加操作、同时适用于写入和分析的数据模型(从而能针对历史数据做决策),我真的相信它能在这个过程中帮到我们。

所以从现在开始树莓派就是我使用Redis的主要目标平台之一了,像是最初被设定为Redis“标准”的Linux服务器。未来几周内我会继续我的修正工作,这些修正会进入Redis4.0。同时我也会在Redis的官方网站上新写一个章节,包括所有与Redis和树莓派有关的信息:不同设备上的测试数据,最佳实践,等等。

也许未来我也会发布关于“代理”概念的证明,它的目的是将Redis用于物联网设备和云服务器之间的数据总线,允许设备只将数据写入Redis,而代理会负责在连到外网时将数据搬到云端,同时从设备那获取命令并回复。结合Redis4.2新增的Stream结构后这会更有意思。

我很想知道那些Redis能帮上忙的嵌入式应用,以及我能为它做些什么。