以NIF的方式在Elixir里使用C

Erlang支持一种方式,就是用C来实现函数,并在Erlang中透明地使用它们。这些函数被叫做NIFs(原生实现的函数)。在两种场景下,NIF被证明是完美的解决方案:当你需要原始的计算速度时;当你需要从Erlang调用已有的C接口时。在本文中,我们一起来看看这两种场景。

请注意,如果我们想要使用C程序(也就是我们想要与现有的C程序进行交互),那么NIF并不是我们唯一的选择。Erlang有其他方式处理外部函数接口来与其他语言交互。其中之一就是Port;如果你想深入了解,可以阅读 Sasa Juric 写的非常棒的文章

我们将全面了解NIF。首先,我们将看看如何写简单的NIF来执行算术计算;接着,我们看看如何在Elixir里使用这些NIF。然后,我们了解如何从NIF里访问已有的C程序。最后,我们学习如何将C的编译合并到我们Elixir代码编译中。

我在本文中所讲的绝大部分内容都可以在Erlang官方文档中 erl_nif C 库 中阅读到更多细节。

本文中讨论的内容适用于Erlang和Elixir,只需进行最小限度的调整。 我会在Elixir中展示我所有的例子,但我会随时提及Erlang和Elixir两者。

严肃的NIF警告

NIF是危险的。我猜你肯定听说过Erlang(Elixir)如何可靠、容错,进程是如何隔离的,一个进程内部的崩溃只会影响到它自己,以及其他的很棒的特性。当你开始玩NIF的时候,你可以和所有Erlang好的东西说再见。一个NIF里的崩溃(比如可怕的段错误)将会使得整个Erlang虚拟机崩溃。没有监督者来恢复,没有容错,没有隔离。这意味着当你使用NIF的时候需要极度的小心谨慎,并且你应该总是确信你有一个好的理由来使用它。

另一个值得注意的是NIF不会被Erlang的调度器抢占:一个NIF做为一个单独的计算单元,它不会被中断。这意味着你的NIF应该尽可能地快; 正如Erlang的NIF文档所建议的那样,一个很好的经验法则是将NIF保持在毫秒级的执行时间内。查看Erlang文档,了解当你的NIF需要更多时间完成时应采取的措施。

基础知识

NIF的工作方式简单:你写一个C文件,然后在一些Erlang提供的设施下导出一些函数,然后编译这个文件。接着,你定义一个Erlang/Elixir文件,在里面调用 :erlang.load_nif/2 。这个函数将把C文件里的所有NIF定义为调用模块里的函数。

在实践中更容易明白这一点。

让我们从容易的开始:写一个没有副作用的NIF,它有一个入参和一个返回值。为完成这个例子,我们写一个 fast_compare 函数,它有两个整数入参,然后比较它们,如果相等就返回0,如果第一个比第二个小,就返回-1,否则就返回1。

定义一个NIF

我们开始写 fast_compare.c 文件。首先我们必须包含 erl_nif.h 这个头文件,它包含了使用NIF的时候,我们需要的所有东西(类型、函数、宏)。

1
#include "erl_nif.h"

C编译器并不知道erl_nif.h在哪里,因此,当稍后我们编译我们的程序的时候,必须指出它的所在。

现在,定义NIF的所有C文件有相似的结构:C函数列表,接着是被导出到Erlang/Elixir的C函数列表(以及它们的名字),最后,调用 ERL_NIF_INIT 宏,它执行把所有这个一切串联起来的神秘的事情。

在我们的例子里,C函数列表就只有 fast_compare 函数。这个函数的签名如下所示:

1
2
3
4
static ERL_NIF_TERM
fast_compare(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) {
// cool stuff here
}

这里有两个NIF的特定类型:ERL_NIF_TERM 和 ErlNifEnv。

ERL_NIF_TERM 是一个“包装”类型,它表示在C里所有的Erlang数据类型(像binary,list,tuple,等等)。我们必须使用 erl_nif.h 提供的函数来将一个 ERL_NIF_TERM 转换为一个C的值(或者多个C的值),反之亦然。

ErlNifEnv仅仅是NIF被执行所在的Erlang环境,我们绝大多数只是把它在函数中进行传递而不必实际对它进行操作。

我们来看看 fast_compare 的参数(所有的NIF参数都如此):

  • env 如上所述,仅是NIF被执行所在的Erlang环境,我们不比太关心它。
  • argc 当从Erlang调用NIF的时候,传递给它的参数个数。后面我们将详述。
  • argv 传递给NIF的参数的数组。

