Go 言語で wc を実装してみた
Go 言語で wc を実装してみた
GitHub - takatoshiono/go-wc: Go implementation of wc command for practice
なぜか
A Tour of Go をやり終えた時「全然うまく書けない」というのが感想だった。もっと Go 言語のコードを読み書きする必要がある。
そして読むだけだとやる気が続かないから何か書きたい。何を作ろうか?
Go 言語なのでスタンドアローンで起動するバイナリ実行形式のファイルがよさそう。仕様が簡単で手頃なやつがいいな...と考えて wc
にしたのだった。他にも以下が候補にあった。
- ab
- smtp server
- beer コマンド(なんかうまそうなビールを表示する)
- wc コマンド
- find コマンド
- (コマンド系で攻めるなら GNU coreutils, findutils などを見るとよさそうか...)
- MySQL のバイナリログを受け取ってフィルタリングする君
wc とは
Wikipedia から引用。
wc(ダブリューシー、Word Count の略)はUNIX系オペレーティングシステムのコマンドの一種。 標準入力か指定されたファイルのリストを読み込み、各種統計情報を生成する。出力情報としては、バイト数、単語数、行数(改行文字の個数)などがある。複数のファイル名を指定した場合、各ファイルの情報と合計が表示される。 wc の実行例を以下に示す。 $ wc ideas.txt excerpt.txt 40 149 947 ideas.txt 2294 16638 97724 excerpt.txt 2334 16787 98671 total 最初のカラムは行数、2番目のカラムは単語数、最後のカラムは文字数である。
https://ja.wikipedia.org/wiki/Wc_(UNIX)
実装としては GNU wc と BSD wc があるようだ。
- GNU https://github.com/goj/coreutils/blob/rm-d/src/wc.c
- BSD https://opensource.apple.com/source/text_cmds/text_cmds-81/wc/wc.c
どういう手順でやったか
以下のような手順を考えた。
- まず機能を満たす
- 実装を見直す
- ベンチマークする
- チューニングする
- 独自機能を足す
なぜそう考えたか、そして実際にやったことを書いていく。
1. まず機能を満たす
まずは汚いコードでもいいから、機能を満たすコードを書くことにした。それには以下の理由がある。
- 最初からきれいに書こうとか、効率よく書こうと考えると手が止まってしまう
- 現時点の実力を知る
- 今の武器は A Tour of Go で学んだことだけ。これでどれだけ書けるかやってみたい
- もっと知識を仕入れた後で改善するという楽しみを取っておきたい
で、主にこういうことをやった。
コードの整形だけちょっと違うけど、Go 言語では「インデントはタブ」など推奨されるフォーマットがあるので、これは早めに従っておきたかった。具体的には vim-go を使うようにした。今のところ特に設定はいじらずに使っている。とても便利。
それでこんな感じのコードができた。
go-wc/go-wc.go at 89cbb1dbc2d901d132ee1ad59e2b964a57aeb0f2 · takatoshiono/go-wc · GitHub
行数、バイト数、単語数を数えるのに何回もファイルから一気読みしたりして富豪的。
2. 実装を見直す
それっぽい動きができてきたところで次のステップ。もうちょっとかっこいい実装にしたい。ちょうどいいことに みんなのGo言語【現場で使える実践テクニック】 が発売されたばかりだったので購入して以下の章を熟読した。
- 第1章 Goによるチーム開発のはじめ方とコードを書く上での心得
- 第3章 実用的なアプリケーションを作るために
- 第4章 コマンドラインツールを作る
めっちゃ勉強になる。
それで以下のようなことをした。
- 引数を io.Reader インターフェース型で受け付けることでファイルと標準入力を同じように扱う
- 構造体、変数の名前の見直し
- ディレクトリ構成の見直し
- lib とか cmd ディレクトリを作るというやつ
- まだコードの大幅変更がありそうなので保留にした。規模が大きくなるとファイル分割は有効だろうけど、今は練習だし1ファイルになっていた方が見通しが良いと考えたため
それでこういうコードになった。
go-wc/go-wc.go at cea7defc2cfe0d148f7df23fe6c6e104c68eedc2 · takatoshiono/go-wc · GitHub
3. ベンチマークする
そろそろ実行速度が気になってきた。Go 言語はそれなりに速いはずという期待があるので wc と比較したい。まずは計測から。 以下のような雑なシェルスクリプトを書いて time コマンドで計測することにした。
#!/bin/sh COMMAND=$1 FILE=$2 MAX=$3 for i in `seq 1 $MAX`; do $COMMAND $FILE >/dev/null done
計測内容
実行環境
- Mac OS X 10.11.6
- MacBook Pro (Retina, 13-inch, Early 2015)
- CPU 3.1 GHz Intel Core i7
- メモリ 16 GB 1867 MHz DDR3
コマンド
- wc: Mac に入ってたやつ
- go-wc: go install して
$GOPATH/bin
にできたやつ
計測対象のファイル
- 小さいファイル (3.4KB)
~/.vimrc
を使った
- 大きいファイル (416MB)
- 手元にあった mysqldump のファイルを使った。1行がとても長い。
結果
小さいファイル (3.4KB)
1000回実行する。
wc コマンド
$ time ./bench.sh wc vimrc 1000 ./bench.sh wc vimrc 1000 0.78s user 1.13s system 75% cpu 2.528 total $ time ./bench.sh wc vimrc 1000 ./bench.sh wc vimrc 1000 0.79s user 1.14s system 76% cpu 2.515 total $ time ./bench.sh wc vimrc 1000 ./bench.sh wc vimrc 1000 0.79s user 1.16s system 76% cpu 2.535 total
go-wc コマンド
$ time ./bench.sh go-wc vimrc 1000 ./bench.sh go-wc vimrc 1000 0.77s user 2.53s system 65% cpu 5.051 total $ time ./bench.sh go-wc vimrc 1000 ./bench.sh go-wc vimrc 1000 0.77s user 2.52s system 65% cpu 5.049 total $ time ./bench.sh go-wc vimrc 1000 ./bench.sh go-wc vimrc 1000 0.78s user 2.53s system 65% cpu 5.063 total
wc の倍くらい遅い。
大きいファイル (416MB)
遅いので10回だけ実行する。
wc コマンド
$ time ./bench.sh wc dump.sql 10 ./bench.sh wc dump.sql 10 17.29s user 0.67s system 99% cpu 18.040 total
go-wc コマンド
$ time ./bench.sh go-wc dump.sql 10 ./bench.sh go-wc dump.sql 10 91.87s user 4.01s system 104% cpu 1:31.42 total
5倍くらい遅いし、実行中にメモリを900Mくらい使っていた。
4. チューニングする
ここから速くしていく。goroutineっていうの使うと速いんじゃないの?という心の声を無視して地道にやっていく。goroutineは使わないで最後にとっておく。
メモリ消費を減らす
メモリ消費量を少なくするために1バイトずつ読んでみたらもっと遅くなった。特に大きいファイルだと遅い。遅すぎて途中で実行中断した。
$ time ./bench.sh go-wc vimrc 1000 ./bench.sh go-wc vimrc 1000 1.13s user 2.69s system 68% cpu 5.560 total $ time ./bench.sh go-wc vimrc 1000 ./bench.sh go-wc vimrc 1000 1.13s user 2.69s system 69% cpu 5.525 total $ time ./bench.sh go-wc dump.sql 10 ^C ./bench.sh go-wc dump.sql 10 515.87s user 5.46s system 107% cpu 8:02.82 total
コードはこれ。
go-wc/go-wc.go at cabe08af93dbf2363d869bf4c5b9cd2f8acb3bfa · takatoshiono/go-wc · GitHub
4KBずつ読む
4KBずつ読むようにしてみた。全然速くならない。
$ time ./bench.sh go-wc vimrc 1000 ./bench.sh go-wc vimrc 1000 0.77s user 2.55s system 65% cpu 5.058 total $ time ./bench.sh go-wc vimrc 1000 ./bench.sh go-wc vimrc 1000 0.81s user 2.92s system 63% cpu 5.837 total $ time ./bench.sh go-wc dump.sql 10 ./bench.sh go-wc dump.sql 10 93.72s user 2.30s system 98% cpu 1:37.34 total
コード
go-wc/go-wc.go at fd6de5797cb1bc0f2911377a588402389011fe4a · takatoshiono/go-wc · GitHub
プロファイリング
どこが遅いかわからないのでプロファイリングしてみることに。pprofというのを使うらしい。
$ go tool pprof ~/bin/go-wc cpu.prof Entering interactive mode (type "help" for commands) (pprof) top20 9.13s of 9.23s total (98.92%) Dropped 36 nodes (cum <= 0.05s) Showing top 20 nodes out of 23 (cum >= 0.05s) flat flat% sum% cum cum% 8.86s 95.99% 95.99% 8.86s 95.99% syscall.Syscall 0.09s 0.98% 96.97% 0.26s 2.82% bytes.FieldsFunc 0.07s 0.76% 97.72% 0.07s 0.76% unicode/utf8.DecodeRune 0.06s 0.65% 98.37% 0.06s 0.65% unicode.IsSpace 0.05s 0.54% 98.92% 0.05s 0.54% runtime.deductSweepCredit 0 0% 98.92% 8.86s 95.99% bufio.(*Reader).Peek 0 0% 98.92% 8.86s 95.99% bufio.(*Reader).fill 0 0% 98.92% 0.26s 2.82% bytes.Fields
ほぼ syscall.Syscall
で結局よくわからず。
困ったなー、とか言いながらコードをいじっているとどうやら単語数を数える処理が遅いようだということがわかった。単語数を全く数えないようにすると wc より速くなる。
単語数を数える処理の改善
これまでは何も考えずに bytes.Fields
を使ってホワイトスペースで区切って len
で長さをとって単語数としていた。
len(bytes.Fields(bytesRead))
bytes.Fields
の実装を見ると (bytes パッケージのコード)
- ホワイトスペースで区切って数を数える
- その数のスライスを作って返す
ということをやってた。いま必要なのは 1. だけで、スライスはいらない。だから実装を真似して自分で数えることにした。
$ time ./bench.sh go-wc vimrc 1000 ./bench.sh go-wc vimrc 1000 0.73s user 2.52s system 65% cpu 4.983 total $ time ./bench.sh go-wc dump.sql 10 ./bench.sh go-wc dump.sql 10 44.01s user 1.56s system 99% cpu 45.631 total
小さいファイルだと変わらないけど、大きいファイルだと倍くらい早い。よしよし。
でも wc
より全然遅い。
コード
go-wc/go-wc.go at f998ed1e0a4d8463ad4baaf63b23e05aee00119c · takatoshiono/go-wc · GitHub
goroutine にする
もう少し見ていくと単語数を数える処理の中でも特に utf8.DecodeRune
が遅いようだった。
これはバッファの先頭の UTF-8 文字を取得する関数。
wc の実装を見ると同じような処理に mbrtowc
というのを使っていた。避けては通れなそう。
んで、結局「遅い処理を goroutine にしちゃえばいいじゃん」という考えに至り、試してみたところさらに倍くらい速くなった。
➜ bench-go-wc time ./bench.sh go-wc vimrc 1000 ./bench.sh go-wc vimrc 1000 0.76s user 2.82s system 65% cpu 5.429 total ➜ bench-go-wc time ./bench.sh go-wc vimrc 1000 ./bench.sh go-wc vimrc 1000 0.75s user 2.78s system 66% cpu 5.316 total ➜ bench-go-wc time ./bench.sh go-wc dump.sql 10 ./bench.sh go-wc dump.sql 10 79.22s user 2.29s system 350% cpu 23.275 total ➜ bench-go-wc time ./bench.sh go-wc dump.sql 10 ./bench.sh go-wc dump.sql 10 81.62s user 2.57s system 340% cpu 24.744 total
大きいファイル(dump.sql)では wc の結果に近づいてきた。
ただ、
- 小さいファイルだとこの恩恵は受けれない
- CPU をたくさん使う
というのがあるので、ズルしている感じはある。
コード
go-wc/go-wc.go at ecfe5944eae2be58e2d7cecb39eb9bf6b830955c · takatoshiono/go-wc · GitHub
5. 独自機能を足す
例えばファイルだけでなく URL を指定できる、というのを考えた。そうすると HTTP 通信するコードを書く練習もできる。 でもまだやってない。
まとめ
9/11 〜 9/21 の間でやっていた。朝、昼、夜の細切れの時間(最短で15分とか)でコツコツやってたけど、やっぱりコードを書くのは楽しくて、がんばって時間を捻出していた。やる気がある時はこのように時間を無理やり作り出すことができるし、継続もするのだなあということがわかった。
みんなのGo言語がなかったら、もっと時間がかかっていたと思う。とても参考になった。例えば bufio.Reader.Peek
とか、goroutine の終了を sync.WaitGroup
で待ち受けるコードなんかは、みんなのGo言語を読んでいたからこそすっと書けたコードなのでとても良かった。
自分でコードを書くというのが、ひとまずできたので、次はお手本となるようなコードを読んだり、写経したりしてみようかな、という気持ちでいます。
あとできることなら wc より速くしたい。

- 作者: 松木雅幸,mattn,藤原俊一郎,中島大一,牧大輔,鈴木健太,稲葉貴洋
- 出版社/メーカー: 技術評論社
- 発売日: 2016/09/09
- メディア: 大型本
- この商品を含むブログ (1件) を見る