net/httpより10倍速いvalyala/fasthttpが面白そうなので調査してみた件

こんにちは。エウレカでコーヒーを淹れることを生業にしている@MasashiSalvadorです。将来の夢はコーヒー豆になることです。type CofffeBean interfaceを実装すればいいんでしょうか。
さて、くだらないことはさておき、
今回は11月頃少し話題になったGoのfasthttpというライブラリに関して調べたことをまとめたいと思います。

流れとしては

  • net/httpについて(簡潔に)
  • fasthttpの出現
  • fasthttpへ移行は簡単にできるのか?
  • 各種Web Application Frameworkの対応状況
  • valyala氏のISSUE上でのやり取りまとめ
  • 高速化の秘訣
  • 概略図
  • コードリーディング
  • まとめ

こんな感じで進めたいと思います。

net/httpという優等生

GoにはHTTPを扱うライブラリとしてnet/httpがあります。net/httpは割と使い勝手がよく、基本的な機能は網羅されているため、事実上のデファクトスタンダードになっています。

標準のnet/httpを使うことで、簡単なサーバをWeb Application Frameworkを使わずにサクッと書いてリリースするというのが、Goらしい使い方と言えます。

既存のGoのWeb Application Frameworkも、使い方の差異はあれ、HTTPを扱う部分ではnet/httpに依存しています。 
(依存していないWeb Application Frameworkがあれば教えてほしいです)

現れたvalyala/fasthttp

valyala/fasthttp(GitHub)

net/httpがメモリの確保の観点から見ると最適な実装になっていないことに注目して、valyala氏が実装したのがfasthttpです。

下記はREADMEからの引用です。

Currently fasthttp is successfully used in a production serving 100K rps from 1M concurrent keep-alive connections on a single server.

valyala氏本人がproductionに投入しているだけなのか、それとも他にもproductionに投入している企業があるのかは定かではありませんが、ハイパフォーマンスが謳われています。

公式のベンチマーク

Goに標準で備わっているベンチマークの機能を利用したベンチマークの結果がREADMEに掲載されています。最速のケースではnet/httpの10倍ほど高い性能を示すようです。

(参考) Go言語のベンチマークでパフォーマンス測定

