使用 Go 编写 Redis Loadable Modules
本文会介绍如何使用 go 编写 redis loadable modules,并分析编写模块和使用cgo可能遇到的坑。
什么是redis loadable modules
可加载模块是redis最新加入的功能,目前需要在unstable
分支才可以使用。简单说模块系统是redis的C代码暴露出一些API,定义在头文件redismodule.h中,外部模块引用该头文件即可访问所有的API函数,这些API提供了包括访问redis的字典空间、调用redis命令、向客户端返回数据等诸多功能。外部模块是以动态库的形式被redis server加载并使用,可以在redis server启动时加载,也可以在启动后动态加载。更多的细节可以参考文档redis module INTRO。
在此之前想对redis扩展有两种方案:一是利用lua脚本;另一种则需要修改redis源码,类似于Kit of Redis Module Tools提供的方案。lua脚本的扩展性有限,并且lua是通过redis的上层API来调用redis命令的,无法直接访问底层的存储数据,调用redis更底层的API;修改源码的方案就更加hack,是没有办法不断与上游分支合并的。
显然心的模块系统明显优于以上两种方案,优点包括:
- 直接访问redis存储的各种数据结构;
- 直接对存储数据的内存进行操作;
- 模块仅依赖
redismodule.h
暴露的接口函数,而不依赖redis本身的实现,因此可以兼容redis的版本升级。
模块系统也有缺点,比如模块中的代码bug引发的异常会直接导致redis server crash掉;模块的问题譬如引入的内存泄漏,代码执行阻塞都会影响redis服务的正常运行。这些缺点不是模块系统本身的问题,而是这种扩展的灵活性和系统稳定性的权衡,是可以通过优质的扩展模块来避免的。
使用C来编写redis扩展模块很简单,参考文档redis module INTRO,你可以在5分钟内学会编写一个redis扩展模块。Redis Lab官方也提供了很多有趣的模块 Module Hub。在文档中同样提到可以用其它语言来编写redis扩展模块。
it will be possible to use C++ or other languages that have C binding functionalities.
go语言的cgo提供了Go和C互相调用的支持,因此本文来尝试通过go语言编写redis的扩展模块。下文所有的代码都可以在RedisModules-Go这个仓库找到,仓库里还提供了redis module lab的两个模块password,graphicsmagick的go版本,以及一些简单的benchmark。
redis扩展模块的形式是很固定的,需要编写且只编写两个部分:注册命令的函数和具体实现命令的函数。我们分两个阶段来编写redis的go扩展模块:第一阶段是通过go编写逻辑代码,即go代码拿到数据、处理、返回处理结果;第二阶段是go代码直接访问redismodule.h提供的API获取数据、处理、返回。先看看第一种类型。
通过go编写逻辑处理
基本思路是使用go编写逻辑处理,go的函数接收的输入是C的数据类型,可以是指向C内存空间的指针;在C代码中调用由go编写的逻辑处理函数,具体的调用方式是go代码编译时指定buildmode=c-shared
得到动态库和相关头文件,在C代码中引用头文件并调用。最简单的实现代码如下所示:
1 | /* ECHO1 <string> - Echo back a string sent from the client */ |
1 | package main |
具体实现中涉及到申请的内存空间是在C的运行环境还是go的运行环境,因为go的运行时提供了gc,而C则需要手动管理内存,因此这其中有很多细节需要注意;同时go和C之间可以传递的数据也有一些限制,后文会详细叙述。
通过go调用redismodule.h定义的API
第一步中所做的是提供数据给go代码进行逻辑处理并返回数据,那么如果希望在go代码中也可以调用redismodule.h定义的API,又需要如何处理?
直观的想通过go调用C代码,在go中直接#include "redismodule.h"
就可以了嘛,于是我们编写如下的go代码:
1 | package main |
redismodule.h
定义的API函数都是函数指针,由于go不支持直接调用C的函数指针,所以通过通过go的变量保存C的函数指针,并将该变量作为参数调用C的bridge_function
,在bridge_function
中调用目标函数指针。编译是可以通过的,但实际运行就会crash。通过调试很容易发现C.RedisModule_CreateString
的值是nil
,它没有指向正确的函数地址。那换一种方式使用一个C的函数来调用RedisModule_CreateString
呢?
1 | package main |
实际运行时依然会在调用RedisModule_CreateString
函数时crash掉,跟进gdb调试可以看到该函数指针并没有指向正确的内存地址。
1 | (gdb) l |
那么redismodule.h
对外提供的函数指针是在何时指向实际的函数内存地址呢?从redismodule.h
文件本身就会得到答案:加载一个外部模块都需要调用RedisModule_Init
函数,在这个函数中会通过RedisModuleCtx *ctx
变量定位到在redis代码module.c
内提供的实际API函数,然后将函数指针指向真正的函数地址。由于在上述的go模块中,没有调用RedisModule_Init
,RedisModule_CreateString
自然指向的是非法的内存地址。所以简单改动一下代码,主动注册一下函数指针的地址即可在go的模块中调用redismodule.h
提供的API。
1 | package main |
一些细节
编写模块的过程中关于内存使用发现不少有趣的地方。
1. Go和C之间传递指针的规则和限制
在go1.5/hello_module.go这个文件中可以看到export给C的go函数的返回值很多返回了go的指针,在C代码中调用go函数之后可以直接访问go的内存,这在go1.5是支持的,但是从go1.6之后加入了Go和C之间传递指针的限制,明确指出:
A Go function called by C code may not return a Go pointer.
go1.6对go和C的互相调用进行了大量的规范,有编译层面的也有在运行时的检查,详细可以参考12416-cgo-pointers。规范的出发点有两点:一是更便于go的内存管理,总所周知go提供了gc,所以有一些破坏了gc规则的使用需要禁止;二是尽可能减少程序运行时内存访问出现的未知错误。
那么回到我们的redis go模块,如果不能从go函数返回go指针给C代码使用,那么从go如何返回数据给C代码呢?目前有两种,一是返回完整的C数据,二是返回C的指针。以hello.echo模块为例,对应的两种返回形式分别是:
1 | func GoEcho1(s *C.char) (*C.char, int) { |
echo1就是直接通过C.String
生成C的char *
并进行一次内存复制,将gostr
的内容复制到C的内存空间。echo6的例子则是现将从C代码传来的内存地址直接映射到go的slice(这里在C代码已经为待处理的数据申请了足够多的内存空间),然后直接在go中直接操作这部分内存,最后返回这部分内存的C指针。显然,echo6比echo1减少了gostr
的一次内存复制。使用较大的echo string进行benchmark可以很明显看到echo6比echo1更快。
1 | Benchmark_Echo1 2000 815435 ns/op |
2. 内存管理
在使用cgo时一定需要区分变量是在go的内存空间还是在C的内存空间,尤其注意在go代码中申请的C的数据一定需要手动释放内存,因为go的gc并不会回收这部分内存。所以譬如通过C.Cstring()
生成的数据、通过C.malloc()
申请的内存在使用后都需要手动回收。
3. Go直接映射C的内存空间
在go代码中C的数据类型会映射成为C.x,可以直接访问C数据类型的变量,但是如果想要对变量进行go代码的逻辑操作,就必须先转换成为go的数据类型,譬如希望对一个C的char *
进行处理,需要现转换成为go的slice
。转换有两种方法:一是直接调用C.GoBytes
方法,另一种是利用反射构造slice
,具体的使用方法参考sharemem.go。使用C.GoBytes
会将C数据结构的内存复制到go的内存空间。而第二种方法则是直接映射,没有内存复制。
在直接映射C内存的使用场景下,对go对象slice的一些操作可以直接作用于C的内存空间,譬如在echo6中使用copy(zslice.Data[int(length):], incr)
。这里就需要注意,如果一开始C申请的内存空间不足,但是强制增大go中slice的capacity,然后进行copy操作,结果会发现程序运行中会crash掉。这种用法的问题在于不能区分slice增加的capacity内存是位于C代码还是go代码的运行环境;如果不是copy而是使用slice的append操作,由于capacity不足go的slice会重新申请内存空间,那么对应的内存都位于go的运行环境,与原来C运行环境中的数据就没有关系了。
小结
后续还会对比使用go和C编写同样的扩展模块在性能方面会有怎样的差距,这里就不再继续讨论。redis计划会在4.0版本中合并模块系统的功能,这还是很值得期待的。使用cgo来完成go和C的交互也十分常见,go标准库本身就有很多地方使用到了cgo来使用C的代码,本文编写的graphicsmagick模块使用的imagick库本身也是通过cgo对ImageMagick MagickWand C API的封装。