Golangのバッファってよくできてるよな

2019-12-07

みんなのGoを読んでいて、バッファの取り扱いを理解できてないと感じたので 簡単にまとめてみました。

備考

この記事では chan のバッファリングについては取り扱いません。

目次

バッファはなんらかの入出力の一部を一時的に保存する領域を指します。

バッファはIOの回数を減らしてパフォーマンスを向上させますが、バッファサイズを制限することはメモリ圧迫を防ぐことにも一役買っています。

例えばファイルをコピーするプログラムで大きなファイルを取り扱うケースを考えてみましょう。

バッファなど考えずにすべてを読み込んでファイルに出力するとメモリを圧迫する可能性があります。

そこで一定のサイズの領域、(例えば4KB とする)を確保し4KBずつ読み出してバッファに格納&コピー先のファイルに書き出す、という操作を繰り返せば最大でも4KBしか消費されないのでメモリを圧迫する恐れがありません。

ではバッファは小さいほどよいかといえばそうではありません。 ちまちま読み込んでいたら大量のIOが発生しものすごい時間がかかるかもしれません。

バッファサイズは省メモリとパフォーマンスのトレードオフ関係にあります。

備考

通常のシステムではスペックに合わせて適切なバッファサイズを設定することが望ましいですが、当記事ではわかりやすさを考慮して小さなサイズのバッファを使うことがあります。

Buffers in Golang

[]byte

Golang におけるバッファは主にbyte型のスライス([]byte)で表現されます。 byte は 1バイトで表される 0 から255 の整数値を扱います。

バッファに対してIOから読み書きを行うんですが、ネットワークやファイルなどIOにもいくつか種類があります。 IOごとにバラバラの読み書き方法があるとめんどくさいのでGolangでは読み書きの方法(メソッド)がある程度統一されています。

備考

この統一されたメソッドを変数に対して強制させるのがインタフェースです。

インタフェースにはメソッドにどのような引数を渡してどのような値が返却されるべきかが定義されており、インタフェース型変数に格納される値にメソッドが定義されていなければビルドが通らないようになっています。

入出力に関するインタフェースは ioパッケージに以下のように定義されており、利用者はインタフェースを見るだけでどのように呼び出せばいいかがわかります。

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

標準、準標準パッケージのIOをはじめ、多くのIOはこれらのインタフェースを満たすように設計されています。 だからこそ私達はインタフェースを使って抽象的なコードを書くことができます。 自分たちで IO を書く場合もこれらのインタフェースを満たすように設計すべきです。

第一引数のファイルを第二引数のファイルにコピーする(cpコマンドのような)プログラムを書いてみました。 5バイトのバッファを使って泥臭く読み書きを繰り返しているだけです。 処理をわかりやすくするためにエラーハンドリングや後処理は書きませんでした。

copy.go
package main

import (
	"fmt"
	"os"
)

func main() {
	r, _ := os.Open(os.Args[1])
	w, _ := os.OpenFile(os.Args[2], os.O_WRONLY|os.O_CREATE, 0644)
	buf := make([]byte, 5)
	for {
		n, _ := r.Read(buf)
		fmt.Println(string(buf[:n]), n)
		if n == 0 {
			break
		}
		w.Write(buf[:n])
	}
}
$ cat read.txt
0123456789
abc

$ go run copy.go read.txt write.txt
01234 5
56789 5

abc
 5
 0

$ cat write.txt
0123456789
abc

重要なのはRead でバッファを受け取って、読み込んだバイト数を返しているところです。 読み込んだバイト数が0なら処理を終了し、それ以外なら Write で切り出したバッファを書き込んでいます。バッファには前に読み込んだバイト列が残っているので切り出さないと予期せぬ内容が書き込まれてしまいます。

備考

ちなみに、IOから際限なくすべての文字列を読み込みたい場合、 ioutil.ReadAll を使うと良いでしょう。

内部 では後述する bytes.Buffer が使われています

bytes.Buffer

bytes 標準パッケージには Buffer という構造体が定義されていて、これをバッファとして使うこともできます。 内部にバッファとして byte のスライスを持ちます。

NewBuffer 関数を使うと初期バッファは利用者が指定できます。

