WebSocket大合戦:Clojure、C++、Elixir、Go、NodeJS、Ruby


Webアプリにリアルタイムの双方向通信が必要な場合、WebSocketを選ぶのは自然なことだと思います。では、どのツールでWebSocketサーバを構築すべきでしょうか。パフォーマンスは重要ですが、開発のプロセスも見過ごしてはなりません。パフォーマンスを基準にするだけでなく、開発のしやすさも考慮に入れるべきでしょう。今回の大合戦では、Clojure、C++、Elixir、Go、NodeJS、Rubyのそれぞれの言語によって慣用的な手法で実装されたシンプルなWebSocketサーバを比較したいと思います。

テスト内容

サーバに実装するのは、echobroadcastの2つのメッセージのみを扱う非常に単純なプロトコルです。echoは送信クライアントに返され、ブロードキャストは全ての接続クライアントに送信されます。そしてブロードキャストが完了すると、結果メッセージが送信者に返されます。メッセージはJSONでエンコードされ、どちらのメッセージも適切な宛先に配信されるペイロード値を取ります。

以下はブロードキャストのメッセージ例です。

{"type":"broadcast","payload":{"foo": "bar"}}

低レベルのWebSocketを実装したプラットフォームでは、上記のメッセージは直接的に機能します。一方、PhoenixやRailsのような、より高レベルの抽象化の場合、メッセージはそれぞれのメッセージの規格と互換性を持つようにエンコードされなければなりません。

比較する言語の紹介

まずは、各言語の興味深い点を見てみましょう。

Clojure

Clojureのサーバは、HTTP Kitを用いたWebサーバ上で構築されます。興味深い点はserver.cljにあります。

クライアントが接続すると、チャネルのatomに格納されます。これが並列処理の安全性を高めますが、要求を並列に処理するClojureにとって、これは重要なポイントです。

