豪鬼メモ

一瞬千撃

C言語での非同期API

C++に引き続いて、C言語でも非同期APIを整備した。非同期APIC++のpromise/future機構を使えば簡単にできるのだが、それをCから使うにはそれなりの工夫が必要だった。


非同期APIとは、該当の処理をバックグラウンドで行わせながら、フォアグラウンドで別の仕事をするためのAPIである。例えば検索処理の場合、「検索を開始する」という操作で検索がバックグラウンドで開始されるが、それは検索処理をバックグラウンドで行うためのタスクを生成するだけなので、一瞬で処理が完了する。実際の処理は別のスレッドが担当する。呼び出し側であるメインのスレッドを便宜上フォアグラウンドと呼ぶが、そこでは他の処理を行う。検索結果が必要になった時に、「結果を取得する」という操作を行えば、バックグラウンドの実行が完了するのを待ち合わせた上で、結果のデータを得ることができる。

非同期APIなんてなくても、アプリケーションプログラマが自分でスレッドを立ててバックグラウンドで処理を行わせればよい。実際には毎回スレッドを立てるのは効率が悪すぎるので、スレッドプールを管理したりなどといった工夫も必要となる。ただ、Cでそれを書くのはめちゃくちゃ面倒くさいというか、複雑怪奇なライブラリを書くことになってしまって現実的ではない。一方、C++では標準ライブラリにpromiseとfugureという便利な機能があるので、それと自前のスレッドプールを組み合わせれば、現実的な規模で非同期処理を書くことができる。実際にTkrzwのライブラリ本体では非同期APIがある。重要なのはアプリケーションプログラマに、なるべく直接スレッドを触らせないことだ。

C++の非同期APIC言語で使えるようにしてみた。C++ではテンプレートによるジェネリクスが利用できるおかげで、様々な型を持つメソッド群を同じ操作体系でまとめることができた。それをCで表現するのには苦労したが、なんとか実現できたので紹介したい。典型的なユースケースは以下のような感じになる。

#include <stdio.h>
#include "tkrzw_langc.h"

int main(int argc, char** argv) {
  // データベースファイルを開く。
  TkrzwDBM* dbm = tkrzw_dbm_open("casket.tkt", true, "truncate=true");

  // 非同期データベースアダプタを準備する。ワーカスレッドは10個。
  TkrzwAsyncDBM* async = tkrzw_async_dbm_new(dbm, 10);
  
  // 非同期でSet操作を開始する。
  TkrzwFuture* set_future = tkrzw_async_dbm_set(async, "one", -1, "hop", -1, true);

  // Set操作の結果を取り出す。
  // ステータスオブジェクトはスレッドローカル変数に格納される。
  tkrzw_future_get(set_future);

  // 直前の操作のステータスオブジェクトを調べる。
  const int32_t status_code = tkrzw_get_last_status_code();
  if (status_code == TKRZW_STATUS_SUCCESS) {
    printf("OK\n");
  } else {
    printf("Error: %s: %s\n",
           tkrzw_status_code_name(status_code), tkrzw_get_last_status_message());
  }

  // Set操作で使ったfutureオブジェクトを破棄する。
  tkrzw_future_free(set_future);

  // Set操作をして、直後にfutureオブジェクトを破棄する。
  // 結果を取り出さないので、待ち合わせも行われない。
  tkrzw_future_free(tkrzw_async_dbm_set(async, "two", -1, "step", -1, true));
  tkrzw_future_free(tkrzw_async_dbm_set(async, "three", -1, "jump", -1, true));

  // 非同期でGet操作を開始する。
  TkrzwFuture* get_future = tkrzw_async_dbm_get(async, "one", -1);

  // Get操作の結果を取り出す。
  // ステータスオブジェクトはスレッドローカル変数に格納される。
  char* value_ptr = tkrzw_future_get_str(get_future, NULL);

  // 直前の操作のステータスオブジェクトを調べ、成功ならば値を表示する。
  if (tkrzw_get_last_status_code() == TKRZW_STATUS_SUCCESS) {
    printf("%s\n", value_ptr);
  }
  free(value_ptr);
  tkrzw_future_free(get_future);

  // Get操作で使ったfutureオブジェクトを破棄する。
  tkrzw_async_dbm_free(async);
  
  // データベースを閉じる。
  tkrzw_dbm_close(dbm);

  return 0;
}

