Ruby の文法をベースにした静的型付コンパイル型言語 crystal とその web アプリケーションフレームワークである Amethyst を使ってみます。なおエディタには Atom と language-crystal-actual, linter-crystal パッケージを使いました。
crystal インストール
crenv を使って 0.9.1 をインストールします。
amethyst + active_record インストール
crystal では shards という bundler 風パッケージ管理ツールがあります。
shards.yml というファイルに依存関係を定義します。なぜかファイル形式が crystal ではなく yaml です。
dependencies:
amethyst:
github: Codcore/amethyst
branch: master
activerecord:
github: waterlink/active_record.cr
branch: master
github のレポジトリを直接指定する形です。インストールコマンドは言語組み込みです。
$ crystal deps
Updating https://github.com/Codcore/amethyst.git
Updating https://github.com/spalger/crystal-mime.git
Updating https://github.com/waterlink/active_record.cr.git
Installing amethyst (master)
Installing mime (master)
Installing activerecord (master)
依存関係を辿って crystal-mime というパッケージもインストールされました。
Hello World
では最小のアプリを作ってみましょう。以下のようなコードを src/hello.cr として保存します。
Model, View は使わずに Controller だけです。Amethystではディレクトリ構造は厳密に定義されてはいないようです。
require "amethyst"
class HelloController < Base::Controller
actions :world
def world
html "hello world!"
end
end
class HelloApp < Base::App
routes.draw do
get "/world", "hello#world"
register HelloController
end
end
app = HelloApp.new
app.serve
Appクラスというのがあります。これはrailsでいう config/application.rb やroutesに相当する箇所だと思います。アプリケーションの設定やルーティングを定義します。
なお Atom と linter-crystal を使った場合、linter が最初のrequire
の時点で amethyst ファイルが見つからないというエラーを返してきます。この問題はlinterがcrystal build
を実行するときにプロジェクトのディレクトリではなく異なるディレクトリから実行するためです。crystal は pwd にある shards しか読み込まないため、インストールされた amethyst のソースコードが読み込まれません。とりあえず回避するために ~/.atom/packages/linter-crystal/lib/linter-crystal.js を編集して、exec の直前で process.chdir("/path/to/project")
することで回避しましたが他のプロジェクトに使えなくなるので一時的な対策です。
実行
$ crystal run src/hello.cr
[Amethyst 0.1.7] serving application "hello" at http://127.0.0.1:8080
[Amethyst 0.1.7] serving application "hello" at http://127.0.0.1:8080
HTTPサーバが起動しました。ここでブラウザで http://127.0.0.1:8080/hello/world にアクセスすると無事 "hello world!" と表示されました。コンソールには何も表示されません。
私のように ruby から crystal に移行を検討している人は速度に関心があると思うので、速度を計測してみます。
Amethyst では rack middleware のように簡単に middleware を追加することができます。routes.draw
のブロックの末尾に以下の行を追加します。
use Middleware::TimeLogger
なぜ routes の中に書くのかはよくわかりません。再度crystal run
してブラウザでアクセスすると今度はコンソールに以下のように表示されます。
$ crystal run hello.cr
[Amethyst 0.1.7] serving application "hello" at http://127.0.0.1:8080
[Amethyst 0.1.7] serving application "hello" at http://127.0.0.1:8080
____/ #<Amethyst::Middleware::TimeLogger:0x2231b40> \__________________________________________________________________
Time elapsed : 0.0530 ms
私の戦闘力は 53μs です。wrk での計測も行いましょう。
$ ./wrk -t12 -c12 -d30s http://127.0.0.1:8080/hello/world
Running 30s test @ http://127.0.0.1:8080/hello/world
12 threads and 12 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 3.84ms 13.47ms 174.71ms 93.96%
Req/Sec 1.33k 330.76 12.28k 87.28%
475947 requests in 30.10s, 131.18MB read
Requests/sec: 15813.05
Transfer/sec: 4.36MB
15,813req/sec です。興奮する数字を叩きだしてくれましたね。
ビルド
上記は crystal コマンドから直接実行しましたが、コンパイルしてバイナリを生成することもできます。
ビルドもcrystal
コマンドを使います。
$ time crystal build src/hello.cr
real 0m1.460s
user 0m1.916s
sys 0m1.792s
$
Core i7-2600 使用で1秒以上かかりました。これは大きなアプリの開発ではかなり問題になりそうな予感がしますね。crystalではjavaのようにソースコード一つひとつがクラスファイルに変換されるような形式ではなく、コンパイル時に指定したファイルを起点としてバイナリを一つ作成する形式になっています。そのため差分コンパイルがおそらくできないのではと思います。そうなるとこのコンパイル速度は重くのしかかってきます。
今度はバイナリが生成されているので、これを直接実行できます。
$ ./hello
速度は特に変わりませんでした。
release ビルド
実は上記のバイナリは、ただの開発用のバイナリです。ビルド時に--release
オプションをつけることで、デバッグ用の情報などを省いたより高速なバイナリを生成できるのです。お待たせしましたね。。。さぁて、第2回戦と行きましょうか。。。
$ time crystal build --release src/hello.cr
real 0m3.351s
user 0m3.428s
sys 0m0.072s
コンパイル速度はさらに遅くなりました。しかたないですね。では起動してみましょう。
Running 30s test @ http://127.0.0.1:8080/hello/world
12 threads and 12 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 6.85ms 21.87ms 270.76ms 91.85%
Req/Sec 1.88k 555.39 2.47k 82.79%
667408 requests in 30.03s, 183.95MB read
Requests/sec: 22221.38
Transfer/sec: 6.12MB
今度は22,221req/sです。40%ほど向上しました!
production モード
Amethyst には production モードというのがあり、いくつかのmiddlewareが省略されるようですが、これを有効にしても速度には特に変化は無しでした。
(追記)
これは該当箇所が以下の様な条件判定になっていたため、environmentに何も指定しなかったら productionと同じ挙動になっていた、ということでした。これは良くないですね。
if self.class.settings.environment == "development"
(追記終わり)
マルチコア
マルチコアをどの程度活用できるのかも気になるところですが、wrk を並列度1でテストしても結果が 17248, 並列度 2 で 19443。上記の並列度12の結果とそこまで変わりません。並列度を上げてもCPUの利用率は 200-300% の間を漂う感じで4コア全てを使い切ることはありませんでした。CPUを完全に使いきれるようになれば 30,000rps くらいまでは上がるのかな?と期待できます。一方で並列度1の時点で CPU を 200% (コア2つ分)使っており、これも理由はよくわかりません。
メモリ
ベンチマークで負荷をかけるとメモリの使用量がぐんぐん伸びていきます。確認した限り2Gまでは増えました。hello world でこの状態になると production で使うのは躊躇してしまいますね。ここまでメモリがぐんぐん増えてくるとGCにどれだけCPUを使ってるのかも気になりますね。今後の改善に期待です。もしかしたら上記の並列度1の時でCPU200%のうちの半分はGCなのかも?
(追記)
メモリが増え続ける原因はセッションでした。現在のバージョンではセッションを全てオンメモリに記録しているので wrk からアクセスするとリクエストごとに新しくセッションが作成されてメモリが膨れ上がるようです。libs/amethyst/amethyst/base/app.crを編集して session middleware を使わないようにするとメモリの増加が解消されました。ただ、現時点ではまだセッションの削除が実装されていないのでこの問題は遅かれ早かれ発生します。
また、やはり CPU200%のうちの半分はGCだったようで、wrk並列度1でのアクセス時にはCPU100%程度しか使わないようにもなりました。一方で、並列度をあげてもCPU100%を超えることはなくなりました。現在はまだシングルスレッドでの挙動なんですね。wrkの並列度を上げると性能が多少あがるのは node と同じように非同期IOを使っているということかな。
加えて、新規セッションを作成するときは SecureRandom で session id を生成しているのですが、この程度の処理がベンチマークには大きく影響するようで、固定文字列を返すだけにしてみると性能が 1.5倍ほどになります。
(追記終わり)
まとめ
組み込みの http サーバ単体で 200,000 rps くらいでるという話を聞いていたのですが、フレームワークを挟むとやはり桁が落ちるようですね。それでも rails よりはひと桁速いので良しとしましょう。