こんにちは,y1rです.
高速に静的ファイルを配信する方法について考えます.
高速な静的なファイルの配信といえば,nginxにsendfile on;
を設定する方法が知られています.
sendfile(2)とは,Linuxに実装されているシステムコールで,
ファイルディスクリプタ間でのコピーをカーネル内で行う機能です.
sendfile(2)を使わずファイルディスクリプタ間でコピーを行いたい場合は,片方からread(2)して,もう片方にwrite(2)する必要があり,
これはカーネルとユーザランドの間でデータ転送が必要なため非効率的である,と言われています.
例えば静的ファイルを配信する場合は,どちらもファイルディスクリプタとして記述されるファイルとソケットの間でデータを転送するので,
まさにsendfile(2)が利用できるシチュエーションとなっています.
実際,sendfile(2)はベンチマークで分かるほど高速化効果が見られるのでしょうか? Goから使う方法と合わせて調べてみます.
Goで書き込み中のファイルをHTTPレスポンスとして返す - hnakamur’s blogに, sendfile(2)がGoから使われる条件として以下が示されていました:
os.File
またはそれをラップしたio.LimitReader
をhttp.ResponseWriter
にio.Copy
でコピーしている,- Transfer-Encoding: chunked ではない (= Content-Length を指定している).
これが具体的にどういうコードのときなのかを調べてみます. Gist に今回使った検証コードを置いておきました.
検証の条件
今回は,以下の条件の組み合わせで実験をしてみます.
net.Listener
として UNIX domain socket もしくは TCP socket を利用した場合
今回の検証コードでいうと,tcpListener()
を使うか unixListener()
を使うかに対応します.
func tcpListener() net.Listener {
listener, err := net.Listen("tcp", "localhost:12345")
if err != nil {
log.Fatalf("error on tcpListener: %v\n", err)
}
return listener
}
func unixListener() net.Listener {
os.Remove("./sock")
socket, _ := net.Listen("unix", "./sock")
return socket
}
http.Handler
として http.ServeFile
(標準ライブラリ) もしくは 独自実装 を利用した場合
今回は以下に示す実装を用意しました.
func serveImpl(file string, withLength bool, withAbstraction bool) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
fileObj, err := os.Open(file)
if err != nil {
log.Fatalf("File %s does not exist", file)
}
defer fileObj.Close()
if withLength {
fileinfo, err := fileObj.Stat()
if err != nil {
log.Fatalf("Failed to get %s info", file)
}
writer.Header().Set("Content-Length", strconv.FormatInt(fileinfo.Size(), 10))
}
var w io.Writer
if withAbstraction {
w = abstractedWriter{underlying: writer}
} else {
w = writer
}
start := time.Now()
written, _ := io.Copy(w, fileObj)
elapsed := time.Now().Sub(start)
log.Printf("%f MB/s\n", float64(written)/1024/1024/elapsed.Seconds())
})
return mux
}
オプションとして2つの場合を考えます:
-
withLengthオプション
- True: Content-Lengthを設定する場合
- False: Content-Lengthを設定しない場合
-
withAbstractionオプション
- True: http.ResponseWriterに委譲する
io.Writer
インターフェイスを実装してio.Copy
する場合 - False: http.ResponseWriterにそのままファイルを
io.Copy
する場合
- True: http.ResponseWriterに委譲する
検証結果
1GBの0埋めデータをGoで実装したサーバからcurlでダウンロードしたときのスループットを,それぞれ10回ずつ測定しました. そのときの平均値を示します.
-
TCP socket
http.ServeFile
: 6.53 GB/s- Content-Lengthあり,io.Writerのwrapなし: 6.31 GB/s
- Content-Lengthあり,io.Writerのwrapあり: 3.70 GB/s
- Content-Lengthなし: 2.25 GB/s
-
UNIX domain socket
http.ServeFile
: 4.65 GB/s- Content-Lengthあり, io.Writerのwrapなし: 4.64 GB/s
- Content-Lengthあり, io.Writerのwrapあり: 4.47 GB/s
- Content-Lengthなし: 3.57 GB/s
考察
標準で実装されているhttp.ServeFile
は十分に高速なので,基本的にはこれを使うのが良いです.
なんらかのロジック(例えばファイルの前後に何かを付け足す必要があるなど)が必要な場合は,http.ServeFile
だけで実装できないので,独自に実装することになります.
このとき,独自の実装の方法によっては,以下に示すようにスループットが大きく異なるので注意が必要です.
独自で実装した中で最も高速だったのは,Content-Lengthあり,io.WriterのwrapなしをTCP socket上で使った実装です. また,io.Writerのwrapありなしがスループットに大きな影響を及ぼしていることが分かります. この差分のコードを以下に示します:
type abstractedWriter struct {
underlying io.Writer
}
func (w abstractedWriter) Write(p []byte) (n int, err error) {
return w.underlying.Write(p)
}
func serverImpl(...) http.Handler {
...
var w io.Writer
if withAbstraction {
w = abstractedWriter{underlying: writer}
} else {
w = writer
}
written, _ := io.Copy(w, fileObj)
}
差分は一見ないですが,withAbstractionの場合は,wはio.Writer
がもつWrite
interfaceしか持ちません.
sendfile(2)が利用できるかもしれないのは,http.ResponseWriter
がもつ readFrom
という特殊化を使った場合ですが,これは abstractedWriter
で委譲していないので,今回はsendfile(2)を利用できていません.
つまりこの速度差(wrapなし 6.31GB/s, wrapあり 3.70GB/s)はsendfile(2)の有無によって説明できることになります.
また,一般に高速だと考えられているUNIX domain socketの場合も,net
にはsendfile(2)を利用するような readFrom
特殊化が実装されておらず,ユーザランドでのコピーが行われているようでした.
さらに,Content-Lengthを設定しない場合はchunk転送が行われるため性能が劣化するので,気をつける必要があります.
まとめ
sendfile(2) は高速に静的ファイルを配信するときにも使える重要なシステムコールでした.
http.ServeFile
を利用せずに,独自に io.Copy
を使って静的ファイル配信を実装したときは,sendfile(2)が使用できておらず,効率が悪いコードになっていることがあります.
そのため,デバッガを使うなどして,正しくsendfile(2)が呼ばれているか確認しましょう.
また, Content-Length を正しく設定しているかも注意が必要です.