In short, fasthttp server is up to 10 times faster than net/http. Below are benchmark results.
//net/http
GOMAXPROCS=1 go test -bench=NetHTTPServerGet -benchmem -benchtime=5s
PASS
BenchmarkNetHTTPServerGet1ReqPerConn 300000 21236 ns/op 2404 B/op 30 allocs/op
BenchmarkNetHTTPServerGet2ReqPerConn 500000 14634 ns/op 2371 B/op 24 allocs/op
BenchmarkNetHTTPServerGet10ReqPerConn 1000000 9447 ns/op 2101 B/op 19 allocs/op
BenchmarkNetHTTPServerGet10KReqPerConn 1000000 7939 ns/op 2033 B/op 18 allocs/op
BenchmarkNetHTTPServerGet1ReqPerConn10KClients 300000 30291 ns/op 4589 B/op 31 allocs/op
BenchmarkNetHTTPServerGet2ReqPerConn10KClients 500000 23199 ns/op 3581 B/op 25 allocs/op
BenchmarkNetHTTPServerGet10ReqPerConn10KClients 500000 13270 ns/op 2621 B/op 19 allocs/op
BenchmarkNetHTTPServerGet100ReqPerConn10KClients 500000 11412 ns/op 2119 B/op 18 allocs/op
#valyala/fasthttp
$ GOMAXPROCS=1 go test -bench=kServerGet -benchmem -benchtime=5s
PASS
BenchmarkServerGet1ReqPerConn 3000000 2341 ns/op 0 B/op 0 allocs/op
BenchmarkServerGet2ReqPerConn 5000000 1799 ns/op 0 B/op 0 allocs/op
BenchmarkServerGet10ReqPerConn 5000000 1239 ns/op 0 B/op 0 allocs/op
BenchmarkServerGet10KReqPerConn 10000000 1090 ns/op 0 B/op 0 allocs/op
BenchmarkServerGet1ReqPerConn10KClients 3000000 2860 ns/op 4 B/op 0 allocs/op
BenchmarkServerGet2ReqPerConn10KClients 3000000 1992 ns/op 1 B/op 0 allocs/op
BenchmarkServerGet10ReqPerConn10KClients 5000000 1297 ns/op 1 B/op 0 allocs/op
BenchmarkServerGet100ReqPerConn10KClients 10000000 1264 ns/op 9 B/op 0 allocs/op
メモリの確保に関連した問題が解消されていることがベンチマークからわかります。
net/httpをvalyala/fasthttpに置き換えよう
valyala/fasthttpの方がベンチマーク的には速度面で優れているので、置き換えを行うことは理にかなっているように見えます。しかし、net/httpを改良するのでなく、新しくfasthttpをリリースすることをvalyala氏が選択したことからも分かるように、置き換えには結構なコストが掛かりそうです。
異なるインターフェイス
最も大きな違いはListenAndServeのインターフェースの違いです。Goを使って簡単なサーバを作成する際に、下記のようなコードを見たことがあると思います。
// net/http
func fooHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Eureka!!")
}
func main () {
http.HandleFunc("/", FooHandler)
http.ListenAndServe(":9000", nil)
}
これに対しfasthttpだと下記のような書き方になります
// fasthttp
func fastFooHandler(ctx *fasthttp.RequestCtx) {
ctx.SetContentType("foo/bar")
ctx.SetStatusCode(fasthttp.StatusOK)
}
fasthttp.ListenAndServe("xxxx", fastFooHandler)
ということで、インターフェイスが異なるので、単純に置き換えていけばいいというわけではありません。
fasthttp対応に関するディスカッション
本家のリポジトリのやWAFのginやmartini(すでにメンテされていないが)
でISSUEが立てられています。
簡単に要約すると
本家リポジトリ
Q.「GoのWAF全体に言えることだけどメンテされてないライブラリに依存しているものが多いよね。
すでにfasthttpのコードベースは巨大だけど、valyala氏はメンテしていけるのか?」
A. 「メンテはしていくつもり」
Q. 「fasthttpは本当にproductionに投入できるくらい安定しているのか?」
A. 「実際にproductionで使っている(動画配信サーバに利用している)」
Q.「このプロジェクトをどうやって進めていくつもりなのか?」
A. 「golang-nutsにポストしておいた(のでそこでやり取りしていくつもり)」
Q.「ベンチマークの詳細が欲しい(マシン / ネットワーク / コネクションの種類)、また1m concurrent connectionをどう実現しているのかわからない、手元でテストしたらnet/httpの4xくらいしか早くなかったけど」
A. 「1m concurrent connectionはproduction環境で実現している。productionでは動画配信をやっていて、マシンやネットワークのスペックはこんな感じ」
「Hi、WebFrameworkBenchmarkの著者です。READMEのPerformance optimization tips for multi-core systemsの内容に疑問があって、
別のISSUEで議論されている内容もましたが、そこで議論されているのとは違う疑問です。fasthttpが高速だという主張を否定したいわけじゃない。
fasthttpは超高速です。私のベンチマークでも4番目のスコアを示しています」
各種WAF
# martini
「fasthttpを使えば高速になるから、martiniの開発を再開してfasthttpサポートを入れて欲しい」
「HTTPの部分で高速化してもreflection等が遅いから結局意味が無いと思う」
(確かに..しかしressurectって単語が使われておりアツい)
# gin
「fasthttpのサポートをして欲しい」
「fasthttpサポートができたら素晴らしいけど、fasthttpやfasthttprouterの組み込みや他のmiddlewareの変換も考えるとかなり大変な仕事だ。
綺麗に組み込みや変換ができれば、スタンダードになるんじゃないかな」
処理を簡単に追う
  • 実装の差がどこにあるのか
  • なぜそんなに高速を謳えるのか
