POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

FeedlyRSSTwitterFacebook
Jack Christensen

本記事は、原著者の許諾のもとに翻訳・掲載しております。

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

テスト内容

サーバに実装するのは、 echo broadcast の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_hdl std::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/http golang.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マイクロ秒という要件下で実施されました。テストは複数回行われ、最良の結果が採用されました。

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

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

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 にあります。

監修者
監修者_古川陽介
古川陽介
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
複合機メーカー、ゲーム会社を経て、2016年に株式会社リクルートテクノロジーズ(現リクルート)入社。 現在はAPソリューショングループのマネジャーとしてアプリ基盤の改善や運用、各種開発支援ツールの開発、またテックリードとしてエンジニアチームの支援や育成までを担う。 2019年より株式会社ニジボックスを兼務し、室長としてエンジニア育成基盤の設計、技術指南も遂行。 Node.js 日本ユーザーグループの代表を務め、Node学園祭などを主宰。