この週末の頑張りで、Tkrzwの実装がほぼ全て完了した。権利関係の処理が済むまでにもうちょいかかりそうなので、先に各種プログラミング言語でのAPIについて語りたい。C++、Java、Python、Rubyのインターフェイスを比較してみると面白い。
Tkrzw本体はC++で書かれていて、当然C++のインターフェイスが提供される。C++のアプリケーションは以下のようなコードになる。
#include "tkrzw_dbm_poly.h" using namespace tkrzw; int main(int argc, char** argv) { // DBMオブジェクトを作ってハッシュデータベースのファイルを開く。 PolyDBM dbm; dbm.Open("casket.tkh", true); // レコードを格納する。 dbm.Set("Japan", "Tokyo"); // レコードを検索する。 std::cout << dbm.GetSimple("Japan") << std::endl; // データベースファイルを閉じる。 dbm.Close(); return 0; }
PolyDBMというクラスは全てのDBMデータ構造のアダプタで、ファイルを開く際の拡張子でデータ構造を指定することができる。.tkh ならハッシュで、.tkt ならB+木で、.tks ならスキップリストといった具合だ
。
OpenやSetなどの操作は戻り値としてStatusというクラスのオブジェクトを返すので、そのコードを調べてエラーチェックを行うことができる。以下のような感じだ。Status::SUCCESSと比較する代わりにIsOKメソッドを呼んでもよい。
Status status = dbm.Open("casket.tkh", true); if (status != Status::SUCCESS) { std::cerr << "Error: " << status << std::endl; return 1; }
エラーは例外として通知したい派の人たちのために、StatusのオブジェクトにはOrDieというメソッドが用意してある。これは、内部コードがSUCCESSではない場合にのみ、それをラップした例外StatusExceptionを投げる。以下のように連結して書くと便利だ。
dbm.Open("casket.tkh", true).OrDie();
チューニングパラメータを与えたい場合には、Openの代わりにOpenAdvancedというメソッドを使い、それに文字列を詰めたstd::mapを渡す。
const std::map<std::string, std::string> params = { {"truncate", "true"}, {"num_buckets", "10000"}}; dbm.OpenAdvanced("casket.tkh", true, params);
リソース管理に関しては、ガベージコレクションがあるJavaやRubyよりも、C++の方が楽だ。上記の例では、DBMのオブジェクトはスタックに貼られるので、該当のスコープを抜ければ暗黙的にデストラクタが呼ばれる。Closeメソッドを呼ばずに脱出したとしても、デストラクタの中で確実にデータベースは閉じられる。RAIIという手法だ。
次にJavaインターフェイスを見てみよう。アプリケーションのコードはこんな感じになる。
public class Example2 { public static void main(String[] args) { // DBMオブジェクトを作ってハッシュデータベースのファイルを開く。 DBM dbm = new DBM(); try { dbm.open("casket.tkh", true); // レコードを格納する。 dbm.set("Japan", "Tokyo"); // レコードを検索する。 System.out.println(dbm.get("Japan")); // データベースを閉じる。 dbm.close(); } finally { // リソースを解放する。 dbm.destruct(); } } }
やっている事はC++版と完全に一緒だ。openやsetの戻り値はStatusを返すので、エラーチェックはそのコードを調べて行う。Status.SUCCESSと比較する代わりにisOKメソッドを呼んでもよい。
Status status = dbm.open("casket.tkh", true); if (status != Status.SUCCESS) { System.err.println("Error:" + status.toString()); return; }
StatusのorDieも実装している。そもそもopenが例外を投げろよと言いたくなる派もあるだろうが、その辺りは適当にラッパーを書いて調整していただきたい。
dbm.open("casket.tkh", true).orDie();
チューニングパラメータは文字列のMapで与えることができるのだが、簡単に書けるように単一の文字列で指定できるパーザのユーティリティを用意した。
Map<String, String> params = Utility.parseParams("truncate=true,num_buckets=10000"); dbm.open("casket.tkh", true, params);
Javaにおけるリソース管理はちょいと厄介だ。ガベージコレクションで解放すべきなのはメモリだけであり、ファイル接続やその他の資源は明示的に解放せねばならない。しかしRAIIは使えないし、例外安全も考えなければいけない。よって、try-finally構文を使う。上記の例では、tryブロックの最後でcloseを呼んでいて、finallyブロックの中ではdestructだけを呼んでいる。destructは閉じていないデータベースを暗黙的に閉じるので、これでも安全なのだ。closeに至る前に例外が飛んだ場合に報告したいのはその例外であって、closeの際に起こるかもしれない例外ではないので、destructの中で発生したエラーは報告する必要は普通はない。でもやっぱりfinallyブロックでcloseを呼びたいという人は、以下のような書き方をしてもよいが、複雑になる上に、tryブロックの中で起きた例外が隠蔽される可能性がある。
try { ... } finally { if (dbm.isOpen()) { dbm.close().orDie(); } dbm.destruct(); }
JDK7からのtry-with-resouces文には対応していない。AutoClosableインターフェイスを実装するためのcloseメソッドの名前が既存のcloseと被るからまずいのだ。どうしたものか。
次にPythonインターフェイス。アプリケーションのコードはこんな感じになる。
from tkrzw import * # DBMオブジェクトを作ってハッシュデータベースのファイルを開く。 dbm = DBM() dbm.Open("casket.tkh", True) # レコードを格納する。 dbm.Set("Japan", "Tokyo") # レコードを検索する。 print(dbm.GetStr("Japan")) # データベースファイルを閉じる。 dbm.Close()
やっている事はC++版と完全に一緒だ。OpenやSetの戻り値はStatusを返すので、エラーチェックはそのコードを調べて行う。Status.SUCCESSと比較する代わりにIsOKメソッドを呼んでもよい。
status = dbm.Open("casket.tkh", True) if status != Status.SUCCESS: print("Error: " + str(status), file=sys.stderr) sys.exit(1)
StatusのOrDieも実装している。そもそもopenが例外を投げろよと言いたくなる派もあるだろうが、その辺りは適当にラッパーを書いて調整していただきたい。
dbm.Open("casket.tkh", True).OrDie()
チューニングパラメータは辞書展開で与えることができるのだが、キーワード引数として与える方が楽だ。
dbm.Open("casket.tkh", true, truncate=True, num_buckets=10000)
Pythonのリソース管理はリファレンスカウントなので、スコープから抜けるなどして参照がなくなった時点でデストラクタが呼ばれてリソースが解放される。デストラクタで暗黙的にデータベースは閉じられる。よって、begin-ensure構文を使う必要はない。with構文の対応は暇があればやるかも。
最後にRubyインターフェイスを見てみよう。アプリケーションのコードはこんな感じになる。
require 'tkrzw' include Tkrzw # DBMオブジェクトを作ってハッシュデータベースのファイルを開く。 dbm = DBM.new begin dbm.open("casket.tkt", true) # レコードを格納する。 dbm.set("Japan", "Tokyo") # レコードを検索する。 p dbm.get("Japan") # データベースを閉じる。 dbm.close ensure # リソースを解放する。 dbm.destruct end
やっている事はC++版と完全に一緒だ。openやsetの戻り値はStatusを返すので、エラーチェックはそのコードを調べて行う。Status::SUCCESSと比較する代わりにok?メソッドを呼んでもよい。
status = dbm.open("casket.tkh", true) if status != Status::SUCCESS STDERR.printf("Error: %s\n", status) exit(1) end
Statusのor_dieも実装している。そもそもopenが例外を投げろよと言いたくなる派もあるだろうが、その辺りは適当にラッパーを書いて調整していただきたい。
dbm.open("casket.tkh", true).or_die
チューニングパラメータはhashで与えることができるのだが、キーワード引数として与える方が楽だ。
dbm.open("casket.tkh", true, truncate: true, num_buckets: 10000)
Rubyのリソース管理はガベージコレクションなので、Java版と同じような構造になる。begin-ensure構文を使って、ensureの中でdestructを明示的に呼んでリソースを解放する。closeはbeginブロックの中でやる方が美しく書ける。
PythonやRubyなどのいわゆるスクリプト言語のコードは明瞭簡潔に書けることが最も重要である。DBMが提供する機能に近い連想配列に関連する機能でいうと、添字演算子とイテレータに対応できていると、書きやすく読みやすいコードになるだろう。
Pythonインターフェイスでは、添字演算子を使って以下のようにDBMを操作することができる。これは__setitem__と__getitem__を実装すれば実現できる。Pythonでは、検索の際に該当のレコードがない場合には例外が投げられるので、DBMでもそれに習っている。
dbm["Japan"] = "Tokyo" print(dbm["Japan"])
Rubyインターフェイスでは、添字演算子を使って以下のようにDBMを操作することができる。これは [] と []= を実装すれば実現できる。Rubyでは、検索の際に該当のレコードがない場合にはnilを返すので、DBMでもそれに習っている。
dbm["Japan"] = "Tokyo" p dbm["Japan"]
Pythonインターフェイスでの各レコードのイテレーションは以下のように書ける。これは、__iter__と__iternext__を実装することで実現できる。
for key, value in dbm: print(key.decode(), value.decode())
Rubyインターフェイスでの各レコードのイテレーションは以下のように書ける。これは、eachメソッドが受け取ったブロック引数を実行することで実現できる。
dbm.each do |key, value| p key + ": " + value end
文字や文字列の扱いも4つの言語でそれぞれ違っていて面白い。まずC++では、バイナリのバイト列と文字列を区別しないで扱うのが一般的である。リテラル文字列は1バイトに相当するcharの配列だし、std::stringはバイトを表すcharの配列である。いわゆるマルチバイト文字はUTF-8などのエンコード方式に基づいたバイト列として扱うことが多い。std::wstringとかintのベクタで表現する場合もあるが、場合によってという感じだ。我らがDBMでも、格納するレコードはバイト列のキーとバイト列の値のペアとして表現される。
Status Set(std::string_view key, std::string_view value, bool overwrite); Status Get(std::string_view key, std::string* value);
Javaでは、文字列を表現するStringクラスは1文字を2バイトで表した整数の配列として実装されている。それとは別にバイト列はbyte型の配列である byte[] で扱うのが一般的だ。Stringをbyte配列に変換するにはStringのgetBytesメソッドにエンコード方式を渡せばよい。byte配列をStringに変換するにはStringのコンストラクタにbyte配列とエンコード方式を渡せば良い。さて、DBMはバイト配列を格納する機構なので、基本的にはバイト配列だけを扱う。以下のようなAPIである。エンコード方式は今のところUTF-8固定にしてある。
public native Status set(byte[] key, byte[] value, bool overwrite); public native byte[] get(byte[] key, Status status);
しかし、実際には文字列を扱うことユースケースの方が多いだろうから、文字列を受け取って文字列をラッパーを同時に提供する。中では文字列のエンコードとデコードを暗黙的に行う。
public Status Set(byte[] key, byte[] value) { return set(key.getBytes(StandardCharsets.UTF_8), value.getBytes(StandardCharsets.UTF_8), overwrite); } public byte[] Get(byte[] key, Status status) { byte[] value = get(key.getBytes(StandardCharsets.UTF_8), status); if (value == null) { return null; } return new String(value, StandardCharsets.UTF_8); }
ここで注意すべきは、キーが文字列であれば値も文字列であるという前提条件でAPIが成り立っていることである。しかし、キーは文字列として扱いたいたいが値はバイナリのバイト列として扱いたいというユースケースもあるだろう。その場合には、キーの文字列を自分でバイト列に変換してからバイト列のAPIを呼んでもらう
。
Pythonにおける文字列の扱いはJavaに似ているが、strクラスは1文字を4バイトで表した整数の配列として実装されている。それとは別にバイト列はbytes型として実装されていて、その中身はCのchar型の配列である。Javaと同じく、基本的なAPIとしてはbytesのみを扱う。
Set(key, value, overwrite) Get(key, status)
Setでは、キーも値も、str型であれば暗黙的にUTF-8文字列とみなしてbytesに変換する。それどころか、bytes以外のいかなるオブジェクトを受け取ることも許し、strに変換した上でbytesに変換する。Getではbytes型のままの値を返す。Getの変種でstrを返すメソッドも用意する。
Rubyにおける文字列の扱いはC++と似ていて、String型はcharの配列として実装されている。ただし、各々のStringオブジェクトにはエンコード方式がメタデータとして付与されていて、それと非互換のバイト列を格納すると例外を発生するようになっている。任意のバイナリ表現のバイト列を文字列として扱うためにASCII-8BITというエンコード方式があるので、DBMのAPIとしてはそれをデフォルトとして扱う。
set(key, value, overwrite) get(key, status)
setの際に受け取った文字列はその中身のバイト列をそのまま保存すれば良い。getでもデータベースから取得した値はASCII-8BITの文字列として返すのがデフォルトである。そうすれば不意な例外を出す事なくバイナリデータが扱える。一方で中身が文字列である場合には、printする際などにforce_encodingでエンコード方式を指定するのがだるいだろうから、DBMにメタデータとしてエンコード方式を設定することで、暗黙的にforce_encodingした値を返すように設定できるようにしてある。以下のようにopenすればよい。
open("casket.tkh", true, encoding: "UTF-8")
インターフェイスの比較から少し離れて、並列処理性能について比較してみたい。ここでいう並列性とは、マルチスレッドによる並列処理の性能である。OSが提供するスレッド機構をネイティブスレッドと呼ぶことにするが、C++の標準スレッドライブラリはこれを使う。よって、タスクを理想的に並列化できれば、CPUのコア数の分だけスループットが倍増する可能性がある。Javaの仮想マシンもネイティブスレッドを使って並列に処理を行うので、Javaのスレッドを使えばスループットを向上させることができる。
一方で、PythonとRubyのスレッド対応は特殊だ。両者の標準実装であるcPythonもcRubyもネイティブスレッドを使って並列処理を行うのだが、PythonやRubyのバイトコードを実行する処理は単一のスレッドでしか実行できないようになっている。その排他制御のためにGIL(Global Interpreter Lock)とかGVL(Global Virtual-machine Lock)とか呼ばれるロック機構が使われている。
Tkrzwのようなマルチスレッド対応のC++ライブラリの他言語インターフェイスを実装する際には、対象の言語の並列処理の考え方を知っていくことは重要だ。Javaの場合、処理系のロック機構のことは考えずに、ライブラリのインターフェイスも実装していい。ライブラリ内部で処理があるスレッドがブロックしたとしても、他のスレッドは動作し続ける。一方で、PythonとRubyの場合、GILのロックを保持したままのスレッドがブロックされた状態では、他のスレッドはPythonやRubyのバイトコードを一切実行できなくなってしまう。したがって、ブロックされる可能性のあるネイティブコードを実行する際には、GILをアンロックした状態で行うのが望ましい。ファイル入出力やネットワーク送受信はまさにその典型である。
ファイル入出力を伴うデータベースライブラリにもこれは当然当てはまるので、PythonとRubyのインターフェイスの内側では、メソッド呼び出しの度にGILのアンロックとロックを行えるようになっている。しかし、デフォルトでは、GILのロックは保持したままネイティブコードを実行する設定になっている。なぜなら、Tkrzwの処理はめちゃくちゃ速く動作するし、うまいこと並列設計をしたおかげで、スレッドがブロックせずに動作するシーンの方が多いからだ。オンメモリのデータベースでスレッドがブロックする可能性はほとんど無いようなもんだし、ファイルのデータベースでも、規模が相当にでかくない限りは、スレッドがブロックする頻度は低い。GILのロックとアンロックには、ロックの処理やコンテキストスイッチに伴うオーバーヘッドがそれなりにかかる。よって、シングルスレッドの場合や、マルチスレッドでもネイティブスレッドがブロックしない場合には、GILのロックとアンロックをするとむしろスループットが下がってしまう。
結局のところ、GILのロックとアンロックをした方が良いのか悪いのかはユースケースによって異なるということだ。したがって、オプションパラメータとしてその切り替えができるようにしてある。デフォルトではGILのロックとアンロックは行わないようになっている。ファイルを開く際に "concurrent" パラメータに真値を設定すれば、GILのロックとアンロックを行う挙動に変更できる。
# Python dbm.Open("casket.tkh", True, concurrent=True) # Ruby dbm.open("casket.tkh", true, concurrent: true)
実際にベンチマークテストをしてみると、concurrentモードの方が遅く、スループットが85%とかに下がる。これはシングルスレッドでもマルチスレッドでも同様だ。マルチスレッドでもconcurrentモードの方が遅いんだったら意味ないじゃんと思うかもしれないが、必ずしもそうではない。データベースの規模が大きくなってDBM側の処理がIOインテンシブになってブロックが頻発する場合、典型的にCPUインテンシブであるPython/Ruby側の処理がブロックせずに並列に行える恩恵は大きい。
実装上の小ネタなのだが、PythonのGILはネイティブコードの実行前にロックを解除して、ネイティブコードの実行後にロックを再取得するとする二段構えになっている。その手順をRAIIで確実に履行するとともに、ロック/アンロックをするかしないかをフラグで制御したくなる。そのためには、以下のようなクラスを用意するとよい。
class NativeLock final { public: NativeLock(bool concurrent) : thstate_(nullptr) { if (concurrent) { thstate_ = PyEval_SaveThread(); } } ~NativeLock() { if (thstate_) { PyEval_RestoreThread(thstate_); } } private: PyThreadState* thstate_; };
使うときには、こんな感じにする。NativeLock名前でありつつ、実はGILを解放した区間を制御している。NativeLockが生存しているブロック内ではcPythonのAPIを呼ばないというのが大事だ。
// Implementation of DBM#Close. static PyObject* dbm_Close(PyDBM* self) { tkrzw::Status status(tkrzw::Status::SUCCESS); { NativeLock lock(self->concurrent); status = self->dbm->Close(); } return CreatePyStatus(status); }
一方で、Rubyでは、GVLのロックを解放した状態でコールバック関数を呼んでからロックを再取得するという動作を一撃で行うAPIになっている。そのコールバック関数は void* (*)(void*) というシグネチャであり、引数で渡されたvoid*を適当にキャストしてから適当な処理をして適当なデータをvoid*にキャストして返すことで、汎用的な処理を実現している。C言語的にはそれがベストプラクティスなのだが、引数や戻り値の構造体をいちいち準備するのが面倒臭い。よって、C++のラムダ式を使ったラッパーを書く事にした。キャプチャ付きのラムダ式は関数ポインタに直接変換できないので、こんな風なラッパークラスを書く事で落ち着いた。
class NativeFunction { public: NativeFunction(bool concurrent, std::function<void(void)> func) : func_(std::move(func)) { if (concurrent) { rb_thread_call_without_gvl(Run, this, RUBY_UBF_IO, nullptr); } else { func_(); } } static void* Run(void* param) { ((NativeFunction*)param)->func_(); return nullptr; } private: std::function<void(void)> func_; };
使うときはこんな風にする。NativeFunction型の無名オブジェクトを構築しつつ、そのコンストラクタの中で、引数として受け取ったラムダ式をstd::functionでラップして、void*(*)(void*)型である静的メンバ関数に自身を渡して呼ぶ。この手の関数プログラミング的な技法はコードがやたら読みにくくなるという欠点があるが、たまに使うと効果的だ。
// Implementation of DBM#close. static VALUE dbm_close(VALUE vself) { StructDBM* sdbm = nullptr; Data_Get_Struct(vself, StructDBM, sdbm); if (sdbm->dbm == nullptr) { rb_raise(rb_eRuntimeError, "destructed DBM"); } tkrzw::Status status(tkrzw::Status::SUCCESS); NativeFunction(sdbm->concurrent, [&]() { status = sdbm->dbm->Close(); }); return MakeStatusValue(std::move(status)); }
まとめ。DBMのC++言語用のAPIをJava、Python、Rubyから利用できるようにするインターフェイスを整備した。4つの言語を行ったり来たりしながら設計と実装の作業を行うと、それぞれの言語の思想や癖みたいなものがより深く理解できて楽しい。
C++のAPIが元になっているだけに、他の言語のAPIもなんだか「C++臭」のする仕上がりになっていると思う。命名規則などの表面的な部分はそれぞれの言語の慣習に寄せてあるが、中身がC++である臭いは消えていないだろう。特に、暗黙的な例外送出を嫌っているあたりは、普段C++で例外の利用を控えている私の好みが如実に現れてしまっている。しかし、OrDieをつければ任意のタイミングで例外も投げられるし、落とし所としては悪くないんじゃないかと思っている。
文字列やスレッドの扱いも言語間で異なっていて面白い。なるべく言語間の違いを意識しないで利用できるAPIにした一方、意識が高い人には細かいチューニングもできる手段も提供するように努めた。
正直なところ、私がJavaやPythonやRubyで書くのは小規模なコンポーネントに止まることが多く、それらの言語で大規模なシステムを組んだ経験には乏しい。そんな奴が決めたAPIなので至らない点も多いかと思うが、そのあたりはフィードバックをいただけると嬉しい。各言語とも、バージョン1に至るまではAPIは躊躇なく変更するが、バージョン1になった時点で非互換の変更は控えたい。