type Buffer struct {
      buf      []byte // contents are the bytes buf[off : len(buf)]
      off      int    // read at &buf[off], write at &buf[len(buf)]
      lastRead readOp // last read operation, so that Unread* can work correctly.
}

フィールドは外部からアクセスできませんが、バッファを操作するためのメソッドが用意されています。

今回は gore という プログラムを利用して対話的に結果を見ていきます。

gore> :import bytes

// バイト列からバッファを作る
gore> b := bytes.NewBuffer([]byte("test"))
&bytes.Buffer{buf:[]uint8{0x74, 0x65, 0x73, 0x74}, off:0, lastRead:0}

// 中身
gore> b.String()
"test"

// キャパシティは4
gore> b.Cap()
4

// testなので4文字
gore> b.Len()
4

// 別のバッファを []byte で作成
gore> b2 = make([]byte, 10)
[]byte{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}

// b の内容を今作成したバッファに移す(Read)
gore> b.Read(b2)
4
<nil>

// b2の先頭4バイトに書き込まれている
gore> string(b2)
"test\x00\x00\x00\x00\x00\x00"

// 読まれたので seekが進んで
gore> b
&bytes.Buffer{buf:[]uint8{0x74, 0x65, 0x73, 0x74}, off:4, lastRead:-1}

// もう読み出せないらしい(Seek的なものはReaderにしかないっぽい)
gore> b.String()
""

// 仕方ないのでWriteで再度バッファを戻す(Write)
gore> b.Write([]byte("test"))
4
<nil>
gore> b
&bytes.Buffer{buf:[]uint8{0x74, 0x65, 0x73, 0x74}, off:0, lastRead:0}
gore> b.String()
"test"

// Reset で空っぽにできる Trancate(0) と同じ
gore> b.Reset()
gore> b
&bytes.Buffer{buf:[]uint8{}, off:0, lastRead:0}

bytes.Buffer 自体がバッファだと考えてしまうと Read, Write関連のメソッドって向きがさっきと逆じゃねーかと思ってしまいそうですが、これらはIOとして呼ばれることが前提となっているメソッドなので「引数であるバッファ」が主語になります。

具体的には以下の Fprintf のように引数でIOを指定する関数では bytes.Buffer はIOのように振る舞います。出力された値をバッファに格納できます。

gore> b = bytes.NewBuffer([]byte(""))
&bytes.Buffer{buf:[]uint8{}, off:0, lastRead:0}
gore> b.String()
""
gore> fmt.Fprintln(b, "test")
5
<nil>
gore> b.String()
"test\n"

Read, Write を使うと IOのように振る舞えるのはわかりましたが、 先程のように別のIOの読み書きはどうやるんでしょうか。ここで使うのが ReadFromWriteTo です。 それぞれ、 io.Readerio.Writer のインタフェースが引数となっているので、適切なIOを渡してやるだけです。

ファイルを読み込んで表示するだけのプログラムを作りました。 例のごとくエラーハンドリングはしてません。

cat.go
package main

import (
	"bytes"
	"os"
)

func main() {
	f, _ := os.Open(os.Args[1])
	buf := bytes.Buffer{}
	buf.ReadFrom(f)
	buf.WriteTo(os.Stdout)
}
$ go run cat.go read.txt
0123456789
abc

この場合はバッファ(bytes.Buffer)が主語となり、 読み込むときは ReadFrom を、書き込むときは WriteTo を使います。

備考

  • ReadFrom は最低だと 512 バイトのバッファが使われます。
  • ここでいうキャパシティはスライスにとっての cap の意味であり、バッファへの書き込みを制限できるという意味のキャパシティではありません。
    • キャパシティを超えて書き込まれれば自動的にアロケーションされます。
  • 上述したように bytes.Buffer は書き込まれた内容を際限なく溜め込みます。
    • バッファに入っている内容が不要になったら ResetTrancate メソッドで削除しましょう。

bufio

bufio はIOをラップしてそこに対する入出力をバッファリングする標準パッケージです。 IOアクセス処理で透過的にバッファを利用します。

再びファイルを表示するためのプログラムを書いてみました。

cat2.go
package main

import (
	"bufio"
	"os"
)