が大変気になったので、ListenAndServeが呼ばれた時の処理を少し追いかけてみることにしました。読み解くのは結構骨が折れましたが、概念としては何をやっているかが分かったように思えます。
登場人物と概略
  • fasthttp.Server
  • fasthttp.workerPool
登場人物は主にこの2つの構造体です。
workerPoolと呼ばれる場所に、リクエストを処理しているコネクションを常に対流させることで、適度にコネクションを使いまわせるような実装になっています(new net.Connの回数を減らすことができている?)
概略図
fasthttp内でworkerと呼ばれているものはnet.Conn(コネクション)を受け渡せるチャンネルとそのチャンネルからnet.Connが送信されてくるのを待ち受けているgoroutineの組です。図で表すと下記のようになります。
リクエストに対する処理(=HandleFunc)を実行していない待機状態のworkerの列はworkerPoolと呼ばれています。リクエストを処理し始めると列から外れ、処理し終わると列に戻ります。
そして、net.Conn(コネクション)がチャンネル経由で送信され、goroutine側が受信すると、HTTPリクエストに対応するHandleFunc関数の処理が走対応する。使われたnet.ConnはCloseされないので、使い回されます。これによってメモリの節約を行っているようです。
実際に追ってみる
// server.go#L58
// # まずは始まりのListenAndServe ... これはnet/httpと同じ
// ListenAndServe serves HTTP requests from the given TCP addr
// using the given handler.
func ListenAndServe(addr string, handler RequestHandler) error {
s := &Server{
Handler: handler,
}
return s.ListenAndServe(addr)
}
Server構造体のListenAndServeが呼ばれます。
Server構造体は下記で定義される構造体で、並行度やIPごとのコネクション数などの要素を持ちます。
// server.go#L106 
// # 適宜コメントを消しています
type Server struct {
Handler RequestHandler // Handler、func(ctx fasthttp.RequestCtx) 最終的にはこの関数が呼ばれる
Name string
Concurrency int // concurrent connectionの最大数
ReadBufferSize int
WriteBufferSize int
ReadTimeout time.Duration
WriteTimeout time.Duration
MaxConnsPerIP int // IPごとに許されるconcurrent connectionの数
MaxRequestsPerConn int // 1つのconnectionが捌けるrequestの最大数 デフォルトは無制限
MaxKeepaliveDuration time.Duration
MaxRequestBodySize int
ReduceMemoryUsage bool // trueにするとmemory usageが50%減る(と書いてある...)
GetOnly bool
Logger Logger
concurrency uint32
perIPConnCounter perIPConnCounter
serverName atomic.Value
ctxPool        sync.Pool
readerPool sync.Pool
writerPool sync.Pool
hijackConnPool sync.Pool
bytePool sync.Pool
}
Server構造体を見たところで更に追っていきます。
// server.go#L1044
func (s *Server) Serve(ln net.Listener) error {
var lastOverflowErrorTime time.Time
var lastPerIPErrorTime time.Time
var c net.Conn
var err error
maxWorkersCount := s.getConcurrency()
wp := &workerPool{
WorkerFunc: s.serveConn, // WokerFuncが最終的に呼ばれる
MaxWorkersCount: maxWorkersCount,
Logger: s.logger(),
}
wp.Start() // workerPoolをstartする
for {
if c, err = acceptConn(s, ln, &lastPerIPErrorTime); err != nil {
// (略)
}
if !wp.Serve(c) {
c.Close()
// (略)
}
c = nil
}
}
workerPoolを始動し、AcceptしたConnectionを、workerPoolのServeに渡します。
workerPoolは下記のような構造体で、ServerのHandler関数はworkerPoolのWorkerFunc関数として最終的に呼びだされ、呼出し後にrelease関数が呼ばれた時間が記録されます。
一応WorkerFunc関数としてworkerPoolの生成時に渡されているServerのserveConn関数を見ておきます。長いのでこの関数がHandlerにリクエストコンテキストを渡して処理を実行していることの確認だけをサクッと。
func (s *Server) serveConn(c net.Conn) error {
// 略
ctx.connRequestNum = connRequestNum
ctx.connTime = connTime
ctx.time = currentTime
ctx.Response.Reset()
s.Handler(ctx) // Handlerにコンテキストを渡して実行
// 略
}
// workerpool.go#17
type workerPool struct {
WorkerFunc func(c net.Conn) error // 終了時にc:connectionをCloseしない
MaxWorkersCount int
Logger Logger
lock sync.Mutex
workersCount int
mustStop bool
ready []*workerChan // 待機しているworkerが詰まった配列
stopCh chan struct{}
}
// workerPool.ready = 待機しているworkerの中身, workerと呼ばれる
type workerChan struct {
t time.Time // WorkerFuncが呼ばれ、処理終了後にrelease関数が呼ばれた時刻
ch chan net.Conn // workerにconnectionを渡すためのチャンネル
}
workerPoolを開始する関数Start()が呼ばれると、定期的にclean関数が呼ばれます。
clean関数は、使用されてから1秒以上経っているworkerPool.ready配列の先頭のworkerを開放します。
// workerpool.go#42
func (wp *workerPool) Start() {
// (略)
for {
select {
case <-stopCh:
return
default:
time.Sleep(10 * time.Second)
}
wp.clean() // 使われてから時間の経っているready配列の先頭のworkerを開放
}
}()
}
Serve関数は、ready配列から1つworkerを取得するgetChを呼びチャンネル経由でconnectionを渡します。取得したworkerは、WorkerFunc関数(=ServerのHandle関数と同義)がconenctionの受信を待ち受けているチャンネルを持っているので、そのチャンネルにconnectionを1つ送信します。
// workerpool.go
func (wp *workerPool) Serve(c net.Conn) bool {
ch := wp.getCh()
if ch == nil {
return false
}
ch.ch <- c
return true
}
getCh関数は、ready配列からworkerを1つ取得します。共有されているready配列に対して数を減らしたり増やしたりを行うので、適宜sync.Mutexのロック機構が使われます。
返すworkerがない場合だけcreateして、goroutineとしてWorkerFunc(ServerのHandleFunc関数と同義)を呼び出します。
goroutineとして実行する際の引数として、connectionを渡すためのチャンネルを与えているので、Serve関数側から非同期にconnectionを送信することができます。
// workerpool.go
func (wp *workerPool) getCh() *workerChan {
var ch *workerChan
createWorker := false
wp.lock.Lock() 
ready := wp.ready
n := len(ready) - 1
if n < 0 { // なけりゃ作るフラグを立てる(作ってないのにカウントを上げる...)
if wp.workersCount < wp.MaxWorkersCount {
createWorker = true
wp.workersCount++
}
} else {
ch = ready[n] // ready配列の一番後ろを割り当てる
wp.ready = ready[:n] // ready配列からいま使う用に割り当てたworkerを除く
}
wp.lock.Unlock()
if ch == nil { //新しく作る場合だけ新たにgoroutineを呼ぶ(readyなworkerがいる場合はgoroutineを呼び出さずにworkerを返す。
if !createWorker {
return nil
}
vch := workerChanPool.Get()
if vch == nil {
vch = &workerChan{
ch: make(chan net.Conn, workerChanCap),
}
}
ch = vch.(*workerChan)
go func() {
wp.workerFunc(ch) // workerFuncをgoroutineとして呼び出す。
workerChanPool.Put(vch)
}()
}
return ch
}
WorkerFuncはgoroutineとして実行される。rangeをチャンネルに対して呼ぶ部分で、チャンネルからconnectionが送られてくるのを待ち受ける。
/// workerpool.go#176
func (wp *workerPool) workerFunc(ch *workerChan) {
var c net.Conn
var err error
defer func() {
if r := recover(); r != nil {
wp.Logger.Printf("panic: %s\nStack trace:\n%s", r, debug.Stack())
}
if c != nil {
c.Close()
wp.release(ch) // 実行後にrelease関数が呼ばれる
}
}()
for c = range ch.ch {
if c == nil {
break
}
if err = wp.WorkerFunc(c); err != nil && err != errHijacked {
// 略
}
if err != errHijacked {
c.Close()
}
c = nil
if !wp.release(ch) {
break
}
}
}
このループが終了するのはrelease()関数でfalseが帰ってきた時なので、connectionはそれまでの間Closeされずに使いまわされることになります。
一応release関数の中身を見ておくと、
mustStopがtrueになっている場合だけfalseを返すのがわかります。
mustStopはStop関数が呼ばれた時にtrueになるので、エラーを検知してサーバを止める(= workerPoolを止める)まで延々とconnectionは使いまわされ続けることが見て取れます。
// workerpool.go#162
func (wp *workerPool) release(ch *workerChan) bool {
ch.t = time.Now()
wp.lock.Lock()
if wp.mustStop { // mustStopがtrueになっている場合にfalseを返す
wp.lock.Unlock()
return false
}
wp.ready = append(wp.ready, ch)
wp.lock.Unlock()
return true
}
このようにWorkerPoolを用いることで、メモリの確保回数と量を減らし、net/httpよりも高速な実装になっているようです。
他にも高速化ための工夫が多く含まれているので、読み進めて何か気になるところがあればまたブログで報告させていただきますmm。
おわりに
で、このライブラリ、使えるのかという話なのですが
  • 結構コードベースが巨大だがvalyala氏がほぼ1人で作っている
  • バグはそれなりにあるかも(READMEでnet/httpほどstableにできる体制ではないとは認めている)
  • とか言ってたらコメントと内容が違うコード見つけた => pull-req送ればいいのか
