WebSocket大混战:Clojure、C++、Elixir、Go、NodeJS、Ruby

Web程序在需要实现实时通信的情况下,选择WebSocket是很自然的事。那么,用什么工具搭建WebSocket服务器最为合适呢?性能虽然很重要,但开发成本也不能忽略。这次将比较各种语言通过各自常见的手法搭建WebSocket服务器的实际操作进行比较。

1.通信格式

服务器执行的任务很简单,只负责处理echobroadcast两种信息即可。消息将使用JSON进行编码,格式正如下面这条广播信息。

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

2.主角介绍

首先,我们先看看各语言比较有特色的地方。

2.1.Clojure(代码核心部分50行)

Clojure服务器使用HTTP Kit搭建,其中值得注意的是server.clj这个文件。
每当和一个客户端完成连接,就把对应的channel存放到channelsatom中。这提高了并行处理的安全性,对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})))

 

2.2.C++(代码核心部分140行)

C++的服务器使用了websocketppwebsocketpp同时依赖于基础库boost,因此编程语法会显得比较冗长和复杂。
服务器程序的主类命名为server,很明显要使用多线程,这在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实现十分类似,但它的构造要复杂得多。

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

因为websocketpp::connection_hdl实际上是一个std::shared_ptr,智能指针的比较没有看起来的那么简单. 先看一下shared_ptr/weak_ptr的通常的内部实现:

智能指针数据结构

它有两个指针, _Ptr指向真正的对象, _Rep指向所谓的control block, 其中记录着shared_count, weak_count。 对于shared_ptr来说, 有两种方式来比较大小:value-based, owner-based。也就是分别比较_Ptr或者_Rep的值.。而weak_ptr则不能简单的比较_Ptr, 因为weak_ptr随时可能expire, _Ptr的值从而改变(清零)。 所以就只能比较_Rep, 也就是control block 的地址.。一般的, 为了和weak_ptr的实现一致,shared_ptr也采用owner-based的机制。

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进行上锁。当函数返回时,lock_guard这个栈对象被自动销毁,自动解锁。
广播函数的核心部分则相对简单,但也略显冗长。

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);
}

2.3.Elixir(代码核心部分20行)

Elixir服务器使用的是Phoenix框架,相关的代码在room_channel.exPhoenixchannel的封装更为彻底,无需手动维护一个集合,再加上传输默认使用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

上面使用pattern matching取代大部分条件分支,它是Elixir下写代码的一个很自然的模式:任务不断拆解,每个函数专注只干一件事,代码的简洁自不必说,其效率还有可能进一步优化。

2.4.Go(代码核心部分100行)

Go服务器使用了net/httpgolang.org/x/net/websocket库进行搭建,Websockethandlerhandler.go中定义。但他并没有对连接对象进行高层级的抽象,为了对连接对象进行管理需要自行创建一个map,这点和C++很相似。而且与C++一样,为了保证并行处理的安全性,需要使用mutex进行上锁操作。

func (h *benchHandler) Accept(ws *websocket.Conn) {
  // ...
 
  h.mutex.Lock()
  h.conns[ws] = struct{}{}
  h.mutex.Unlock()
 
  // ...
}

广播的方法也不难理解,也跟C++很像。

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)
}

算上导入外部库文件,异常处理,不使用无类型map而是用强类型检查map的代码,整个程序的代码量也只是100行而已。

2.5.Javascript/NodeJS(代码核心部分30行)

通过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);
    }
  });
});

2.6.Ruby/Rails(代码核心部分20行)

Rails 5引入了ActionCable,这个库对于websocket的抽象化跟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

3.比较基准

为了测试这些websocket服务器的性能表现,选择搭建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个客户端连接,然后产生10个线程分别发送100个广播请求。接着每次增加1000个客户端连接,直到超过5%的请求处理往返时间超过250毫秒为止。上面的例子,直到客户端连接数量到达5000依然能满足条件。
下面的测试结果是在一台机器作为服务器,其他机器运行压测基准工具,所有机器都是Bare Metali7 4790K 4GHz 16GB RAM,操作系统是Ubuntu 16.04,他们通过吉兆比特网络进行连接。测试条件是4个并行请求线程,直到超过5%的请求处理往返时间超过500毫秒。对于每种语言都测试多次,最后取最优结果作为最终比较的结果。

比较结果1

下面是内存的使用量

比较结果2

显而易见的是,C++表现性能是最优的,但是C++服务器的代码编写最为复杂和冗长。其原因在于,它集低层级的内存管理、指针、内联汇编、多重继承、模板、lambda、异常处理于一身。而且开发者还要面对复杂的编译器选项、makefile文件的编写、长时间的编译过程、复杂难懂的错误信息。

同时,Clojure作为一门抽象层次更高的编程语言,依然拥有不错的表现性能,本身又运行于JVM,有丰富的类库作支持。同时它依存于JVM,整个程序打包成一个jar文件,部署的难度相当小。但它的语法属于Lisp系,对于这个语言体系不熟悉的开发者来说,其使用难度可能要比C++还要难上不少。

Elixir表现效果排名第3,它运行在函数型编程先天高并行性的Erlang VM平台,同时语法与Ruby快上手的特点很相似。但目前知道这门语言的开发者还很少,作为一门新语言更新也比较频繁。

Go语言Elixir的表现性能排名并列第三,但内存占用只是ClojureElixir的一半。代码编写虽然也挺长,但并不像C++那么晦涩难懂,而是相当地明确。同时Go能编译成静态二进制程序,部署起来也不是问题。
NodeJS的性能显然不能跟前面的语言相提并论,这主要是受到它单线程的架构设置的局限。但如果跟其他单线程的架构相比,它已经算是佼佼者了。而且用它编写的程序代码量是最小的,JavaScript对于Web开发者来说是最熟悉不过的语言了。

对于这次的测试,Rails显然是很不适应的,因为他只是一个对原有Rails程序的补充,只适用于少数的客户端连接的场合,但这只是对于运行在 Ruby MRI平台上的Rails websocket而言。如果真有开发需求,那建议使用JRuby,虽然比不上其他语言,但能保证性能是MRI的数倍之上。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值