func main() {
	f, _ := os.Open(os.Args[1])
	rb := bufio.NewReaderSize(f, 5)
	wb := bufio.NewWriterSize(os.Stdout, 5)

	for {
		n, _ := rb.WriteTo(wb)
		if n == 0 {
			break
		}
	}
	wb.Flush()
}
$ go run cat2.go read.txt
0123456789
abc

結果は同じですが、bytes.Buffer を使ったコードより行数が増えています。 何が嬉しいのでしょうか?

bytes.Buffer は内部のバッファに上限を持たないため、読み込んだものをすべてバッファに入れることが可能です。 実際、先程のコードは一気に読み込んで一気に出力していた(かつエラーハンドリングもしてない)のであれだけ短くできました。

bufioReaderWriter はともにバッファに上限を持つため、バッファを超える入出力を考慮して繰り返し読み書きするプログラムを書く必要があります。 正直めんどくさくはありますが、メモリが有限なことを考えればこちらのほうが現実的です。(バッファサイズは小さすぎますが)

Write buffering

通常、IOへの書き込みでは自動的にバッファされないため、連続した読み書きを行うとパフォーマンス劣化を招く恐れがあります。

bufio.Writer への書き込みは 「自身のバッファサイズの限界に達したとき」、あるいは「 Flush メソッドを実行したとき」に IOへの書き込みが発生するため、IOアクセスを抑えパフォーマンス向上につながります。

なにげに先程のプログラムでも bufio.Writer は使っていましたが、正直あれだけではありがたみがわからないので書き込み回数ごとに実行時間を計測するプログラムを書きました。

benchmark_test.go
package main

import (
	"bufio"
	"io/ioutil"
	"os"
	"testing"
)

// バッファしない
func BenchmarkWrite(b *testing.B) {
	f, _ := ioutil.TempFile(".", "tmp")
	defer os.Remove(f.Name())
	for i := 0; i < 1000; i++ {
		f.Write([]byte("0123456789"))
	}
}

// (バッファサイズに関係なく)10回に1回フラッシュする
func BenchmarkWriteWithBufferOnceInTen(b *testing.B) {
	f, _ := ioutil.TempFile(".", "tmp")
	defer os.Remove(f.Name())
	buf := bufio.NewWriter(f)
	for i := 0; i < 1000; i++ {
		buf.Write([]byte("0123456789"))
		if i%10 == 0 {
			buf.Flush()
		}
	}
	buf.Flush()
}

// バッファサイズ1K
func BenchmarkWriteWithBuffer1K(b *testing.B) {
	f, _ := ioutil.TempFile(".", "tmp")
	defer os.Remove(f.Name())
	buf := bufio.NewWriterSize(f, 1024)
	for i := 0; i < 1000; i++ {
		buf.Write([]byte("0123456789"))
	}
	buf.Flush()
}

// バッファサイズ4K(defaultSize:4096)
func BenchmarkWriteWithBuffer4K(b *testing.B) {
	f, _ := ioutil.TempFile(".", "tmp")
	defer os.Remove(f.Name())
	buf := bufio.NewWriter(f)
	for i := 0; i < 1000; i++ {
		buf.Write([]byte("0123456789"))
	}
	buf.Flush()
}
$ go test -bench Bench
goos: linux
goarch: amd64
BenchmarkWrite                          1000000000               0.00528 ns/op
BenchmarkWriteWithBufferOnceInTen       1000000000               0.00191 ns/op
BenchmarkWriteWithBuffer1K              1000000000               0.000145 ns/op
BenchmarkWriteWithBuffer4K              1000000000               0.000067 ns/op
PASS
ok      ./benchmark        0.056s

毎回書き込んでるのは一番遅く、書き込み回数が減るごとにパフォーマンスが向上しているのがわかります。

備考

BenchmarkWriteWithBuffer はデフォルトのバッファサイズが一番大きいので最速のはずですが、結構ブレが大きくて1Kのバッファが一番速くなることもよくありました。(よくわからん)

bufio.Scanner

bufio.Scanner は1行ごとにテキストを読み込む機能です。

再び gore を使って動きを見てみましょう。