ので、いきなり飛びつくのは結構ハードルが高そうです。
valyala氏の布教活動がどこまで継続するのか、Goのコミュニティの人たちがこの周りに集まってきて、メンテがしっかり行われる高速で安定しているライブラリになれるのかどうか。見ものだとは思います。
とは言え、
  • 使ったら面白いしエッジが聞いている
  • が、採用しているWAFなどがない => 作れば割りと注目される?
みたいなことは考えてみます。
net/httpを元にオレオレフレームワークを作っている人は結構いるようなので、

作って公開(後悔?)してみようかなと思います。それはまた別の機会に。
長々と読んでいただきありがとうございましたmm。
感想
コードリーディングをするのは久しぶりなので、読むのに非常に時間がかかりました。
GoはConcurrentな処理が得意とはいいますが、普通のWebアプリケーションを書いているとチャンネルを多用する機会はそれほど多くないので、チャンネル周りの扱いを思い出すのに苦労しました。
こういう非同期な処理を思い出すためにも、たまに何か書くようにして、
Goの設計思想やGoの良さと言うものをハラオチさせていきたいなと思いました。
余談
  • valyala氏はGitHubのlocationを見るとKiev在住のようです。
  • ウクライナでのGoの立ち位置が気になるところです。
  • martiniはMy Thoughts on Martiniの通りメンテされないことが決まっています
  • martiniの代わりにintroduceされたnegroniもGitHub見る限り死んでいるような…
一時期盛り上がったmartiniやnegroniがメンテナンスされていない状態や
AwesomeGoで紹介されているWAFの中にも継続的にメンテされているものが多くなかったりと、GoのWeb Application Framework周りの移り変わりは本当に激しいと感じました。
初学者がどのWAFを使えばいいか判断するのは結構大変なのではと感じています。
Like what you read? Give eureka_developers a round of applause.

From a quick cheer to a standing ovation, clap to show how much you enjoyed this story.