(defonce channels (atom #{}))

(defn connect! [channel]
  (log/info "channel open")
  (swap! channels conj channel))

(defn disconnect! [channel status]
  (log/info "channel closed:" status)
  (swap! channels disj channel))

ブロードキャストは極めてシンプルです。メッセージは全てのチャネルに送信され、結果が送信者に返されます。

(defn broadcast [ch payload]
  (doseq [channel @channels]
    (send! channel (json/encode {:type "broadcast" :payload payload})))
  (send! ch (json/encode {:type "broadcastResult" :payload payload})))  (send! ch (json/encode {:type "broadcastResult" :payload payload})))

面白みのないセットアップやHTTPルーティングを含めても(ただし、CLIランナーは除く)、Clojureでのサーバのコア部分は50LOC以内で収まります。

C++

C++のサーバではwebsocketppが使われ、websocketppはWebSocketサーバのインフラに関してboostに依存します。これはコードベースがダントツに冗長で複雑です。サーバクラスはserver.cppserver.hにあります。

マルチスレッディングは明示的です。マルチスレッドが開始され、全てがwebsocketppのサーバのrunメソッドを実行します。

void server::run(int threadCount) {
  boost::thread_group tg;

  for (int i = 0; i < threadCount; i++) {
    tg.add_thread(new boost::thread(&websocketpp::server<websocketpp::config::asio>::run, &wspp_server));
  }

  tg.join_all();
}

稼働中の接続がコレクションに格納される点は、Clojureによる実装と似ています。

void server::on_open(websocketpp::connection_hdl hdl) {
  boost::lock_guard<boost::shared_mutex> lock(conns_mutex);
  conns.insert(hdl);
}

void server::on_close(websocketpp::connection_hdl hdl) {
  boost::lock_guard<boost::shared_mutex> lock(conns_mutex);
  conns.erase(hdl);
}

スレッドセーフであるためには、接続を追加または削除する前にロックを取得しなければなりません。boost::lock_guardオブジェクトを作成すると、conns_mutexがロックされます。そして関数が返されると、デストラクタがロックを自動的に解除します。

接続セットを定義するための構文は、これがC++であることを考えると興味深いと思います。

class server {
  // ...
  std::set<websocketpp::connection_hdl, std::owner_less<websocketpp::connection_hdl>> conns;
};

これはwebsocketpp::connection_hdl向けにテンプレート化されたstd::setです。また、websocketpp::connection_hdlstd::shared_ptrなので、セットの比較述語でテンプレート化する必要もあります。

ブロードキャスト関数のコア部分は比較的単純ですが冗長です。

void server::broadcast(websocketpp::connection_hdl src_hdl, const Json::Value &src_msg) {
  Json::Value dst_msg;
  dst_msg["type"] = "broadcast";
  dst_msg["payload"] = src_msg["payload"];
  auto dst_msg_str = json_to_string(dst_msg);

  boost::shared_lock_guard<boost::shared_mutex> lock(conns_mutex);

  for (auto hdl : conns) {
    wspp_server.send(hdl, dst_msg_str, websocketpp::frame::opcode::text);
  }

  Json::Value result_msg;
  result_msg["type"] = "broadcastResult";
  result_msg["payload"] = src_msg["payload"];
  result_msg["listenCount"] = int(conns.size());
  wspp_server.send(src_hdl, json_to_string(result_msg), websocketpp::frame::opcode::text);
}

まずJSONデータが構築され、全てのクライアントに送信されます。続いてconnsの共有ロックが取得され、全てのクライアントにメッセージが送信されます。最後に、ブロードキャストの結果メッセージが構築され、送信されます。

WebSocketサーバクラスのコア部分は約140LOCです。

Elixir

Elixirのサーバで使われるのはPhoenixのレームワークで、関連するコードはroom_channel.exにあります。

Phoenixにはチャネルを抽象化する機能が内蔵されており、接続されたクライアントのコレクションを手動で管理する必要はありません。トランスポートとして自動でJSONを使うため、コードをさらにシンプルにできます。

defmodule PhoenixSocket.RoomChannel do
  use Phoenix.Channel

  def join("room:lobby", _message, socket) do
    {:ok, socket}
  end

  def handle_in("echo", message, socket) do
    resp = %{body: message["body"], type: "echo"}
    {:reply, {:ok, resp}, socket}
  end

  def handle_in("broadcast", message, socket) do
    bcast = %{body: message["body"], type: "broadcast"}
    broadcast! socket, "broadcast", bcast
    resp = %{body: message["body"], type: "broadcastResult"}
    {:reply, {:ok, resp}, socket}
  end
end

パターンマッチングで、クライアントの要求をスムーズに処理することが可能です。関連チャネルの全体的なコードはわずか20LOC前後で済みます。

Go

Goのサーバはwebsockets向けのnet/httpgolang.org/x/net/websocketライブラリを直接使用します。Websocketハンドラはhandler.go内に定義されます。

接続の他には高いレベルの抽象化は無く、接続されている全てのクライアントを格納するためにマップが使われる点はC++に似ています。C++と同様に、ミューテックスが並列処理の安全性のために使われます。

func (h *benchHandler) Accept(ws *websocket.Conn) {
  // ...

  h.mutex.Lock()
  h.conns[ws] = struct{}{}
  h.mutex.Unlock()

  // ...
}

ブロードキャストの方法はとても単純です。接続のミューテックスに読み取りロックをかけます。次に、各接続にメッセージを送ります。最後に、ミューテックスのロックを解除し、ブロードキャストの結果を送信者に送ります。

func (h *benchHandler) broadcast(ws *websocket.Conn, payload interface{}) error {
  result := BroadcastResult{Type: "broadcastResult", Payload: payload}

  h.mutex.RLock()

  for c, _ := range h.conns {
    if err := websocket.JSON.Send(c, &WsMsg{Type: "broadcast", Payload: payload}); err == nil {
      result.ListenerCount += 1
    }
  }

  h.mutex.RUnlock()

  return websocket.JSON.Send(ws, &result)
}

明示的なパッケージのインポートのようなボイラープレートや、明示的なエラー処理、型付けされていないマップの代わりに強い型を使うことなどを合わせると、Goのwebsocketコードは100LOC近くになります。

Javascript / NodeJS

Javascriptによる実装では、NodeJSwebsockets/wsを使用します。サーバ全体がindex.jsの中に含まれています。

websockets/wsサーバは接続されたクライアントのトラッキングを自動的に維持します。コードはシンプルで、説明はほとんど不要です。

var WebSocketServer = require('ws').Server;
var wss             = new WebSocketServer({ port: 3334 });

function echo(ws, payload) {
  ws.send(JSON.stringify({type: "echo", payload: payload}));
}

function broadcast(ws, payload) {
  var msg = JSON.stringify({type: "broadcast", payload: payload});
  wss.clients.forEach(function each(client) {
    client.send(msg);
  });

  ws.send(JSON.stringify({type: "broadcastResult", payload: payload}));
}

wss.on('connection', function connection(ws) {
  ws.on('message', function incoming(message) {
    var msg = JSON.parse(message);
    switch (msg.type) {
      case "echo":
        echo(ws, msg.payload);
        break;
      case "broadcast":
        broadcast(ws, msg.payload);
        break;
      default:
        console.log("unknown message type: %s", message);
    }
  });
});

アプリケーション全体の長さは間違いなくこれが最短で、1ファイルのたったの31LOCです。

Ruby / Rails

Rails 5ではActionCableが導入されました。このwebsocketの抽象化は、Phoenixにおける抽象化によく似ています。

Phoenixのように、接続されているクライアントのコレクションを自動的に管理し、JSONのパースとシリアライズを処理します。コードの構造や規模も似ています。

class BenchmarkChannel < ApplicationCable::Channel
  def subscribed
    Rails.logger.info "a client subscribed: #{id}"
    stream_from id
    stream_from "all"
  end

  def echo(data)
    ActionCable.server.broadcast id, data
  end

  def broadcast(data)
    ActionCable.server.broadcast "all", data
    data["action"] = "broadcastResult"
    ActionCable.server.broadcast id, data
  end
end

Railsのwebsocketのハンドリングのコードはおよそ20LOCで、PhoenixやNodeJSのコードと同じぐらいです。

ベンチマーク

今回のテストの目的は、ただ単に、ほとんどがアイドル状態である大量の接続を処理できるかということだけでなく、重い負荷の下でサーバのパフォーマンスを見ることです。

この比較の一環として、これらのwebsocketサーバのパフォーマンスをテストするために、ベンチマークのツールであるwebsocket-benchが構築されました。websocket-benchは、サーバが許容レベルのパフォーマンスを提供しながら、いかに多くの接続を処理できるかを判別するように設計されています。例えば、250マイクロ秒以内に少なくとも95%のブロードキャストを完了しなければならない状況で、10のブロードキャストを同時に行うという要件を与えた場合、サーバはいくつの接続を処理できるでしょうか。

以下は、ベンチマークの実施例です。

$ bin/websocket-bench broadcast ws://earth.local:3334/ws --concurrent 10 --sample-size 100 --step-size 1000 --limit-percentile 95 --limit-rtt 250ms
clients:  1000    95per-rtt:  47ms    min-rtt:   9ms    median-rtt:  20ms    max-rtt:  66ms
clients:  2000    95per-rtt:  87ms    min-rtt:   9ms    median-rtt:  43ms    max-rtt: 105ms
clients:  3000    95per-rtt: 121ms    min-rtt:  21ms    median-rtt:  58ms    max-rtt: 201ms
clients:  4000    95per-rtt: 163ms    min-rtt:  30ms    median-rtt:  76ms    max-rtt: 325ms
clients:  5000    95per-rtt: 184ms    min-rtt:  37ms    median-rtt:  95ms    max-rtt: 298ms

上記のベンチマークは、1000のwebsocketクライアントがws://earth.local:3334/wsに接続している状態から開始します。次に、10の並列処理で100のブロードキャストのリクエストを送ります。95パーセンタイルのラウンドトリップ時間が250マイクロ秒を超えるまで、クライアントは1回につき1000ずつ増えていきます。この例では、クライアント数5000までなら、サーバはパフォーマンスの要求を満たすことができます。

以下に示す結果は、1台のマシンでサーバを稼働させ、他のマシンでベンチマークツールを実行したものです。マシンはどちらもベアメタルのi7 4790K 4GHz 16GB RAMでOSはUbuntu 16.04、これをギガビットイーサネット経由で接続しました。このテストは並列処理4で、95パーセンタイルのラウンドトリップ時間が500マイクロ秒という要件下で実施されました。テストは複数回行われ、最良の結果が採用されました。

Clients010,00020,00030,00040,000C++ClojureElixir / PhoenixGoRuby MRI / RailsJRuby / RailsNodeJS / webs…

Platform Clients
C++ 33,000
Clojure 27,000
Elixir / Phoenix 24,000
Go 24,000
Ruby MRI / Rails 500
JRuby / Rails 1,100
NodeJS / websocket/ws 13,000

制約のある環境で実行する際には、メモリの使用量も要素となり得ます。

Memory Usage (MB)05001,0001,5002,000C++ClojureElixir / PhoenixGoRuby MRI / RailsJRuby / RailsNodeJS / webs…

Platform Memory Usage (MB)
C++ 600
Clojure 1,500
Elixir / Phoenix 1,900
Go 800
Ruby MRI / Rails 150
JRuby / Rails 650
NodeJS / websocket/ws 300

当然のことながら、パフォーマンスのグラフではC++が断然トップです。C++は、接続数に対するメモリの使用量でも最も効率的です。しかし、C++サーバは最も冗長で、最も複雑な実装でもあります。この言語は、低レベルのメモリ管理、生ポインタ、インラインアセンブラから、複数継承のクラス、テンプレート、ラムダ、例外まで、あらゆることを含む非常にマルチパラダイムな寄せ集めです。開発者はまた、コンパイラフラグ、makefile、長時間のコンパイル、難解なエラーメッセージの構文解析などを深く掘り下げなければなりません。長所は、シングルバイナリにまでプロジェクトをコンパイルできるので、デプロイが簡単にできるという点です。最高のパフォーマンスを求めるならば、まさに最適な言語ですが、開発に時間がかかることや、熟練した開発者を探す労力を覚悟しておく必要があります。

トップであるC++の82%のパフォーマンスで、Clojureは「非常に高水準な言語でも、とても高速になりうる」ということを証明してくれます。JVM言語でもあるので、豊富なJavaライブラリから恩恵を得ることができます。ClojureはLisp系に属すので、この言語体系を使ったことがない開発者にとっては骨が折れるかもしれません。ですが、いったんコツをつかんでしまえば、開発速度を上げられるでしょうし、コードもとても簡明です。デプロイはJVMに依存しますが、それ以外に、プロジェクト全体がjarファイルに含まれているため、デプロイは比較的、容易なはずです。もしClojureに詳しい開発者がいるならば、Clojureは素晴らしい選択です。

ElixirはC++の73%の速度で3番手につけます。Elixirは、関数型で高い並行性を持つErlang VMに、とっつきやすいRubyスタイルのシンタックスを付与したものです。コードは簡潔で分かりやすく、Elixirを知らない開発者でも簡単に読めるでしょう。Phoenixは、websocketが使いやすくなるようなチャネルの抽象化を与えてくれます。デプロイの最善の方法はまだ確定していませんが、Hashrocket向けの Gatling でElixirのデプロイをどう扱っているかが分かります。デメリットとしては、Elixirを知っている開発者はまだまだ少なく、言語は新しくていまだに変更を重ねていることです(例えば日付/時刻型は2016年の6月に出た1.3で標準化されました)。

GoはElixirと並んでパフォーマンスの3番手です。メモリ量はClojureとElixirの半分で済みます。Goはこのリストにある他の言語とは違うタイプのシンプルさを有しています。あらゆる魔法のような動作や隠れた動作を避けるので、非常に単純で明確ですが冗長な言語となります。GoにおけるCSP実装は、イベント駆動型システムよりも並行システムについて考えるのをずっと容易にしてくれます。Goはスタティックバイナリにコンパイルするので、デプロイをとてもシンプルにしてくれます。先に述べた2つの選択肢と同様に、Goはwebsocketサーバには適した選択と言えるでしょう。

Goから大きく差を付けられて次に位置するのが39%のNodeJSです。NodeJSのパフォーマンスは、その単一スレッドのアーキテクチャによって妨げられています。むしろ単一スレッドであることを考えると良いパフォーマンスを叩きだしている方です。NodeJSサーバは全体のコード行数が最も小さく、非常に早く簡単に書けます。JavascriptはWeb開発者にとってリンガフランカと言えるでしょうが、NodeJSはおそらく開発者を採用したりトレーニングしたりするには最も簡単なプラットフォームと言えるでしょう。

C++の2%にも満たないパフォーマンスなのがRailsで、Ruby MRI上で動作するRailsは単純にwebsocketのパフォーマンスを他と比べることはできません。これの役割は、「従来的なRailsアプリケーションであるが、少数の並行的なクライアントのためだけにwebsocketが必要」というケースを補うことです。こういった特定のケースでは、websocketsのためだけに他のテクノロジスタックを用いるのは避けるほうがよいでしょう。Rubyのパフォーマンステストで軍配が上がったのはJRubyでした。他の言語にはまだまだ及びませんが、JRubyのパフォーマンスはMRIの倍以上でした。Railsでのデプロイなら、JRubyでしょう。

結論

websocketsに関しては、ClojureとElixir、そして Goは優れたパフォーマンスと開発しやすさのバランスを取れる選択肢です。NodeJSはパフォーマンスとしては低いですが、多くの用途では、それで十分に事足りています。Railsは「既存のRailsアプリケーションの一部として、並行的にごく少数のクライアントと接続する」という条件であればお勧めできるでしょう。C++は最も優れたパフォーマンスを見せますが、開発が複雑で難しくなるためにあまりお勧めはできません。

この議論における、コードと完全なベンチマークの結果はGithubにあります。