Go言語のLogger「zap」は何故高速に構造化されたログを出力する事が出来るのか
2016.06.15
こんにちは。開発部の平田です。
今回は、PHP製のWeb APIをGoに移植するプロジェクトでアプリケーションの情報やエラーを出力する為のLoggerを検討した際に、uber-go/zapというライブラリが公表しているパフォーマンスがその他ライブラリと比べて大分良かったので、どこでパフォーマンスの差を出しているのか、そのアプローチを簡単に紹介したいと思います。
Zap
初めに、簡単にzapの紹介をしておくと今年の2月にUberから公開されたまだ比較的新しいプロダクトです。その為開発ステータスはBetaの段階で出力もJSONしか対応していませんが、Github上で800以上のスターが付いており注目されているプロジェクトとなっています。
「Fast, structured, leveled logging in Go」とあるように、構造化されたログを出力するためのライブラリで、標準のlogのようなprintfスタイルの出力は提供していません。代わりにkeyとvalueに対応する構造体を渡します。この辺はlogrusなどでも同じ様な感じだったりしますね。
簡単な使い方を抜粋します。
可能な限りリフレクションと文字列のフォーマットを行わないという方針で設計されているので、fieldを指定する際には1つ1つ zap.String("string", "1")
のようなヘルパーメソッド経由で渡す必要があります。この辺の手間はパフォーマンスとのトレードオフという所でしょうか。
その他のデータ型としては以下が用意されています。
パフォーマンス
それでは、実際にどのようにしてパフォーマンスを出しているかを見ていきしょう。
zapが公表しているベンチマークでは、README.mdにあるように、その他のライブラリと比べてかなり高速に動作しているようです。ベンチマークのコードはbenchmarks以下にあります。
その他のライブラリとのパフォーマンス面での最大の違いとして、「reflection-free, zero-allocation JSON encoder」と公式に謳っています。reflection-freeに関しては上の例でもあるように、渡すデータを構造体でラップして型情報をLoggerに渡すことでinterface{}
でデータを受けることを回避しています。ではzero-allocationの部分に関してはどの様に実現しているのでしょう。
答えから先に言ってしまうと、JSONへのEncoderとBufferをFreeListで管理することで、ランタイムのアロケーションとGCを極力行わないような戦略となっています。FreeListの実装にはsync.Pool
が使われています。
最初の例を元に流れを確認してみましょう。
zap.NewJSON()
でLoggerを生成した際に、sync.Pool
から取得したEncoderをencにセットします。
ちょっと話が逸れてしまいますが、newJSONEncoder
で取得できるEncoderは基本的に再利用される前提なのでenc.bytesには以前にencodeしたデータが残っています。その為、再利用する際にはenc.bytesを初期化する必要があります。ここではもう一度[]byteをアロケーティングしたりゼロクリアなどは行わずにfiltered sliceを用いて初期化しています。この辺のテクニックはgo/wikiにもあるので、まだ見ていない人は一度目を通しておくと良いかもしれません。
引き続き、書き込みの部分を見ていきます。
jsonLogger.log
の中で、最初に確保したEncoderをClone
してもう一つのEncoderを確保しています。これはjl.encがWith
で添付された共通のフィールドを保持している為です。AddFields
に関しては単純に、enc.bytesに、JSONに対応する値をappendしたり文字のエスケープを行っている関数です。簡単な例だけ紹介しておくと、
このように、各データ型に対してJSON文字列をどんどん詰めていきます。
WriteMessage
で最終的な出力用のJSONを構築するためのEncoderを取得し、JSONを組み立ててio.Writer
に書き込みます。使用済みのEncoderはFree
でFreeListに返します。
結構処理を省いていますが、ログを出力する部分の基本的な流れはこんな所でしょうか。
まとめ
簡単に駆け足ですが、zapが以下の戦略でパフォーマンスを向上させている部分を見ました。
- リフレクションやフォーマット文字列のパースを行わない
- アロケートした領域を使い回すことによって、全体的なアロケーションとGCの発生頻度を減らす
これらはLoggerを作る場合だけではなく、Goで書く大抵のプログラムにも応用できる部分ですね。また、sync.Pool
を活用しているわかりやすいサンプルだと思うので、最適化の一つとして、覚えておいて損は無いと思います。