读取Erlang/Elixir类型的值为C类型的值

我们从Elixir调用 fast_compare ,如下:

1
2
fast_compare(99, 100)
#=> -1

当执行 fast_compare ,argc为2,argv 是 99 和 100 组成的数组。然而,这些参数的类型是 ERL_NIF_TERM ,因此我们必须将它们“转换”为C的数据类型才能操作它们。erl_nif.h 提供了函数将Erlang的数据类型转换为C的数据类型。在本例子里,我们需要用enif_get_int这个函数。enif_get_int的签名如下:

1
int enif_get_int(ErlNifEnv *env, ERL_NIF_TERM term, int *ip);

我们必须传入变量 env,我们需要转换的Erlang数据(从argv中取出的),以及转换得到的值所存储的地址。

将C类型的值转换为Erlang类型的值

erl_nif.h 提供了几个 enif_make_* 类似的函数来将C类型的值转换为Erlang类型的值。它们都有相似的签名(只是根据被转换的数据类型不同而有差别),并且它们都返回 ERL_NIF_TERM类型的值。在本例子中,我们需要 enif_make_int 函数,它的签名如下:

1
ERL_NIF_TERM enif_make_int(ErlNifEnv *env, int i);

编写NIF

我们已经知道如何在Erlang类型数据和C类型数据之间进行转换,那么编写NIF就很直观了。

1
2
3
4
5
6
7
8
9
10
11
12
static ERL_NIF_TERM
fast_compare(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) {
int a, b;
// Fill a and b with the values of the first two args
enif_get_int(env, argv[0], &a);
enif_get_int(env, argv[1], &b);

// Usual C unreadable code because this way is more true
int result = a == b ? 0 : (a > b ? 1 : -1);

return enif_make_int(env, result);
}

连接我们的C

我们现在必须将我们写的函数导出到Erlang。我们必须使用ERL_NIF_INIT这个宏。

1
ERL_NIF_INIT(erl_module, functions, load, upgrade, unload, reload)
  • erl_module 是Erlang模块,我们导出的函数将被定义在里面。它不需要被双引号括起来,因为它将被ERL_NIF_INIT字符串化(例如,用my_module而不是用”my_module”);
  • functions 是ErlNifFunc结构类型数据的数组,它定义哪些NIF被导出,以及对应的Erlang函数和它的参数个数;
  • load,upgrade,unload,reload是函数指针,它们指向那些NIF被装载卸载等操作的回调函数;我们现在不太关心这些回调函数,把它们全部设置为NULL。

我们所需的所有元素都准备好了。完整的C文件如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include "erl_nif.h"

static ERL_NIF_TERM
fast_compare(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) {
int a, b;
enif_get_int(env, argv[0], &a);
enif_get_int(env, argv[1], &b);

int result = a == b ? 0 : (a > b ? 1 : -1);
return enif_make_int(env, result);
}

// Let's define the array of ErlNifFunc beforehand:
static ErlNifFunc nif_funcs[] = {
// {erl_function_name, erl_function_arity, c_function}
{"fast_compare", 2, fast_compare}
};

ERL_NIF_INIT(Elixir.FastCompare, nif_funcs, NULL, NULL, NULL, NULL)

要记得我们必须要在ERL_NIF_INIT宏里使用Elixir模块的全名的原子(是Elixir.FastCompare而不是FastCompare)。

编译我们的C代码

NIF文件应该编译为 .so 共享库。编译标志在不同的系统和编译器中有所不同,但它们应该看起来像这样:

1
2
3
$ cc -fPIC -I$(ERL_INCLUDE_PATH) \
-dynamiclib -undefined dynamic_lookup \
-o fast_compare.so fast_compare.c

使用这个命令,我们用一些为了生产动态代码的标志把 fast_compare.c 编译为 fast_compare.so (-o fast_compare.so)。注意我们如何把 $(ERL_INCLUDE_PATH) 包含在搜索路径里:这个路径包含了erl_nif.h 头文件。这个路径通常在Erlang的安装目录里,即 lib/erts-VERSION/include。

在Elixir中装载NIF

剩下的事情是装载在Elixir模块 FastCompare 里定义的NIF。如Erlang中关于NIF的文档所建议,钩子 @on_load 是做这件事的最适合的地方。

请注意,对于我们要定义的每个NIF,我们也需要在加载模块中定义相应的Erlang / Elixir函数。 这可以被利用来在NIF不可用的情况下定义例如回退代码。

