各種処理系のFFIコールバック仕様を見てみる会

YuniFFIは今のところ10くらいの処理系のScheme→C呼び出しを抽象化している。しかし、コールバックを取るC APIは地味に多いため、コールバックにも真面目に対応しないといけない。。
YuniFFIではScheme→Cの呼び出しAPIをNCCCと呼んでいる特定のCプロトタイプのもの"だけ"実装している。事前にネタバレをしておくと、コールバックも同様となる

  • NCCC 関数のプロトタイプ
void func(uint64_t* in, int in_len, uint64_t* out, int out_len); // 引数はuint64_tの配列に詰め込み、結果もuint64_tの配列に受けとる

YuniFFIにおいてSchemeから呼べるのは上記のプロトタイプを持つAPIだけだが、YuniのSDKは任意のC APIをNCCC関数にwrapするC関数(forwardスタブ)を生成するツール付きとしている。このため、forwardスタブを通すことで通常のCライブラリとインターフェースできる。
YuniFFIでのコールバックのサポートとは、即ちScheme手続きをNCCC呼び出し規約を持つC関数ポインタに変換する機構を各種Scheme処理系向けに用意することと言える。
通常のC APIがコールバックを取るときは当然NCCCではないAPI独自のシグネチャを持つ関数ポインタを受ける。このため、何らかの変換機構が必要となるが、コールバック関数にいわゆる"コンテキスト"としてポインタを渡すことができるAPIであれば、NCCC関数を呼び出すための事前に用意したコールバック関数(backwardスタブ)を用意することで、NCCC関数だけをサポートした処理系が任意の処理をコールバックとして提供できる。つまり、コンテキストとなるポインタが指す領域に、NCCCなコールバック関数のアドレスと真のコンテキスト値を置いておき、そちらを呼ぶコードをbackwardスタブとして生成しておけば良い。(コンテキスト引数を持たない qsort のようなAPIは、不正なテクニックを駆使してコンテキスト引数相当の何かを用意する必要がある。あとで書く。)
例えば、コールバックを取るC API foreach_byte() が有ったとして、

void /* C API。この関数をScheme側からコールバックを与えつつ呼びたい */
foreach_byte(uint8_t* in, int count,
             void (* cb)(int c, void* p), void* ctx){
    for(int i=0; i!=count; i++){
        cb(in[i], ctx);
    }
}

backwardスタブ foreach_byte_cb_to_nccc() を生成しておけば、

typedef struct nccc_ctx_t {
    void (*nccc_func)(uint64_t* in, int in_len,
                      uint64_t* out, int out_len);
    void* ctx;
};

void /* Backwardスタブ(任意のAPIシグネチャでNCCC関数を呼びだす) */
foreach_byte_cb_to_nccc(int c, void* p){
    uint64_t param[2]; /* c と p */
    uint64_t bogus = 0;
    nccc_ctx_t* ctx = (nccc_ctx_t*)p;

    param[0] = c;
    param[1] = p;

    ctx->nccc_func(&param, 2, &bogus, 1);
}

以下のようなNCCC呼び出し規約で実装されたコールバックを、

void /* NCCCなCallbackその1 - 実際はSchemeでbytevector-u64-native-ref等を駆使して書かれる */
my_callback(uint64_t* in, int in_len, uint64_t* out, int out_len){
    printf("called - %d\n",(int)in[0]);
}

void /* NCCCなCallbackその2 */
my_callback2(uint64_t* in, int in_len, uint64_t* out, int out_len){
    printf("another one - %d\n",(int)in[0]);
}

一度nccc_ctx_tにコンテキストをwrapすることで呼び出すことができる。

void /* テストコード。実際はSchemeでyuniの語彙を駆使して書かれる */
testcode(void){
    nccc_ctx_t ctx;
    ctx.ctx = 0x123456; /* 真のコンテキスト値 - 内容は適当 */
    uint8_t in[] = {0, 1, 2, 3};

    ctx.nccc_func = my_callback; /* 実際のcallbackのアドレスはctxを通じて指定する */
    foreach_byte(in, 4,
                 foreach_byte_cb_to_nccc /* ★ C関数に渡されるコールバックは固定になるのに注意 */,
                 &ctx);

    ctx.nccc_func = my_callback2;
    foreach_byte(in, 4,
                 foreach_byte_cb_to_nccc /* ★ 同上 */,
                 &ctx);

}

