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的时候,我们需要的所有东西(类型、函数、宏)。
|
C编译器并不知道erl_nif.h在哪里,因此,当稍后我们编译我们的程序的时候,必须指出它的所在。
现在,定义NIF的所有C文件有相似的结构:C函数列表,接着是被导出到Erlang/Elixir的C函数列表(以及它们的名字),最后,调用 ERL_NIF_INIT 宏,它执行把所有这个一切串联起来的神秘的事情。
在我们的例子里,C函数列表就只有 fast_compare 函数。这个函数的签名如下所示:
|
|
这里有两个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 ,如下:
|
|
当执行 fast_compare ,argc为2,argv 是 99 和 100 组成的数组。然而,这些参数的类型是 ERL_NIF_TERM ,因此我们必须将它们“转换”为C的数据类型才能操作它们。erl_nif.h 提供了函数将Erlang的数据类型转换为C的数据类型。在本例子里,我们需要用enif_get_int这个函数。enif_get_int的签名如下:
|
|
我们必须传入变量 env,我们需要转换的Erlang数据(从argv中取出的),以及转换得到的值所存储的地址。
将C类型的值转换为Erlang类型的值
erl_nif.h 提供了几个 enif_make_* 类似的函数来将C类型的值转换为Erlang类型的值。它们都有相似的签名(只是根据被转换的数据类型不同而有差别),并且它们都返回 ERL_NIF_TERM类型的值。在本例子中,我们需要 enif_make_int 函数,它的签名如下:
|
|
编写NIF
我们已经知道如何在Erlang类型数据和C类型数据之间进行转换,那么编写NIF就很直观了。
|
|
连接我们的C
我们现在必须将我们写的函数导出到Erlang。我们必须使用ERL_NIF_INIT这个宏。
|
|
- erl_module 是Erlang模块,我们导出的函数将被定义在里面。它不需要被双引号括起来,因为它将被ERL_NIF_INIT字符串化(例如,用my_module而不是用”my_module”);
- functions 是ErlNifFunc结构类型数据的数组,它定义哪些NIF被导出,以及对应的Erlang函数和它的参数个数;
- load,upgrade,unload,reload是函数指针,它们指向那些NIF被装载卸载等操作的回调函数;我们现在不太关心这些回调函数,把它们全部设置为NULL。
我们所需的所有元素都准备好了。完整的C文件如下所示:
|
|
要记得我们必须要在ERL_NIF_INIT宏里使用Elixir模块的全名的原子(是Elixir.FastCompare而不是FastCompare)。
编译我们的C代码
NIF文件应该编译为 .so 共享库。编译标志在不同的系统和编译器中有所不同,但它们应该看起来像这样:
|
|
使用这个命令,我们用一些为了生产动态代码的标志把 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不可用的情况下定义例如回退代码。
|
|
:erlang.load_nif/2 的第二个参数可以是任何东西,它会被传递给我们上面提到的load回调函数。 你可以看看erlang.load_nif/2的文档以获取更多信息。
搞定!我们可以在IEx里测试一下我们的模块:
|
|
其他例子
写“纯”的NIF(没有副作用,只是转换)非常有用。我非常喜欢的一个例子是 devinus/markdown 这个Elixir库:这个库用NIF封装了一个C的markdown解析器。这个用例是完美的,因为将Markdown转换为HTML可能是一项昂贵的任务,而通过将该工作委托给C来做可以获得更好的性能。
有用的东西:资源
正如我上面提到的,NIF的一个非常有用的地方是包装已有的C库。然而,这些库常常提供它们自己的数据抽象和数据结构。例如,一个C的数据库驱动导出一个 db_conn_t 类型来表示一条数据库链接,其定义如下:
|
|
相应的函数初始化链接、发起查询、释放链接,如下所示:
|
|
如果我们能够在Erlang/Elixir里处理db_conn_t数据类型并且在NIF调用之间传递它们的话,这将非常有用。NIF的API有一个叫做 resources 的概念。没有比用Erlang的官方文档更好的方式来快速解释什么是 resources 。
资源对象的使用是一种从NIF返回指向原生数据结构的指针的安全方法。 资源对象只是一块内存。 […].
资源是内存块,我们可以构建并返回指向该内存的安全指针作为Erlang的类型数据。
让我们来探讨一下如何在NIF内部包装上面简单的API。 我们将从这个骨架C文件开始:
|
|
资源的创建
要创建一个资源,我们必须要使用 enif_alloc_resource 函数的帮助来分配一些内存。从这个函数的签名你可以看出它和 malloc 函数相似(原则上):
|
|
enif_alloc_resource 的第一个参数是一个资源类型(这只是我们用来区分不同类型资源的东西),第二个参数是需要分配的内存大小,返回值是已分配内存的指针。
资源类型
资源类型是用 enif_open_resource_type 函数来创建的。我们可以在我们的C文件里声明资源类型作为全局变量。同时,利用传递给 ERL_NIF_INIT的load回调函数的便利性来创建资源类型并且把它们赋值给全局变量。如下所示:
|
|
创建资源
我们现在可以包装db_init_conn并且创建我们的资源。
|
|
获取资源
为了包装db_query,我们需要获取 db_init_conn_nif 返回的资源。为实现这个功能,我们需要使用 enif_get_resource 函数。
|
|
在Elixir里使用资源
让我们跳过在DB模块里导出我们创建的NIF,直接跳到IEx shell环节,而且假设C代码已经编译并被DB模块装载进虚拟机了。正如我前面所述,当资源返回给Erlang/Elixir的时候完全是一个不透明的数据。它们表现得就像是空的的二进制数据。
|
|
因为资源是不透明的数据,除了将它们回传给其他NIF,你无法在Erlang/Elixir里对它进行任何有意义的处理。它们的行为和看起来像二进制数据,这甚至可以导致问题,因为他们可以被误认为二进制数据。基于这个缘故,我建议将资源包装到结构当中。这样我们可以限制我们的公共API仅能处理结构并且在内部处理资源。我们也可以通过实现结构的 Inspect 协议来获得好处,这种方式使得我们可以安全地检测资源,而隐藏了它们看起来像是二进制数据的事实。
|
|
用Mix编译
Mix提供了一个特性,叫做Mix 编译器。每一个Mix项目在编译的时候可以指定一个编译器列表来运行。新的Mix编译器是自动编译C源代码的完美场所。对于本节的范围,假设我们正在构建一个叫做 :my_nifs 的Elixir应用程序,该应用程序将使用my_nifs.c C源文件中的NIF。
首先我们创建 Makefile 来编译C源码(反正我们可能会这么做)。
|
|
这个 Makefile 文件假设 my_nifs.c 存储在我们的Mix项目的根目录。我们将把 .so 共享库存放在我们应用的 priv 目录中,以便在发布的时候它是可用的。现在,无论何时,只要我们修改了 my_nifs.c ,然后运行 make ,priv/my_nifs.so 都会被重新编译。
我们现在可以挂接一个只调用make的新的Mix编译器。在 mix.exs 中,我们来实现之:
|
|
我们调用 IO.binwrite/1 来将 make 的运行结果输出到终端。在一个真实的场景里,我们肯定要检查 make 的结果,同时也要确认 cc 和 make 已经安装到系统里,并且其路径可用;不过在这里,我们简单地忽略了这些步骤。
我们现在需要将 :my_nifs 编译器添加到 :my_nifs 应用的编译器列表。
|
|
现在,任何时候我们运行 $ 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/