1
2
3
4
5
6
7
8
9
10
11
12
# fast_compare.ex
defmodule FastCompare do
@on_load :load_nifs

def load_nifs do
:erlang.load_nif('./fast_compare', 0)
end

def fast_compare(_a, _b) do
raise "NIF fast_compare/2 not implemented"
end
end

:erlang.load_nif/2 的第二个参数可以是任何东西,它会被传递给我们上面提到的load回调函数。 你可以看看erlang.load_nif/2的文档以获取更多信息。

搞定!我们可以在IEx里测试一下我们的模块:

1
2
3
iex> c "fast_compare.ex"
iex> FastCompare.fast_compare(99, 100)
-1

其他例子

写“纯”的NIF(没有副作用,只是转换)非常有用。我非常喜欢的一个例子是 devinus/markdown 这个Elixir库:这个库用NIF封装了一个C的markdown解析器。这个用例是完美的,因为将Markdown转换为HTML可能是一项昂贵的任务,而通过将该工作委托给C来做可以获得更好的性能。

有用的东西:资源

正如我上面提到的,NIF的一个非常有用的地方是包装已有的C库。然而,这些库常常提供它们自己的数据抽象和数据结构。例如,一个C的数据库驱动导出一个 db_conn_t 类型来表示一条数据库链接,其定义如下:

1
2
3
typedef struct {
// fields
} db_conn_t;

相应的函数初始化链接、发起查询、释放链接,如下所示:

1
2
3
db_conn_t *db_init_conn();
db_type db_query(db_conn_t *conn, const char *query);
void db_free_conn(db_conn_t *conn);

如果我们能够在Erlang/Elixir里处理db_conn_t数据类型并且在NIF调用之间传递它们的话,这将非常有用。NIF的API有一个叫做 resources 的概念。没有比用Erlang的官方文档更好的方式来快速解释什么是 resources

资源对象的使用是一种从NIF返回指向原生数据结构的指针的安全方法。 资源对象只是一块内存。 […].

资源是内存块,我们可以构建并返回指向该内存的安全指针作为Erlang的类型数据。

让我们来探讨一下如何在NIF内部包装上面简单的API。 我们将从这个骨架C文件开始:

1
2
3
4
5
6
7
8
9
10
#include "db.h"
#include "erl_nif.h"

typedef struct {
// fields here
} db_conn_t;

db_conn_t *db_init_conn();
db_type db_query(db_conn_t *conn, const char *query);
void db_free_conn(db_conn_t *conn);

资源的创建

要创建一个资源,我们必须要使用 enif_alloc_resource 函数的帮助来分配一些内存。从这个函数的签名你可以看出它和 malloc 函数相似(原则上):

1
void *enif_alloc_resource(ErlNifResourceType *res_type, unsigned size);

enif_alloc_resource 的第一个参数是一个资源类型(这只是我们用来区分不同类型资源的东西),第二个参数是需要分配的内存大小,返回值是已分配内存的指针。

资源类型

资源类型是用 enif_open_resource_type 函数来创建的。我们可以在我们的C文件里声明资源类型作为全局变量。同时,利用传递给 ERL_NIF_INIT的load回调函数的便利性来创建资源类型并且把它们赋值给全局变量。如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ErlNifResourceType *DB_RES_TYPE;

// 每次当一个资源被释放的时候这个函数被调用
// 资源被释放在enif_release_resource函数被调用
// 以及Erlang回收内存的时候发生
void
db_res_destructor(ErlNifEnv *env, void *res) {
db_free_conn((db_conn_t *) res);
}

int
load(ErlNifEnv *env, void **priv_data, ERL_NIF_TERM load_info) {
int flags = ERL_NIF_RT_CREATE | ERL_NIF_RT_TAKEOVER;
DB_RES_TYPE =
enif_open_resource_type(env, NULL, "db", db_res_destructor, flags, NULL);
}

创建资源

我们现在可以包装db_init_conn并且创建我们的资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static ERL_NIF_TERM
db_init_conn_nif(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) {
// 让我们给一个 db_conn_t 指针分配内存
db_conn_t **conn_res = enif_alloc_memory(DB_RES_TYPE, sizeof(db_conn_t *));

// 让我们创建一条链接并且把它拷贝到指针所指的内存
db_conn_t *conn = db_init_conn();
memcpy((void *) conn_res, (void *) &conn, sizeof(db_conn_t *));

// 我们现在可以让Erlang类型数据持有这个资源...
ERL_NIF_TERM term = enif_make_resource(env, conn_res);
// ...然后释放这个资源以便在Erlang垃圾回收的时候它将被释放
enif_release_resource(conn_res);

return term;
}

