http-parser と picohttpparser との比較

h2o Advent Calendar 2014 の記事です。

Minefield は http-parser (nginx のパーサーと基本的に同じもの) を利用しているのですが、これを picohttpparser に置き換えてみます。 最初は libh2o に置き換えるつもりだったのですが、まだ API が unstable でアプリに組み込む手順も確立されてないので parser だけを置き換えることにしました。

まだ完成していないのですが、 Hello, World くらいは問題なく動くレベルになったので、ここまでで気づいた http-parser と picohttpparser の違いを紹介し、ベンチマーク対決もやってみます。

特に置き換えを始める前は、 perf top で execute_http_parser という http-parser の関数が2%程度しかCPUを消費していないのを見て、実際にサーバーの性能に与える影響については懐疑的だったのですが、実際に置き換えてみると意外に影響が大きいことが分かりました。

イベントドリブン vs シーケンシャル

http-parser はイベントドリブンで、ヘッダのフィールド名、フィールド値など個々にコールバック関数が呼ばれます。毎回与えられた受信バッファを完全に消費する設計になっているので、フィールド名の途中までで一旦コールバックが呼ばれ、次に受信バッファを与えた時にまたフィールド名の続きがコールバックされるかもしれません。

一方 picohttpparser は、ほぼステートレスで、ヘッダを一気にパースしますす。パース結果は受信バッファ上の位置と長さという形で返されるので、ほぼゼロコピーになっています。 (1度に read できなかった場合、2度め以降は前回読み込んだ位置より後ろに HTTP ヘッダの終端があるかどうかをチェックするので、「前回読み込んだ位置」が唯一の状態になります)

http-parser の状態管理や追記に対応するためのデータ構造やコードが面倒で、パーサー本体よりもこちらのほうが性能に影響を与えそうです。 頭のなかに状態表を持ってないとコードが読めないのでメンテナンス性の問題もあります。 picohttpparser にすることで、最初から完全に揃ったヘッダを受け取れるのでかなりコードをシンプルにすることが出来ました。

一方、 http-parser は毎回受信バッファを完全に消費するので1つのバッファをスレッド or プロセス全体で使いまわせるのですが、 picohttpparser では1度の read でヘッダ全体を受信できなかった場合にその受信バッファを保持しておく必要が生じます。

Minefield ではとりあえずコネクションごとに 32KB の read buffer を用意しましたが、 keep-alive 中のコネクションにこのバッファを確保し続けるのはもったいないのでもう少し賢く管理する必要があります。 Minefield は http pipelining にも対応しているので、受信バッファの管理は特に難しい問題になります。

HTTP/1.1 に対する知識

http-parser では、リクエストメソッドenum で渡してもらえますし、リクエストボディも callback で渡されます。

一方 picohttpparser では、リクエストメソッドも先頭位置と長さだけが渡されますし、リクエストボディも自分で処理する必要があります。自分で Content-Length ヘッダや Transfer-Encoding: chunked ヘッダを見つけて、指定バイト数読み取るか、 phr_decode_chunked() するかを決める必要があります。

この点は HTTP/1.1 の仕様の多くをパーサー側でカバーしてくれている http-parser の方が簡単ですし、脆弱性を作りにくく堅牢になると思います。

既存の http-parser を使ったアプリを picohttpparser に移植する際に問題になりそうなこと

HTTP/1.1 の知識が分散することもそうですが、バッファに関する制限が変わり、サーバーの仕様に影響を与えそうです。

どういうことかというと、 http-parser ではヘッダ名、ヘッダ値ごとに callback が呼ばれ、追記される可能性があるので、1つのヘッダの大きさを制限したくなります。最大長が決まっていれば、追記を許すと言ってもリアロケーションが不要になるからです。 一方 picohttpparser では、ヘッダを一括でパースするので、個々のヘッダの大きさよりもヘッダ全体の大きさを制限して、受信バッファの大きさを固定したくなります。

このため、パーサーの置き換えは Apache 2.2 -> 2.4 のような大規模なバージョンアップのタイミングで行った方がよいでしょう。

ベンチマーク

おまちかねのベンチマークです。 Gazelle vs Meinheld と同じ環境 (c3.xlarge) を使い、 wrk から Minefield の並列数は16ずつにします。

nginx が間に入ると Minefield の性能の変化が見えにくくなるので、直接アタックするのみにします。ただし、前回同様の keep-alive に加えて、今回はより性能が上がる(パーサー部分の負荷も増える) pipelining も試してみます。

ベンチマーク詳細 (各4回計測し、2番めに性能が高かった数値を採用)

parser keep-alive pipeline
http-parser 545552.78 782338.04
picohttpparser 567908.05 818628.66

f:id:methane:20141222122716p:plain

4% 強の性能向上が観測できました。今後完成度を高めていく上で、より picohttpparser に最適化されることで性能が上る可能性と、まだケアできてない HTTP/1.1 の仕様を実装することで速度が下がる可能性の両方がありますが、おおまかな目安にはなると思います。

このブログに乗せているコードは引用を除き CC0 1.0 で提供します。