gore> :import strings
// 1文字ごとに改行を入れた4行のテキスト
gore> s := strings.NewReader("1\n2\n3\n4")
&strings.Reader{s:"1\n2\n3\n4", i:0, prevRune:-1}

gore> :import bufio
gore> scanner := bufio.NewScanner(s)
&bufio.Scanner{r:(*strings.Reader)(0xc00000c060), split:(bufio.SplitFunc)(0x48eda0), maxTokenSize:65536, token:[]uint8(nil), buf:[]uint8(nil), start:0, end:0, err:error(nil), empties:0, scanCalled:false, done:false}

// まだScanしてない状態でテキストを呼ぶとエラー
gore> s.Text()
s.Text undefined (type *strings.Reader has no field or method Text)
gore> scanner.Text()
""
gore> scanner.Scan()
true
gore> scanner.Text()
"1"
gore> scanner.Scan()
true
gore> scanner.Text()
"2"
gore> scanner.Scan()
true
gore> scanner.Text()
"3"
gore> scanner.Scan()
true
gore> scanner.Text()
"4"
gore> scanner.Scan()
false
gore> scanner.Text()
""
gore> scanner.Err()
<nil>

Scan メソッドを呼ぶと改行コードが見つかるまで文字列を読み込み Text メソッドで読み込んだ(改行コードを除いた)テキストを返却します。

Scan は読み込めたら true 、読み込めなかったら false を返すため for 文の条件句で Scan を呼び出し、 forの中で Text メソッドを呼ぶのが一般的な使い方だと思います。

以下は標準入力から受け取った1行の文字列をそのまま返却するエコーサーバです。

echo.go
package main

import (
	"bufio"
	"fmt"
	"os"
)

func main() {
	scanner := bufio.NewScanner(os.Stdin)
	for scanner.Scan() {
		if scanner.Text() == "こだまでしょうか?" {
			fmt.Println("いいえ、だれでも")
			break
		}
		fmt.Println(scanner.Text()) // Println will add back the final '\n'
	}
	if err := scanner.Err(); err != nil {
		fmt.Fprintln(os.Stderr, "reading standard input:", err)
	}
}
$ go run echo.go
遊ぼう
遊ぼう
ばか
ばか
もう遊ばない
もう遊ばない
ごめんね
ごめんね
こだまでしょうか?
いいえ、だれでも

(ネタが古い)

一行あたりの最大バイト数は 64KB という制限があります。 Scan メソッドがエラーを返さないので気づきにくいですが、 scanner.Err() をちゃんと確認しましょう。

gore> s := strings.NewReader(strings.Repeat("a", 65536))
gore> scanner := bufio.NewScanner(s)
&bufio.Scanner{r:(*strings.Reader)(0xc00000c060), split:(bufio.SplitFunc)(0x48eda0), maxTokenSize:65536, token:[]uint8(nil), buf:[]uint8(nil), start:0, end:0, err:error(nil), empties:0, scanCalled:false, done:false}
gore> scanner.Scan()
false
gore> scanner.Err()
&errors.errorString{s:"bufio.Scanner: token too long"}

先程のコードを見ればわかると思いますが、長すぎて読み込めない場合は Scan() メソッドが false を返すため自動的にループを抜けます。

バイナリファイルや一行あたりの文字数が保証されないファイルでは bufio.Reader を使ってください。

考察

bytes.Buffer, bufio いずれを使っても同じ結果を得るプログラムを書けましたが それぞれの性質は全く異なり、それに伴い使い所も違ったものになるでしょう。

  • bytes.Buffer は読み込まれた内容を自由に溜め込み、 書き出す用途に適している
  • bufio は特定の IO に密接に結びつき、 IOとバッファのデータ受け渡しをシンプルに書くのに適している

よくできてると言いましたが、 IOがインタフェースを満たすように設計された結果、バッファを扱う各関数は抽象化したコードになるのだと思います。

改訂2版 みんなのGo言語
Goでの開発から周辺ツールの紹介まで幅広く書かれています。一つ一つのセクションは短く区切られているので読みやすいですが、内容的に難しい箇所もあるので必要な箇所を繰り返し読むことを推奨します。

個人的にはreflectの章が難しかったので、ライブラリとか作る機会がにあったら並行しながら読み返したいです。
参考