获取资源

为了包装db_query,我们需要获取 db_init_conn_nif 返回的资源。为实现这个功能,我们需要使用 enif_get_resource 函数。

1
2
3
4
5
6
7
8
9
10
11
12
static ERL_NIF_TERM
db_query_nif(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) {
db_conn_t **conn_res;
enif_get_resource(env, argv[0], DB_RES_TYPE, (void *) conn_res);

db_conn_t *conn = *conn_res;

// 我们现在可以运行我们的查询
db_query(conn, ...);

return argv[0];
}

在Elixir里使用资源

让我们跳过在DB模块里导出我们创建的NIF,直接跳到IEx shell环节,而且假设C代码已经编译并被DB模块装载进虚拟机了。正如我前面所述,当资源返回给Erlang/Elixir的时候完全是一个不透明的数据。它们表现得就像是空的的二进制数据。

1
2
3
4
iex> conn_res = DB.db_conn_init()
""
iex> DB.db_query(conn_res, ...)
...

因为资源是不透明的数据,除了将它们回传给其他NIF,你无法在Erlang/Elixir里对它进行任何有意义的处理。它们的行为和看起来像二进制数据,这甚至可以导致问题,因为他们可以被误认为二进制数据。基于这个缘故,我建议将资源包装到结构当中。这样我们可以限制我们的公共API仅能处理结构并且在内部处理资源。我们也可以通过实现结构的 Inspect 协议来获得好处,这种方式使得我们可以安全地检测资源,而隐藏了它们看起来像是二进制数据的事实。

1
2
3
4
5
6
7
defmodule DBConn do
defstruct [:resource]

defimpl Inspect do
# ...
end
end

用Mix编译

Mix提供了一个特性,叫做Mix 编译器。每一个Mix项目在编译的时候可以指定一个编译器列表来运行。新的Mix编译器是自动编译C源代码的完美场所。对于本节的范围,假设我们正在构建一个叫做 :my_nifs 的Elixir应用程序,该应用程序将使用my_nifs.c C源文件中的NIF。

首先我们创建 Makefile 来编译C源码(反正我们可能会这么做)。

1
2
3
4
5
6
ERL_INCLUDE_PATH=$(...)

all: priv/my_nifs.so

priv/my_nifs.so: my_nifs.c
cc -fPIC -I$(ERL_INCLUDE_PATH) -dynamiclib -undefined dynamic_lookup -o my_nifs.so my_nifs.c

这个 Makefile 文件假设 my_nifs.c 存储在我们的Mix项目的根目录。我们将把 .so 共享库存放在我们应用的 priv 目录中,以便在发布的时候它是可用的。现在,无论何时,只要我们修改了 my_nifs.c ,然后运行 make ,priv/my_nifs.so 都会被重新编译。

我们现在可以挂接一个只调用make的新的Mix编译器。在 mix.exs 中,我们来实现之:

1
2
3
4
5
6
defmodule Mix.Tasks.Compile.MyNifs do
def run(_args) do
{result, _errcode} = System.cmd("make", [], stdout_to_stderr: true)
IO.binwrite(result)
end
end

我们调用 IO.binwrite/1 来将 make 的运行结果输出到终端。在一个真实的场景里,我们肯定要检查 make 的结果,同时也要确认 cc 和 make 已经安装到系统里,并且其路径可用;不过在这里,我们简单地忽略了这些步骤。

我们现在需要将 :my_nifs 编译器添加到 :my_nifs 应用的编译器列表。

1
2
3
4
5
6
7
8
9
10
# in mix.exs
defmodule MyNifs.Mixfile do
use Mix.Project

def project do
[app: :my_nifs,
compilers: [:my_nifs] ++ Mix.compilers,
...]
end
end

现在,任何时候我们运行 $ mix compiler ,我们的C代码就被自动重新编译(如果需要)。当其他库把 :my_nifs 作为依赖的话,这个过程也会一样地执行,因为现在运行 make 是 :my_nifs 项目编译的一个过程。

结论

这是一个很长的帖子,但我希望我覆盖了NIF的大部分内容。如你所见,在Erlang/Elixir里使用NIF是相当方便的。正如本文开头提到的那样,由于NIF的脆弱性(记住NIF可能导致整个Erlang虚拟机崩溃)和速度要求,因此要谨慎使用NIF,而且它并不总是正确的工具。

感谢您的阅读!

原文链接: http://andrealeopardi.com/posts/using-c-from-elixir-with-nifs/