↓のような動的なFFIをネイティブサポートした処理系では呼び出しの度にbackwardスタブを経由し、処理系ネイティブの引数ハンドリングの恩恵を受けられない分効率が悪くなる。これは移植性のためということで仕方無い。

Chez、Guile、Guile、Racket、Sagittarius

これらの処理系はFFIコールバックをネイティブサポートしている。というわけで、これらの処理系ではSchemeコードだけで済む。

重要なのは、VicareのcallbackはGCされない点で、生成したC関数は明示的に解放する必要がある。常識的なシチュエーションではコールバックの必要なC APIは適当なポインタを引数に持っていたり、その他様々な方法を使ってコールバックをAPI呼び出しの度に生成することの無いように配慮できる。
処理系によってマーシャリング等の都合が異なるため色々なスタイルのAPIになっているが、やっていることは要するにlibffi( https://github.com/libffi/libffi )とかdyncall( http://www.dyncall.org/ )へのインターフェースなので機能的にはあまり差がない。(FFIの機能性については、dyncallのマニュアル http://www.dyncall.org/r0.9/manual.pdf が比較的充実している。)

Larceny

larcenyはちょっと特殊で、(-> ...)によって関数ポインタを生成できるが生成したトランポリンコードはGCされない。...とりあえずそういうことになっている。回収用のAPIも無い。

The current implementation of arrow types introduces an unnecessary space leak, because none of Larceny's current garbage collectors attempt to reclaim some of the structure allocated (in particular, the so-called trampolines) when functions are marshaled via arrow types.

ついでに言うとLarcenyはGCに対する領域の保護もできないため、GC collectableなオブジェクトをSchemeコールバック後にC側で消費すると領域が移動してしまっている可能性がある。というわけで、chibi-schemeGauche等の動的FFIが無い処理系と同様の考察が必要になる。(以前書いた通り、コールバックが有る場合はバッファのpinが必要 http://d.hatena.ne.jp/mjt/20160913/p1 )

Chibi-schemeGauche、Chicken、Gambit、...

動的FFIの無い処理系では、専用のCエクステンション(DLL)を用意してサポートすることになる。
Chicken、GambitのようなSchemeコンパイラでは、Schemeオブジェクトの受け渡しを気にする必要は無いので実装も十分にtrivialと考えられる。
Chibi-schemeGaucheのような処理系ではScheme手続きをC側で受け取り、引数を構築してapplyする必要があるため、処理系の用意するC APIを活用しなければならない。

GaucheはBoehmGCを使っていて、特にコンテキストを引き回す必要は無い。
chibi-schemeではアロケーションが発生する可能性のある呼び出しは常にctxを引き回す必要があり、ctxの値を知らないとヒープの操作が行えない。このctxはScheme→C呼び出しを行う際に確定する。が、chibi-scheme標準のFFIスタブジェネレータであるchibi-ffiではctxをC関数側からアクセスできないため、スタブを手で書く必要がある。

/* chibi-ffiがユーザC関数 yuniffi_pointerp()をwrapするために生成したコード */
sexp sexp_yuniffi_pointerp_stub (sexp ctx, sexp self, sexp_sint_t n, sexp arg0) {
  sexp res;
  res = sexp_make_integer(ctx, yuniffi_pointerp(arg0));
  return res;
}

例えば、yuniffi_pointerp手続きのために生成されたCスタブコード sexp_yuniffi_pointerp_stub() を見ていると、C関数yuniffi_pointerp()にctxを渡していない。通常のchibi-ffiを使用したワークフローでは、ユーザはyuniffi_pointerp()しか用意しないため、Scheme側からのスタブ呼び出し時には提供されているctxをC API側から利用できない。