各種Scheme処理系でFFIコールバックを実装する

yuniではコールバックのシグネチャも一種類に絞ることにしている。yuniのFFI互換層はNCCCなC関数しか呼び出せないが、コールバックとして書けるのもNCCCに絞ることになる。

  • C言語での NCCC 関数のプロトタイプ
void func(uint64_t* in, int in_len, uint64_t* out, int out_len); // 引数はuint64_tの配列に詰め込み、結果もuint64_tの配列に受けとる
(define (func in in-len out out-len) ...) ;; inやoutは64bit巾の数値のベクタ(専用手続きでアクセスする)

前回( http://d.hatena.ne.jp/mjt/20170211/p1 )書いたように、適当なトランポリンを生成することで任意のC APIをNCCCシグネチャに変換することができる。同様に、Scheme側のコードも適切に生成してやることで普通のScheme処理系と同じような見た目のFFIライブラリを作ることも原理的には可能になる。(通常のシチュエーションではあまり役に立たないのでやらないけど)

移植層のAPI構成

というわけで、移植層のAPI -- つまり、各処理系毎に実装の必要があるものも、以下の3機能に絞ることができる:

  1. (Scheme) yuni-nccc-proc-register: SchemeからNCCC手続きを登録する
  2. (Scheme) yuni-nccc-proc-release: Schemeから登録したNCCC手続きを抹消する
  3. (C) yuni_nccc_proc_call: CからSchemeで書かれたNCCC手続きを呼び出す(処理系によっては存在しない)

これらは"普通のCコールバック"がポインタ1つをコンテキストとして受けとれることを前提とする。常識的なC APIは、APIがコールバックを取る場合はユーザデータとしてポインタ1つ分を自由に使えるものとして確保していることが普通で、そのポインタを借用する形でScheme手続きを渡すことになる。
前回の記事で触れたChezやRacketのような動的に関数ポインタを生成できる処理系では、yuni_nccc_proc_call APIは必要無い。別に単純なトランポリンを要求しても良いが、特に理由なくネイティブコードを要求することは避けたいので特別扱いしている。実際にはそれなりの割合のScheme処理系が動的に関数ポインタを生成できる。
つまり、yuni_nccc_proc_callが不要な処理系ではこういうgeneric版をyuniFFIのバインディング側に持たせて使用する:

typedef void(*yuni_nccc_proc_t)(uint64_t* in, int in_len,
                                uint64_t* out, int out_len);

void
yuni_nccc_proc_call_generic(uintptr_t proc, uint64_t* in, int in_len,
                            uint64_t* out, int out_len){
    yuni_nccc_proc_t func;

    func = (yuni_nccc_proc_t)proc;
    func(in, in_len, out, out_len);
}

GaucheやChibi-scheme、その他言語組込みの動的FFIが無い処理系ではこのような手続きを処理系毎に用意する必要がある。

処理系のApplyを直接呼ぶ処理系: Chibi-schemeGauche

Chibi-schemeGaucheには言語組込みのFFIは存在しないため、yuniで独自のFFIを実装している。

Chibi-schemeでは、手続きの呼び出しを含めた全てのヒープ操作にctxを渡す必要があるため、コールバックオブジェクトはctxと実際の手続きオブジェクトのconsとしている。

  • コールバックオブジェクトの生成
static sexp
sexp_yuniffi_nccc_proc_register(sexp ctx, sexp self, sexp_sint_t n,
                                sexp proc){
    sexp_gc_var2(res, resptr);
    REQUIRE(ctx, self, proc, sexp_procedurep, SEXP_PROCEDURE);
    sexp_gc_preserve2(ctx, res, resptr);
    res = sexp_cons(ctx, ctx, proc);
    resptr = sexp_make_cpointer(ctx, SEXP_CPOINTER, (void*)(uintptr_t)res,
                                 SEXP_FALSE, 0);
    sexp_preserve_object(ctx, res);
    sexp_gc_release2(ctx);
    return resptr;
}
  • コールバックオブジェクトの呼び出し
static void
do_call(sexp ctx, sexp proc,
        uint64_t* in, int in_len, uint64_t* out, int out_len){
    sexp_gc_var1(args);
    sexp_gc_preserve1(ctx, args);
    args = sexp_list2(ctx, sexp_make_cpointer(ctx, SEXP_CPOINTER,
                                              out, SEXP_FALSE, 0),
                      sexp_make_fixnum(out_len));
    args = sexp_cons(ctx, sexp_make_fixnum(in_len), args);
    args = sexp_cons(ctx, sexp_make_cpointer(ctx, SEXP_CPOINTER,
                                             in, SEXP_FALSE, 0), args);
    sexp_apply(ctx, proc, args);
    sexp_gc_release1(ctx);
}

chibi-schemeはprecise GCを採用しているため、sexp_preserve_objectでコールバックオブジェクトが自動的に解放されないように保護する。手続きオブジェクトを評価するにはsexp_apply()を使う。Chibi-schemeにはGaucheのような引数を直接構築して評価する手続きは無いようだ。
Gaucheではchibi-schemeのような制約は無く、コールバックを実際に呼び出すためには手続きオブジェクト自体が有れば十分となる。

  • コールバックオブジェクトの生成
ScmObj
yuniffi_nccc_proc_register(ScmObj proc){
    ScmObj* box;
    if(!SCM_PROCEDUREP(proc)){
        Scm_Error("proc: must be a procedure", proc);
        return SCM_UNDEFINED;
    }

    box = GC_MALLOC_UNCOLLECTABLE(sizeof(ScmObj));
    *box = proc;

    return YUNIPTR_BOX(box);
}
  • コールバックオブジェクトの呼び出し
void
callback_bridge(uintptr_t procobjptr,
                uint64_t* in, int in_len,
                uint64_t* out, int out_len){
    ScmObj* box = (ScmObj*)(void*)procobjptr;
    Scm_ApplyRec4(*box,
                  YUNIPTR_BOX(in), Scm_MakeInteger(in_len),
                  YUNIPTR_BOX(out), Scm_MakeInteger(out_len));
}

GaucheではGenericなCポインタ型は無いためYUNIPTRとして自前のポインタ型を用意している。手続きの呼び出しは呼び出し元のFFI呼び出しコンテキストをそのまま使って良いのでScm_ApplyRec4() APIを使う。
GC_MALLOC_UNCOLLECTABLE()はGaucheGCであるBoehmGCのAPIで、SCM_MALLOCのようなGaucheが用意しているAPIだとcollectableなオブジェクトになってしまうためここでは直接BoehmGCを呼んでいる。

まだ良く判ってない処理系: RacketとGuile

残りの処理系はScheme側だけで実装できる。

Racketはfunction-ptr手続きに関数型を渡せばコールバック用のポインタを生成させることができる。

(define (yuniffi-nccc-proc-register proc)
  (function-ptr proc nccc-func))

(define (yuniffi-nccc-proc-release proc) #t)
(define nccc-func
  (_fun _gcpointer _int _gcpointer _int -> _void))

yuniffi-nccc-proc-release はどうやって実装するのかわからなかったのでno-opにしている。Racketでは関数型定義の方に生成コールバック手続きを設定できるので、それを使って寿命を管理するのが正しいような気がしている。
Guileはprocedure→pointer手続きを使う。ただ、Scheme側からuncollectableに設定する良い方法が見当たらなかった。

(define (yuniffi-nccc-proc-register proc)
  (procedure->pointer void proc
                      (list '* int '* int)))
(define (yuniffi-nccc-proc-release proc) #t)
(define (yuniffi-callback-helper) #f)

とにかく、RacketとGuileではハッシュテーブルか何かにポインタを詰めておいてGCから保護する追加の対策が必要になる。

FFIコールバックの確保/解放がある処理系: ChezとSagittariusとVicare

yuniでは基本的にはFFIコールバックの寿命は明示的に管理する。というわけで、これらの処理系では解放手続きを実装することができる:

Chezではコールバックオブジェクトとポインタは別のオブジェクトになっているが、それぞれが相互に変換できるため、APIとしてはコールバックのポインタだけを扱うことができる。

(define (yuniffi-callback-helper) #f)
(define (yuniffi-nccc-proc-register proc)
  (let ((r (foreign-callable proc (void* int void* int) void)))
   (lock-object r)
   (foreign-callable-entry-point r)))
(define (yuniffi-nccc-proc-release ptr)
  (unlock-object (foreign-callable-code-object ptr)))

コールバックオブジェクトを確保するには、foreign-callableで確保したあとlock-objectでロックし、foreign-callable-entry-pointでポインタを得る。解放時はforeign-callable-code-objectでポインタからオブジェクトを逆算し、unlock-objectでアンロックすることになる。
SagittariusもChez同様コールバックオブジェクトとポインタは別のオブジェクトになっている。

(define (yuniffi-callback-helper) #f)
(define (yuniffi-nccc-proc-register proc)
  (c-callback void (void* int void* int) proc))
(define (yuniffi-nccc-proc-release proc)
  (free-c-callback proc))
(define-c-struct callbackbox
                 (callback fn))

(define (bv-write/w64ptr! x off v)
  (cond
    ((callback? v)
     (c-struct-set! (bytevector->pointer x off) callbackbox 'fn v))
    (else
      (bv-write/u64! x off (pointer->integer v)))))

Chezと違いSagittariusはポインタとコールバックの相互変換ができないため、ポインタを扱う手続きの方でコールバックかどうかをチェックして処理を分ける必要がある。yuniでは移植性の都合で数値とポインタを区別しているため、この違いは移植レイヤで吸収することができる。また、pointer-set-callback!手続きは無く、pointer-set-pointer!手続きにコールバックを渡すとエラーにならず常にゼロがセットされるため構造体を定義してそれへの代入で代替している。
Vicareでは、ChezやSagittariusとは異なりコールバックとポインタは同じオブジェクトとして扱うことができる。

(define nccc-callback-maker
  (make-c-callback-maker 'void '(pointer signed-int pointer signed-int)))
(define (yuniffi-callback-helper) #f)
(define (yuniffi-nccc-proc-register proc)
  (nccc-callback-maker proc))
(define (yuniffi-nccc-proc-release proc)
  (free-c-callback proc))

vicareはコールバックを生成する手続きを生成する手続きを使用して事前にcallback-makerを用意しておく必要がある。これはScheme→C方向の呼び出しでも同様(callout-maker)で、毎回libffiのための型オブジェクトを生成する必要が無いので多少効率的と言える。