NIF を書いてみた！

おはこんばんにちは、 2023年12月に入りました。いかがお過ごしでしょうか！
[NervesJP](https://nerves-jp.connpass.com/) のポジローです。

[#NervesJP のアドベントカレンダー](https://qiita.com/advent-calendar/2023/nervesjp)を作った際にトップバッターを埋めたことを若干、若干ね、後悔しています（頑張って書くとはどこかでいいましたが、結局ギリギリになって書いている自分がいます

などと弱音は吐きますけど、始めていきましょう！

本記事は、 Nerves と直接の関係はないのですが、2023年は NIF を書く機会があったのでその際に私が学んだことや気づいたことを紹介できたら思います。

### NIF を書くことになったきっかけ

ROS 2 クライアントの Elixir 版として @takasehideki がコードオーナーの [Rclex](https://github.com/rclex/rclex) があります。

ROS 2 クライアントには rclcpp や rclpy などがありますが、その Elixir 版です。
これらクライアントは rcl という ROS 2 の C言語ライブラリ を C++, Python から呼び出して 各言語でのユーザ API を提供しています。 Rclex も例外にもれず、 rcl を NIF から呼び出しています。

<img src="https://docs.ros.org/en/dashing/_images/ros_client_library_api_stack.png" alt="image.png" width="640">

Rclex は 2022年、2023年とコントリビュートさせていただく機会があり、そのプロセスで理解が深まったことで「動く Nerves」（カレンダー別日で書きます）を作れるのでは？となり実現もできました。

![roscon2023_trim.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/369713/2efadcdc-239e-6e4e-8d25-a2f22b933b5d.gif)
※ 動画 was made by @takasehideki & @Shintaro_Hosoai

これらを経て、 Rclex の面白さを知り、「もっと発展させたい」と「こうなってたらいいのにな」というちょっと勝手ではありますが、そういった思いをもちました。

それがきっかけとなり Rclex にコントリビュートしようとなって、今は 0.10.0 系の開発に参加しています。
不定期ではありますが、開発ミーティングもしています。

0.10.0 系は再設計を行うため、 NIF もリライトすることになりました（しました）。
それが本記事につながっています。

### NIF を書く前に知っておくこと

ここからは NIF を書くことの話をしていくのですが、書き始める前に知っておいたほうがいいことがあるのでそれをさらっと紹介します。

#### NIF を使うことのトレードオフ

Erlang の公式ドキュメントにもあるように

> As a NIF library is dynamically linked into the emulator process, this is the fastest way of calling C-code from Erlang (alongside port drivers). Calling NIFs requires no context switches. But it is also the least safe, because a crash in a NIF brings the emulator down too.

https://www.erlang.org/doc/tutorial/nif.html

NIF を実装するとは emulator process である BEAM に動的にリンクされる共有ライブラリを実装することです。NIF である共有ライブラリは BEAM にリンクされるので、例外が発生した場合、その取り回しが悪いと BEAM ごとクラッシュします。処理の実行速度を手にする代わりにリスクを負うことになります。

[Rustler](https://github.com/rusterlium/rustler) は安全に NIF を書くために、 Rust で NIF を書くことを可能にするライブラリのようです。

が、 Rclex では C言語ライブラリである rcl を C の薄いラッパーとなる NIF でラップして呼び出すので Rustler は使わず、 C で NIF を書いています。

#### C で NIF を安全に書くにはどうしたらいいのか？

書き始める前に（書きながらだったかもだけど）、どのようにしたらできるだけ安全に C が書けるか悩んでいました。

このような場合、自分の無い頭を絞るよりは、先人たちの知恵を借りるのが得策です。

まず、 @zacky1972 が "Describe Robust NIFs: Elixir& C with Performance for Fault-tolerant Systems" という発表をされている動画があるので、まずこれを見ました。

https://www.youtube.com/watch?v=csZ1-VO8yGw

ポイントは以下の３つで、

> ① Appropriately set a conditional branch where each function call may return an uncertain value.

①各関数呼び出しが不確実な値を返す可能性がある条件分岐を適切に設定すること

> ② Perform error handling according to general conventions.

②一般的な規則に従ってエラー処理を実行すること

> ③ Make assertions that specify implicit preconditions and raise exceptions for Supervisor to handle.

③暗黙的な前提条件を指定するアサーションを作成し、（それに違反したら）スーパーバイザーが処理する例外を発生させること

で、それぞれについて解説されています。自身 NIF を実際に書いてみて、そのとおりだと思いました。これは後述します。

次に、（実際には実装しながら良い書き方を探す中で）既に実装され使われているコード参考にしました。
読んだのは、CouchDB の 以下のコードです。

https://github.com/apache/couchdb/blob/main/src/khash/c_src/khash.c

悩んだ時は既に使われている良い意味で枯れた実装を参考にしました。コードが公開されていることに感謝です。

### 実際に書いたコードを解説してみる

紹介するコードは Rclex の次期バージョンの開発ブランチ [0.10.0-dev](https://github.com/rclex/rclex/tree/0.10.0-dev) からです。2023年12月は存在しますが、それ以降に本記事を読んでそのブランチが存在しなかったら main を見てみて下さい。多分マージされているはず（そのはず、そうであれ！）です。

以下のコードは `rcl` の `context` というリソースの終了処理をラップした関数 [nif_rcl_fini](https://github.com/rclex/rclex/blob/0.10.0-dev/src/rcl_init.c#L40-L57) です。
※この関数を選んだのは比較的紹介しやすいと判断したからです。

```c
ERL_NIF_TERM nif_rcl_fini(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) {
  if (argc != 1) return enif_make_badarg(env);

  rcl_ret_t rc;
  rcl_context_t *context_p;

  if (!enif_get_resource(env, argv[0], rt_rcl_context_t, (void **)&context_p))
    return enif_make_badarg(env);
  if (!rcl_context_is_valid(context_p)) return raise(env, __FILE__, __LINE__);

  rc = rcl_shutdown(context_p);
  if (rc != RCL_RET_OK) return raise(env, __FILE__, __LINE__);

  rc = rcl_context_fini(context_p);
  if (rc != RCL_RET_OK) return raise(env, __FILE__, __LINE__);

  return atom_ok;
}
```

この関数は以下のようにバインディングしており、

```c
static ErlNifFunc nif_funcs[] = {
  ...
  { "rcl_fini!", 1, nif_rcl_fini, ERL_NIF_DIRTY_JOB_IO_BOUND },
  ...
};
```

Elixir からは以下のように呼び出される、引数が一つの例外を起こすことが想定される関数です。

```elixir
  rcl_fini!(context) # context は reference 型 の NIF リソース 
```

それでは、 `nif_rcl_fini` を少しずつ見ていきます。

まず、引数の数チェックです。

```c
if (argc != 1) return enif_make_badarg(env);
```

これは、どんな NIF 実装でも書かれていると思いますが、意図通りの引数の数で呼ばれてるかをチェックしています。意図と異なる場合は、問答無用で return です。 `enif_make_badarg` は badarg の例外を Elixir 側に返します。

引数の数があっていたら、引数が意図したリソース型であることを確認して、 Elixir の世界から C の世界に読み込みます。

```c
  if (!enif_get_resource(env, argv[0], rt_rcl_context_t, (void **)&context_p))
    return enif_make_badarg(env);
```

ここでも、意図と異なる場合は、問答無用で return です。

**NIF の関数ではまず、 Elixir の世界から C の世界に入ってくるものが意図通りのものであるかをチェックします。** これはそうですよね。だって、意図と違うものが入ってきたら困ってしまいます。意図と異なった時点で即、例外リターンで Elixir の世界に返しておけば、C の世界を守ることができ、それはすなわち BEAM を守ることになります。

:::note info
これはハンドリングとして general な convention なので、 @zacky1972 の言うところの ② に相当すると思います。
:::

次は、 rcl の機能を使ってさらに読み込んだ値をチェックしています。

```c
  if (!rcl_context_is_valid(context_p)) return raise(env, __FILE__, __LINE__);
```

context が valid でない場合、ここでは自作の `raise 関数` を呼び出して return しています。
関数名のとおり何らかの例外を返す関数です。

`raise 関数`については後述します。

ここからは雰囲気が分かったと思うのでまとめて説明します。

```c
  rc = rcl_shutdown(context_p);
  if (rc != RCL_RET_OK) return raise(env, __FILE__, __LINE__);

  rc = rcl_context_fini(context_p);
  if (rc != RCL_RET_OK) return raise(env, __FILE__, __LINE__);

  return atom_ok;
```

`rcl_shutdown` は context のシャットダウン処理をします。このシャットダウン処理は以下を返す可能性があります。

```
RCL_RET_OK: if the shutdown was completed successfully, or
RCL_RET_INVALID_ARGUMENT: if any arguments are invalid, or
RCL_RET_ALREADY_SHUTDOWN: if the context is not currently valid, or
RCL_RET_ERROR: if an unspecified error occur.
```

なので、ここまでのチェックで `RCL_RET_OK` が返らない限りは、想定し得ない異常なこと（メモリリークではないけどメモリが枯渇したとか、ハードが壊れたとか）が起きていると思います。
（そんなことが起きていたら NIF に関係なく BEAM もおかしくなっているはずです。）

なので、 `RCL_RET_OK` が返らないのであれば raise させています。 `rcl_context_fini`も同様です。

:::note info
関数から返りうる値を想定し、適切な条件分岐を書いていますし、ある意味で `RCL_RET_OK` が返るべきという assert を意図していますので @zacky1972 のポイントの ①、③に相当すると思います。
:::

で、すべて正常に終わったら `:ok` アトムを返しています。 atom_ok は自作です。これも後述します。

### 工夫

#### 自作の raise 関数

assert なそうあるべしという条件分岐にも関わらず、例外が発生した場合、開発中だとそれはバグであることが多い（かった）です。なので、それがどこで発生したかを分かるようにしたいモチベーションがありました。

そこで、以下のような自作の raise 関数を用意しました。

```c
static inline ERL_NIF_TERM raise(ErlNifEnv *env, const char *file, int line) {
  char str[1024];
  snprintf(str, sizeof(str), "at %s:%d", file, line);
  return enif_raise_exception(env, enif_make_string(env, str, ERL_NIF_LATIN1));
}

static inline ERL_NIF_TERM raise_with_message(ErlNifEnv *env, const char *file, int line,
                                              const char *message) {
  char str[1024];
  snprintf(str, sizeof(str), "at %s:%d %s", file, line, message);
  return enif_raise_exception(env, enif_make_string(env, str, ERL_NIF_LATIN1));
}
```

raise を利用する箇所が多くなるので、発生箇所を明確にするためにファイル名と行数が分かるように工夫しました。

Rclex ユーザーが例外を検出した場合は、 Rclex のバージョンと箇所を書いた issue を挙げてもらえれば、少なくともどこで起きたかは分かります。

#### よく使う atom は事前に作っておいて使い回す

```c
ERL_NIF_TERM atom_ok;
ERL_NIF_TERM atom_error;
ERL_NIF_TERM atom_true;
ERL_NIF_TERM atom_false;

void make_atoms(ErlNifEnv *env) {
  atom_ok    = enif_make_atom(env, "ok");
  atom_error = enif_make_atom(env, "error");
  atom_true  = enif_make_atom(env, "true");
  atom_false = enif_make_atom(env, "false");
}
```

これはよくある手法のようで、 CouchDB の khash.c でもそのような実装があります。

https://github.com/apache/couchdb/blob/3d60e4136a67f1be38d2b7a9763de874db9f96d4/src/khash/c_src/khash.c#L609-L614

`:ok`, `:error` なんかはよく使うので毎回作らず load 時に作っておけば、（実装も実行も）効率いいはずです。

### 避けては通れないかも DirtyNIF

以下のコードを書いた時に、触れていない点がありました。 `ERL_NIF_DIRTY_JOB_IO_BOUND` です。

```c
static ErlNifFunc nif_funcs[] = {
  ...
  { "rcl_fini!", 1, nif_rcl_fini, ERL_NIF_DIRTY_JOB_IO_BOUND },
  ...
};
```

:::note warn
**以降は私の想像が多いので、疑いながら読んで下さい**
:::

BEAM は erlexec により実行されるとその子プロセスとしてスケジューラーを作ります。
[erlinit が起動する beam.smp プロセスについて](https://qiita.com/pojiro/items/ed6e4aa57e65ebb25d30) のキャプチャを見ると

- scheduler が 4 つ
- dirty_cpu_scheduler が 4 つ
- dirty_io_scheduler が 10 つ

存在することが分かります。これは事実。

で、BEAM 上のプロセスは並行に処理されるようよしなに scheduler によって管理され実行されているはずです。これは想像。

では、BEAM に動的にリンクされる NIF はどうでしょうか？

https://www.erlang.org/doc/man/erl_nif の `Long-running NIFs` に以下の記載があります。

> As mentioned in the warning text at the beginning of this manual page, it is of vital importance that a native function returns relatively fast. It is difficult to give an exact maximum amount of time that a native function is allowed to work, but usually a well-behaving native function is to return to its caller within 1 millisecond.

想像ですが、通常の scheduler からは NIF の動作のスケジューリングはできないので、NIF の処理はできる限り早く終わらせろと言っているのだと思います。

そして、そうはいってもすぐに終わらせられない NIF の処理というのは存在するので、そのために Dirty NIF scheduler がその特性に合わせて CPUバウンド と IOバウンド の二種が用意されているのだと思います。

`within 1 millisecond` の記載がありますが、大事なのは 1 ミリという時間ではなく（だって CPU によるだろうし）、計算処理の多い NIF や IO 待ちが発生するような NIF は Dirty NIF として扱うべき場面があるということだと思います。

私の経験として、実際に Rclex の NIF のリライトにおいて `ERL_NIF_DIRTY_JOB_IO_BOUND` を設定せずに実装を進め、テストを回していた際にテストの実行時間が実行毎で安定しないことがありました。

https://www.erlang.org/doc/man/erl_nif の Dirty NIF の節に記載のある以下のようなことが起こったのかなと想像します。

> While a process executes a dirty NIF, some operations that communicate with it can take a very long time to complete. Suspend or garbage collection of a process executing a dirty NIF cannot be done until the dirty NIF has returned. Thus, other processes waiting for such operations to complete might have to wait for a very long time. Blocking multi-scheduling, that is, calling erlang:system_flag(multi_scheduling, block), can also take a very long time to complete. This is because all ongoing dirty operations on all dirty schedulers must complete before the block operation can complete.

これは、結果論ですが、`ERL_NIF_DIRTY_JOB_IO_BOUND`を設定することでその現象は発生しなくなりました。
（rcl はネットワーク処理をするので IO_BOUND を設定しました）

Rclex は、 Rclex から見ると 3rd party である rcl ライブラリを NIF 内で呼び出します。処理時間がどのようにかかるかの計測はできますが（実際benceeスクリプトを書いて処理時間の計測をしています）、 rcl のバージョンアップでそれがどのように変わるかは想定できません。なので Rclex では rcl を呼び出す部分は `ERL_NIF_DIRTY_JOB_IO_BOUND` を設定するようにしています。（大雑把ではありますが、それが現状です）

### まとめ

ここまで、NIF を書いた際のいろいろをつらつらと書いてみました。
参考になるかは、分かりませんがどこか一部でも読んでくださった方のどこかに引っかかるものがあれば嬉しいです。

もう今年も残すところ一ヶ月です。健康に気をつけて楽しい年の瀬を過ごしましょう！！

明日は、じゃかじゃん、 @MickeyOoh の記事がでます。お楽しみに！！