強まっていこう

あっちゃこっちゃへ強まっていくためのブログです。

Nim、golang、Crystal、Node.js それぞれの Web Server ベンチマーク

以下のコードをそれぞれちょいとベンチしてみました。

Nim
import asynchttpserver, asyncdispatch

const port = 8080

let server = newAsyncHttpServer(true, true)

waitFor server.serve(Port(port), proc (req: Request) {.async.} =
  await req.respond(
    Http200, "Hello world",
    newHttpHeaders([("Content-Type", "text/plain")])
  )
)
golang
package main

import (
  "fmt"
  "net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
  fmt.Fprintf(w, "Hello world")
}

func main() {
  http.HandleFunc("/", handler)
  http.ListenAndServe(":8080", nil)
}
Crystal
require "http/server"

port = 8080

server = HTTP::Server.new(port) do |context|
  context.response.content_type = "text/plain"
  context.response.print "Hello world"
end
server.listen
Node.js
var http = require('http');

const port = 8080

http.createServer(function (req, res) {
  res.writeHead(200, { "Content-Type": "text/plain" })
  res.end("Hello world")
}).listen(port)

ab -c 400 -n 4000 でテスト

言語 ファイルサイズ メモリ 結果(req/sec)
Nim 237KB 812KB 4,300 ~ 4,800
golang 6.8MB 2.3MB 4,300 ~ 4,800
Crystal 2.8MB 2.3MB 4,300 ~ 4,800
Node.js 186B 18MB 2,500 ~ 3,200

速度的には Nim、golang、Crystal それぞれどっこいどっこいと言った感じです。Node.js はさすがにその他3つと比べると遅いです。

メモリはさすがに Nim が一番食いません。ファイルサイズはそりゃコンパイルもへったくれもない単なるスクリプトの Node.js が一番小さいですね。Nim もコンパイルされたものの中ではかなり小さいです。

Node.js と Nim は、シングルプロセスで多重 IO を使っているだけなので1つ処理が引っかかってしまうとその他全てが足を引っ張られます。そうならないためには fork か thread で処理を分散させてやる必要があります。Node.js の場合は cluster などを使うと簡単にできます。Nim の場合は以下のような感じでできます。

import asynchttpserver, asyncdispatch, osproc, posix

const port = 8080

proc response(req: Request) {.async.} =
  await req.respond(Http200, "Hello world\n")

proc forkChild =
  var server = newAsyncHttpServer(true, true)
  asyncCheck server.serve(Port(port), response)
  runForever()

proc main =
  let procNum = countProcessors()
  for i in 0..<procNum:
    let pid = fork()
    if pid == 0:
      forkChild()
      quit(0)
  while true: discard

main()

CPU の個数分 fork しています。これは簡易的なもので、子プロセスが死んでも復活しません。親プロセスは知らんぷりです。ちゃんとやる時は親で SIGCHILD を受け取ってちょろちょろやる必要があります。その内詳しく書きます。

Crystal と golang は thread で勝手に並列処理してくれます。

ただ、Crystal はコンパイルが遅くてかなりイライラします。release でコンパイルすると、あり得ないレベルになります。

Nim は最初の1回目は遅いんですが 2回目以降はいじったファイル以外は cache が効くので速いんです。
何故そうなるのかは、make が何者か知っている人であればコンパイルを実行したディレクトリに出来上がる ninmcache ディレクトリを見ると一発でわかると思います。

知らない人向けに一応書いておくと、Nim でコンパイルすると、Nim で書かれたコードがファイル毎に C言語に変換されて nimcache ディレクトリに格納されます(hoge.nim が hoge.c に)。

その hoge.c がコンパイルされてオブジェクトファイルと呼ばれる中間ファイルになります(hoge.c が hoge.o に)。

コード中で呼んだライブラリも全て同じように .c と .o になって nimcache に格納されます。

これらのオブジェクトファイル全てをリンカってやつががっちゃんこして実行ファイルが作られるわけです。

コンパイル時には、いじったファイルだけが再コンパイルされ、いじってないものは以前のオブジェクトファイルそのままでがっちゃんこされます。こうしてコンパイルする量を減らすことで全体のコンパイル速度を上げているわけです。これこそが make のお仕事だったりします。

golang は今回のコードが少ないおかげでコンスタントに速いですが、オブジェクトファイルなどはないので、書いているコードが増えると遅くなっていきます。

言語 1回目(sec) 2回目(sec)
Nim 3.2 1.0
Nim(release) 5.1 0.8
golang 1.0 1.0
Crystal 3.3 3.2
Crystal(release) 44 44

速度、メモリ使用量などなど、全部ひっくるめて考えると、やはり Nim が最強ですね(Nim バカ談)。