全てのデータベースの操作は、開始と結果取得という二つの手順に分かれることになる。開始手順を踏むとTkrzwFutureというオブジェクトが返される。これをそのまま破棄してしまえば、結果の待ち合わせは行われない。結果を取得する際には、開始メソッドに応じた結果取得メソッドを呼ぶ必要がある。tkrzw_async_dbm_setはステータスだけを作るので、tkrzw_future_getで結果を取得する。tkrzw_async_dbm_getはステータスと文字列を作るので、tkrzw_future_get_strで結果を取得する。

重要なのは、開始メソッドと結果取得メソッドの間に任意の処理を入れられることだ。ゲームなどでたくさんのコルーチンを使うなら、開始メソッドと結果取得メソッドの間にyieldを入れることだろう。ユーザ対話型のアプリケーションでは、タイムアウト付きのwaitメソッドでfutureオブジェクトを調べるかもしれない。いずれにせよ、アプリケーションのコードではスレッドを直接操作しないで済ませている。

各種非同期が受け取ったパラメータの文字列は、中でコピーされて、タスクキューで持ち回される。これもAPIの利便性を左右する問題だ。非同期の場合、登録したタスクが終わるまで呼び出し側のスレッドが生きている保証がないので、タスクオブジェクトが持つデータは自身で完結している必要がある。いわゆる自己完結(self contained)という状態だ。C++のstd::stringは自己完結だが、C++のstd::string_viewやCのchar*はそうではない。受け取ったパラメータが依存するリソースに対する前提条件について明記する必要があるのだ。

詳しいことは、C言語の非同期APIの文書をご覧いただきたい。get、set、removeといった基本的なメソッドだけでなく、searchとかcompare_exchangeとかの便利なメソッドも実装してある。どれも使い方はだいたい同じで、開始と結果取得を分けて行う。


ライブラリ側の実装をする上で最大の課題となったのは、C++のfutureテンプレートをCの名前空間には公開できないことであった。C++のコードに現れるfutureテンプレートの型インスタンス毎にCのラッパー構造体を定義するというのが率直な方法だが、かなりたくさんの構造体を定義しなければならない。また、その各々に応じて生成関数と破棄関数とその他の操作関数を定義しなければならない。そこまでやると、さすがにシンボルの数が多くなりすぎて、覚えにくいAPIになってしまう。

そこで、futureテンプレートの全ての型インスタンスを代表するStatusFutureというクラスをC++で定義して。内部でRTTIを使って実際の型を判別するようにした。type_idとvoid*をメンバに持たせて実行時にキャストするという、ひどく汚い実装だ。しかし、そこに汚い部分を押し込めたおかげで、Cの層ではStatusFutureをラップしたTkrzwFutureという構造体のみを介してfutureの主要機能を利用できるようになった。しかし、それでも、実際の方に応じて取得関数を呼ぶようにドキュメントで指示しなければならないというダサい仕様が残ってしまっている。SetやRemoveはステータスだけを返すので tkrzw_future_get で結果を取得するが、Getはステータスと文字列を返すので tkrzw_future_get_str を呼ばなければならない。Searchはステータスとキーのリストを返すので、tkrzw_future_get_arrayを呼ばなければならない。GetMultiはステータスと文字列のマップを返すので、tkrzw_future_get_mapを呼ばなければならない。PythonRubyのように動的に戻り値の型を変えられるか、C++Javaのようにジェネリクスがあれば、こんなダサい感じにはならないのだが、まあCなんで仕方ない。とはいえ、なんとか覚えきれる範囲の複雑さで、非同期APIをCに落とし込むことには成功していると思う。


まとめ。データベースライブラリTkrzwで、C言語用の非同期APIを実装した。アプリケーションプログラマが、既存の同期APIを非同期化しようとすると非常に面倒だが、それをライブラリ側で肩代わりしているので、かなり簡単かつ完結に非同期処理を書くことができる。Cでこれ以上簡単に非同期処理は書けまい。しかし、そこまでしてCに拘る現場がどれほどあるのかは知らない。