AdventCalendar
HTTP
Web
Nim
NimDay 18

Nimで速いWebサーバーを書くのです(両手を大きく広げ、輝く)

どうも、Nim Advent Calendar 18日目担当の2vgです。
Advent Calendar初参加ですが、よしなによしなに!!

文章力も語彙力もないので間違ってたりとか読みづらくても仕方ない!!
仕方ないのだふぇねっく!!!!

※2017/12/18追記
libuvのバインドも終わりました Nimuv

※2018/01/22
Webサーバー書きました。
https://qiita.com/2vg/items/23175c9bcd0c714c1a85

イントロ

みなさん、Nimって言語知ってます?
こいつですよこいつ → Nim

簡単に言うと、
Python風構文で書けてC並の速度が出てクロスプラットフォームで何故かJavaScriptにもコンパイルできる強力なメタプログラミング言語です(キリッ

マクロ機能が強力らしいですね。
ついでにGCもあるのでCのメモリ管理みたいなことはしなくてよさそう。
ちなみにこの記事には一切マクロが出てきません。

Nimについては上の公式サイトや適当にWikipediaでも参照しましょう。 Nim: Wikipedia

かなりマイナー言語であるがゆえにドキュメントが非常に少ないのが残念
でも公式ドキュメントは最高 コンパイラは友達

今回はですね、Nimを学ぶためにHTTPサーバーを書きたいと思います(唐突
Webサーバーを書くことでたぶんいい感じに言語を学べるはずです。たぶん。(こなみかん)

「おじさんが若い頃はウェブサーバを自分で作ったんだよ」
 @mattn_jpさん

だそうです。私もつくる!!!!!!!!!
あとこの辺のスライドも面白いのでぜひぜひ
新しいプログラミング言語の学び方 HTTPサーバーを作って学ぶ Java, Scala, Clojure

実は標準モジュールでAsyncHTTPServerという既に非同期なWebサーバーを簡単に作れるモジュールがあるのですが、
Webサーバー内部の動作とかを詳しく学んでみたいソケットレベルから書きます。えぇ、書いちゃいますよ私は

もちろん作るからには動作が速い方がいいので、
標準モジュールのAsyncHTTPServerと今回作ったもので同じレスポンスを返す条件のもと、ベンチマークで速度を超えていければいいかなと思います。目標は1.5倍くらい早くなればいいかな!!!!

Q, Webサーバーってなぁに?

WebサーバーとはHTTPという通信プロトコルでうんぬんかんぬん
Webサーバー: Wikipedia

Q, HTTPってなぁn

RFC7230 ~ RFC7235を読んでね♡
RFC723X (日本語訳)

Webサーバー Hello World !

ではソケットから書いていきます。
ちょっと理解すればそんなに難しくないと思います。

まず、Webサーバーの流れとしてはざっくりこんなかんじ

  1. TCPのportをbind、そしてlistenでクライアントの受け入れを開始
  2. accpetをコールして、クライアントからの接続を受け付け
  3. recvでクライアントからのHTTPリクエストを受け取る
  4. クライアントから受け取ったリクエストにそって処理する
  5. HTTPレスポンスの適切な書き方に従って、クライアントにレスポンスを返す
  6. クライアントとの接続をclose、そのクライアントとのTCP接続を終了する

今回は特にリクエストに沿った処理(4ばんのやつ!)をやらずに、単純に決められたレスポンスを返す簡単なものを作ります。

とりあえずざっくり上の流れを実装したコードをぺたり

import nativesockets

let body= "HTTP/1.1 200 OK" & "\r\n" &
          "Content-Length: 11" & "\r\n" &
          "Hello World" & "\r\n\r\n"

let server = newNativeSocket()
server.setSockOptInt(cint(SOL_SOCKET), SO_REUSEADDR, 1)

var name: SockAddr_in
name.sin_family = toInt(AF_INET)
name.sin_port = htons(8080'u16)
name.sin_addr.s_addr = htonl(INADDR_ANY)

discard server.bindAddr(cast[ptr SockAddr](addr name), Socklen(sizeof(name)))
discard server.listen()

var
  sockAddress: Sockaddr_in
  addrLen = SockLen(sizeof(sockAddress))
  incoming: array[8192, char]
  client: SocketHandle

while true:
  client = sock.accept(cast[ptr SockAddr](addr(sockAddress)), addr(addrLen))
  discard client.recv(addr incoming, incoming.len, 0)
  discard client.send(addr body[0], body.len, 0)
  clinet.close()

server.close()

ちょっと説明します

Nimには変数宣言が3種類あるのです
var, let, constがあって、
varは再代入可能、letとconstは再代入不可です。
letとconstの違いはコンパイル時に値が決まっているかどうか
例えば標準入力で入力したものをletで宣言した変数には入れることができるけど、constはコンパイル時に値が決まってないといけないので、constで宣言した変数に標準入力で入力された物を入れるようなコードを書くとコンパイルエラーになります。
あと、Nimではポインターを扱えるのですが、ポインターを使って何か入れるときにはvarを使わなきゃいけないのです。

bodyとserverはコード内で特に再代入することはないのでletを使っています。
あとはポインターなりで使うのでvarです。

bodyに入っているのはクライアントに返すHTTPレスポンスです。
上記にも書いたとおり、特に処理は行わないのでbodyを返すだけのWebサーバーになります。
レスポンスの書き方については上記のRFCを参照して下さい。ここでは省きます。

newNativeSocket()でサーバー側のソケットファイルを作ります。

次の行では、REUSEADDRオプションをオンにしています。
このオプションについてはググればわかるのですが、TCPのTIME_WAITの関係云々でサーバーを終了した後にまた開始しようとするとアドレスが使えませんみたいなエラーが出ることがあります。(でないこともある。条件によるのです)
それを回避するためにオンにします。

name変数はアドレスとかポートとかなんかそこらへんの関係ありそうなやつを格納しています。たぶん。
次のbindAddr()でバインド、そしてlisten()でクライアントの受け付けを開始します。

ちなみにdiscardというのは戻り値を破棄するときに使います。
戻り値でintやstringがある関数で使うとvoidっぽく使えます(伝われ)

varで宣言された4つの変数はクライアントが接続してきた時に使います。
ポインターだったりを使うのでvarです。
あ、ちなみにポインターはaddr()でゲットできます。

ところでちょいちょいhoge.addrだったりaddr(hoge)って書き方があるのですが、
Nimでは関数の第一引数を呼び出しの前に持ってきてドットで繋げると第一引数を省略して呼び出すことができます(伝われ)

あとの流れはコードを見ればわかると思いますが、
acceptでクライアントを受け付け、recvで受け取って、特に処理をせずすぐに
sendでbodyを送り込み、closeでクライアントとの接続を終了します。

最後のclose()はサーバーの終了を意味しますが、whileで無限にaccept, recv, sendを回しているため、ここにたどり着く事はないです。たぶん。

Webサーバーを速くしたい!

上記で実装したものはなんと、1クライアントが接続したらほかのクライアントは接続できないポンコツWebサーバーです。
理由はrecvやsendが流れをブロックしてしまうコールだからです。

ブロックせずに複数のクライアントを捌くにはいくつか方法があって、

  1. Acceptした後の処理は別スレッド or 別プロセスが行う
  2. Acceptから全ての処理をn個のスレッド or プロセスで行う
  3. イベントループでIOの多重化(合ってるかなこれ)

こんなかんじ。他にもあるかも!!!

1番目はWorker Model, 2番目はPrefork or Prethread Model, 3番目はイベント駆動(イベントドリブン)モデルと呼ばれているみたいです。

散々C10K問題が騒がれていましたが、現代のコンピュータのスペックだとWorkerModelでも大丈夫かも、なんて言われてたりします。ソースはないのでほんとかはわかりません。

今回は3番目のイベント駆動モデルで組んじゃいます。イベント駆動、流行ってるよね!!!
よく聞くのはあの有名なイベント駆動Node.jsですね。
Webサーバーだと私も愛用しているNginxやh2oなんかもそうですね。

イベントループの仕組みはselect, poll, epollなんかのシステムコールを使ってファイルディスクリプタが読み込み可能とかそういうのを見続ける感じです。
これを使うと読み込めないファイルディスクリプタは即座にエラーを返し、次のファイルディスクリプタに移るので流れをブロックしないのです!!!
とはいいつつも読み込み可能になって処理をする部分で、重い処理なんかがあるとイベントループはそこで止まってしまい次のファイルディスクリプタに進めません。
ループが止まってしまうとあれなのでここらへんも非同期とかでなんとかする必要がありそうですね。今回はそういうのやらないけど

select, pollよりもepollが優れているとよく言われます。
epollはファイルディスクリプタの監視をカーネルに全部任せるので計算量の違いで効率がいいらしい。たぶん。
速いならepollを使おう!!なんて思ったのですがこいつ実はLinux専用で、
BSDにはなかったりもちろんWindowsなんかにもあるわけがないです。
あと素でイベントループを書くのも意外とめんどくさいんですよ。

「言語がクロスプラットフォームなのにLinuxしかサポートしないの...?」
  CV: 可愛い女の子

なんて可愛い女の子に言われたらどうしますか?全力で対応しますよね?
えぇ、私は対応しますよ

ということでここらへんを上手くいい感じにしてくれるライブラリが世の中にはいくつかあります。
例えばさっきのNode.jsなんかはlibuvでイベント駆動しています。確かh2oもだったかな?わかんない。
Node.js、昔はlibevって高速なイベントループライブラリと非同期IOのlibeioを使っていました。libevもいい感じにしてくれるやつですね。
たぶんイベントループでぐぐるとlibeventとかいうのもちらほら出てきます。こいつも仲間です。
有名なのはlibuv, libev, libeventの3つかな?他にもいっぱいあります。

NimはC言語やC++なんかと連携できるのでここらのライブラリはどれも使えるはずです。
ライブラリのバインドがあれば

そうです、NimでCなんかのライブラリを使うにはバインドが必要です。ヘッダをincludeしたら使えるなんて甘ちゃんです。
てかコンパイルしたら結局Cとかになるのにめんどっちだよね。や、文句言ってスミマセン...

ちなみにc2nimってやつでいい感じにCをNimに変換できるらしい(?)ものがあったんですがビルドできませんでした。このポンコツがっ!!!!ごめんなさい

使う構造体や関数なんかをいちいち書かなきゃならんのです。めんどくさいね。
とりあえずやることは一つです。Googleを使います。

Nim libuv [検索] 👈 ポチー

なんと、数個既にバインドされてるようです。さすが我らのGoogle先生。
ちなみにlibeventとlibevはありませんでした...残念

試しにlibuvがバインドされたものを試そうと思ったのですが...なぜでしょうか、上手く行かないんですねこれが
バインドされてるもの全てやってみましたが全部ダメです、全滅。最近のもダメ。意味わからん
もしかして扱いを間違えてる説がある...?そんなわけない!!!ほんとに使えないねん!!!!

使えるものがありません。ライブラリが少ないのもNimの欠点だと思います。

仕方ない、無いなら作るしかないね!!!!!!!

ということでパフォーマンスが高いとよく言われるlibevを使ってみたかったので、バインドしました。 こちらにあるのでNim信者で使いたい方がいればぜひ使ってやってください。一部バインドできてないっぽいのがあるのでなんとかします。そのうち。

Nimev

libuvも途中までバインドしてて絶賛バインドなうなのでできたら記事を更新します。(ちゃんと動くやつだからね!!!!!)
バインドしました → Nimuv

バインドが終わって動作テストも終わったので早速組んでみました。
コードはそこまで長くならずにこんな感じになりました。

じゃじゃーん

import net, nativesockets, nimev

# こいつを使うとノンブロッキングにしながらacceptできる すごい
proc accept4(a1: cint, a2: ptr SockAddr, a3: ptr Socklen, flags: cint): cint
  {.importc, header: "<sys/socket.h>".}

var
  # Nagleを無効化するためにインポート たぶん早くなる
  TCP_NODELAY {.importc: "TCP_NODELAY", header: "<netinet/tcp.h>".}: cint

  # accept4コールでつかう
  SOCK_NONBLOCK* {.importc, header: "<sys/socket.h>".}: cint

  # 上に同じく
  SOCK_CLOEXEC* {.importc, header: "<sys/socket.h>".}: cint

  # クライアントに返すやつ 固定
  body = "HTTP/1.1 200 OK" & "\r\L" &
         "Connection: keep-alive" & "\r\L" &
         "Content-Length: 11"  & "\r\L" &
         "Content-Type: text/plain; charset=utf-8" & "\r\L" & "\r\L" &
         "Hello World"

  # クライアントの処理につかうこーるばっく
  client_cb: ev_io_cb = proc(loop: ptr ev_loop_t, w: ptr ev_io, revents: cint): void {.cdecl.} =
    var incoming: array[1024, char]
    let r = w[].fd.SocketHandle.recv(addr(incoming), incoming.len, 0).cint

    # ほんとはここもう少しなんとかしないといけない
    if r == -1:
      echo "error"
    # recvが0ならクライアントをクローズできる状態
    elif r == 0:
      # くろーずします
      w[].fd.SocketHandle.close()
      # くろーずしたので監視対象から外す
      ev_io_stop(loop, w)
      # 手動でメモリ確保したので開放してあげる ところで開放ってなんかえろいよね
      dealloc(w)
    # クライアントに送り込むやつ
    else:
      discard w[].fd.SocketHandle.send(addr(body[0]), body.len, 0)

  # サーバーがクライアントをacceptするときに使うこーるばっく
  server_cb: ev_io_cb = proc(loop: ptr ev_loop_t, w: ptr ev_io, revents: cint): void {.cdecl.} =
    var
      sockAddress: SockaddrIn
      addrLen = sockAddress.sizeof.SockLen
      client_watcher = cast[ptr ev_io](alloc(sizeof(ev_io)))

    # ここでaccept ついでにノンブロッキングにしちゃう 一度でできるのすごい
    var client = w[].fd.accept4(cast[ptr SockAddr](addr(sockAddress)), addr(addrLen), SOCK_NONBLOCK or SOCK_CLOEXEC)

    # 監視対象として読み込みを表すEV_READをイベントとクライアントのFDとかをひもづける?
    ev_io_init(client_watcher, client_cb, client, EV_READ)
    # 監視スタート。 だと思う あんまりドキュメント読んでない
    ev_io_start(loop, client_watcher)

# サーバーのソケットをいい感じにセットアップするやつ
proc newServerSocket(port: int = 8080, backlog: int = SOMAXCONN): cint =
  let server = newSocket()

  # アドレスのあれ
  server.setSockOpt(OptReuseAddr, true)       
  # ポートのあれ
  server.setSockOpt(OptReusePort, true)
  # Nagle無効化
  server.getFd.setSockOptInt(cint(IPPROTO_TCP), TCP_NODELAY, 1)
  # もちろんノンブロッキング
  server.getFd.setBlocking(false)

  # バインド
  server.bindAddr(Port(port))
  # バックログ数を設定してリッスン開始
  server.listen(backlog.cint)

  # そしてサーバーのFDをりたーん!!!
  return server.getFd().cint

# こっからメイン

# ループで使う奴ら
var
  loop = ev_default_loop(0)
  watcher: ev_io
  server = newServerSocket()

# 
ev_io_init(watcher.addr, server_cb, server, EV_READ)
ev_io_start(loop, watcher.addr)

# ループが始まる!!!
ev_loop(loop, 0)

# ここはたぶん到達できない。 はず
server.SocketHandle.close()

素でepollを書くともう少し長くなるのですが、だいぶスッキリしてかけたと思います。
マクロ使ったらもっといい感じになるかな?

大体はコメントにあるとおりなので説明省きたい

netモジュールはnativesocketsのラッパーで高水準でソケット扱いやすくするやつ。
なんかfamilyとかめんどくさくてここだけ楽をしたとかそういうわけではない。
高水準モジュールもあるぞと言いたかっただけ(大嘘

そんなことよりaccept4コールが強力です。
accept、setnonblockingでコールが2つの所を一つで済ますのですからそこそこの高速化につながるでしょう。

ちょいちょいあるcintはC用のintらしいのですが中身は確か結局Nimのintなのでintでよくない?ダメなの?

あとオリジナルのlibevとちょっと違うのはコールバック関数用の型を新しく作ってバインドしてます。
ほかはオリジナルとほぼ同じAPIで使えるはずです。そういうふうにバインドしたもん。

ただ、ev_loopがオブジェクト(Cとかでいう構造体)と関数の方で被るので、
ev_loop_tの型でバインドしました。
ev_loopの関数はオリジナルだとマクロなのでinlineで展開するようにバインドしちゃったんだけどいいのかな((
動いてるしいっか(適当
Nimでもソース置換とかそういうのがあればいいですね、ASTの作成なんかは強力ですけど、確かソースの置換みたいなのは実装されてないはず...実装ほしいね

Nagle無効はnginxでいうtcp_nodelayのやつです
早くなると思いますよ、えぇ

バックログはあれですね、最大受付数みたいなやつだっけ?
標準は128だったかな...カーネルパラメータを弄ると調節できます。
確かnet.core.somaxconn。ここ見ればわかりそう
nginx - カーネルパラメーターのチューニング

あとはコメントか調べるか直接聞いてください(小声

流れ的にはとりあえずループを作成してオブジェクト作ってそこに監視イベントとかFDとかこーるばっくをぶち込んでセットしてループ回すみたいな感じです。ざっくりね。

あ、FDはファイルディスクリプタのことです。

ベンチマーク

完成したので、動作速度検証してみましょう
コンパイルはどっちもnim c -d:release -r hoge
で最適化コンパイルをしています。

まずは標準モジュール、AsyncHTTPServerの入場です。
コードはこれ

import asynchttpserver, asyncdispatch

var server = newAsyncHttpServer()
proc cb(req: Request) {.async.} =
  await req.respond(Http200, "Hello World")

waitFor server.serve(Port(8080), cb)

そしてベンチ結果
キャプチャ.PNG

これは早いのか?わからん

つづいて私が今回libevで書いたやつです。じゃじゃん
キャプチャ.PNG

!?!?!?!

圧 倒 的 差

びっくり、こんなに早いとは思わなかった
まぁAsyncHTTPServerは内部でパースとかもしちゃってると思うのでそこらへんが重くなってるのかな それにしても約4~5倍の差が出るのはちょっと笑っちゃう

ついでにサーバーにGoも入ってたので、思い立っておまけでGoでもベンチしてみた
実行はgo run test.goでやった
コードはこれ

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

そしてベンチ。 青いベンチ。 サスケ。 ごめんなさい。
キャプチャ.PNG

わー Goよりも速いすごーい!!!!
てかAsyncHTTPServer一番遅いじゃん...アッ

まとめ

無事速いWebサーバーっぽいのが書けたかな?リクエストパースとか処理部分をごにょごにょすればいい感じになりそう(伝われ

後半説明だいぶ省いちゃったので何かあればコメントとかで文句言ってください...治します...

さいごに

就職したい。

おしまい