Posted at
PerlDay 8

Redis::Fastでつらかったことまとめ&Redis::Fastはなぜ遅いか

More than 3 years have passed since last update.


Redis::Fastでつらかったことまとめ&Redis::Fastはなぜ遅いか

こんにちは、いっちーです。

一年とちょっと前にRedis::FastというモジュールをCPANizeしました。

PurePerlなRedisクライアントであるRedis.pmのXS版です。

Redis.pmとある程度互換性があり、Redisと書かれている部分をRedis::Fastに置換するだけで動きます。

はじめてのCPANizeでXSを使ったモジュールを公開するという暴挙にでたために、

公開後のバグ修正に頭を悩ませることになりました。

1年経って色々と溜まってきたので、この場を借りてまとめておきます。


つらかったことまとめ


minillaでXS&submodule

Redis::FastはPurePerlなRedis.pmをフォークして、

バックエンドをhiredisに置き換えたものです。

CPANモジュールの作り方を調べてminillaを使うとよさそうというのがわかったので、

フォークするさいにminillaを導入することしました。

しかし、当時のminillaはXSに対応しておらず、XSを使うためにModule::Build::XSUtilを使って

ビルド処理をカスタマイズするなどいろいろ苦労しました。

XSのいろいろを解消して、いざリリース!というタイミングで、公開用のパッケージの中にhiredisが含まれずにコンパイルできないという事態に遭遇。

hiredisの埋め込みにはgit-submoduleを使っていたのですが、当時のminillaはsubmoduleをパッケージに含めてくれないのでした。

git-submodule対応のforkがあったので、初回リリースはそのfork版を使いました。

現在のminillaはXSをサポートしているので、少し設定を書くだけでXSを使えます。

git-submodule対応もsongmuさん経由で作者の方にお願いして、対応していただきました。


メモリーリーク

Redis::Fastで遭遇したメモリーリークは大きく分けて二種類あります。

一つ目はリファレンスカウントの操作ミスや循環参照。

XSのマクロに慣れておらずリファレンスカウントが0になっていなかったり、

クロージャを多用したために気がつかないうちに循環参照を作ったりしてしまいました。

リファレンスカウントの操作ミスは、新しい値を作ったら必ずsv_2mortalで大抵防げます。

これをしておけばXSを抜けたよしななタイミングで自動的に解放されます。

SV * s = newSVpv("Hello World",0);  // Perl の文字列オブジェクト

sv_2motral(s) // 揮発性にすることで、使われなくなったら自動的に解放してくれる

av_push(配列へのpush)やhv_store(ハッシュへの代入)をするときはSvREFCNT_incする必要がありますが、

自信のないところでは使わないほうが無難です。

つけ忘れているところは比較的すぐに気がつけますが、メモリーリークはなかなか気が付けないので...。

Test::LeakTraceを使ってテストを書いておくとさらに安心です

(Redis::Fastでの使用例)。

二つ目はsv_2mortalで揮発性にしたオブジェクトが開放されるタイミングの問題です。

揮発性のオブジェクトは「XSで書かれた関数が終了してPerlに戻るとき」に解放されます。

多くのケースはそれで十分ですが、Redis::Fastでは「(タイムアウトしない限り)XSで書かれた関数が終了しない」ケースがあるので、

揮発性にしたはずのオブジェクトが永遠に解放されません。

この場合明示的に揮発性オブジェクトの有効範囲を教えてあげる必要があります。

sv_2motral(s);

ENTER;
SAVETMPS;
sv_2motral(v);
FREETMPS;
LEAVE;
// v はココで解放される
// s は生き残ってる

関数が終了すると解放はされるので、Test::LeakTraceでは見つけにくいのがつらいところです。

Devel::Refcount::refcountやDevel::Peek::SvREFCNTでリファレンスカウントの変化を注意深くテストするくらいしかなさそうです。

以前書いたブログ記事も参考にどうぞ。


double free

XSとは直接関係ないですが、double freeにも悩まされました。

通常の場合とエラー発生時とでメモリを開放しなければならないタイミングが異なるので、

memory leakを解消するために通常処理に開放処理を追加すると、エラー時にdouble freeになるということが頻発しました。


  • freeする前にNULLチェック

  • freeしたらNULLを代入する

でなんとかなると信じてます。


割り込み上手く受け取れない問題

Perlはシグナルハンドラを即座に処理しているのではなく、

シグナルハンドラを安全に実行できるタイミングを見計らって実行しています。

PurePerlな世界ではPerlの処理系が「安全に実行できるタイミング」をよしなに考えてくれますが、

XSの中ではそうもいきません。

きちんと「安全に実行できるタイミング」を教えてあげないと、永遠にシグナルハンドラは実行されません。

特にRedis::Fastは「(タイムアウトしない限り)XSで書かれた関数が終了しない」ケースがあるので深刻です。

XSのなかではPERL_ASYNC_CHECKというマクロを使うと教えてあげられるようです。

以前書いたブログ記事も参考にどうぞ。


Redis::Fastは何故遅いか

ここまでは解決済みのつらかったことでした。

ここからは未解決問題にフォーカスしたいと思います。

Redis::Jetという更に速いモジュールが出てきたので、何故Redis::Fastが勝てないのかの言い訳と、解決してくれる方の募集です。


AUTOLOAD/wantarray/coderef

Redis::Fastは以下の三種類の呼び出しかたができます。



  • my $hoge = $redis->command_name('args') スカラコンテキスト


  • my @hoge = $redis->command_name('args') リストコンテキスト


  • $redis->command_name('args', sub{}) Pipelining

この形式で呼び出すためにAUTOLOAD機能を使ったり、

呼び出し方法を見分けるのにwantarrayを使ったりするので、

どうしてもPerlのコードを書かなければならず、その分のオーバヘッドが生まれてしまいます。

Redis::hiredisにあと一歩追いつけないのはこういうところなのかなと思ってます。

まだ僕のPerl力が足りないだけ説もあるので、詳しい人助けてください。


auto reconnect

Redis::Fastはredisとの接続が切れた時に、自動的に再接続する機能があります。

通信用バッファに書き込みが終わったあとに再接続が発生する場合があるため、通信用バッファの使い回しが難しく、

バッファをその都度確保しています。

最近はmallocも速いらしいですが、ちりも積もればなんとやら。

ここらへんが通信用バッファの使い回しをしているRedis::Jetとの速度差なのかなと考えてます。


hiredis

バックエンドにhiredisを使っているのですが、hiredisは汎用的に使えるようにするため、

redisからのレスポンスのパース結果を独自のデータ型に入れています。

そのため、redisのレスポンス→hiredis独自のデータ型→Perlのデータ型と変換が発生するので、

パースした結果を直接 Perl のデータ型に入れる Redis::Jet と比べると遅くなってしまいます。

hiredisここ数年バージョンが更新されていないので、バージョンアップして速くなったりしないかな・・・(他力本願)


最後に

Redis::Fast 0.14 リリースしました。

Redis.pm 1.976 の変更を取り込んだバージョンです。

今後も改良を続けていくのでよろしくお願いします。