Help us understand the problem. What is going on with this article?

Golang `encoding/csv` のReaderは行全体の文字列への参照をもつ部分文字列を返すので参照リークに気をつけよう

TL;DR

  • encoding/csv のReader.Read/ReadAllは一行全体から部分文字列で切り出して []string を返すので部分文字列が行全体文字列への参照を持っている
  • なのでReadの返値の一部だけをメモリに持つつもりでも行全部への参照を持ったままになってしまうので特にでかいCSVを読むときには気をつけよう
  • メモリリークかと思ったらpprofで何でヒープを消費しているか調べてみるといいかも

英語版Wikipedia対応で発覚したCSV読み込みによるメモリリーク

PediaRoute というWikipediaの任意の2ページの間をリンクだけでたどっていけるかを検索するサイトを運営しています。
最近英語版Wikipediaも検索できるように改修したんですが、それによってメモリを馬鹿食いするようになって困っていました。

PediaRouteではWikipediaのページの情報のうちページタイトル、リンクデータ(読み込み位置とリンクの数)を起動時にCSVファイルから読み込んでいます。
CSVデータはこんな感じです。

5,false,アンパサンド,0,161,88970362,167
10,false,言語,161,292,88970529,2540
11,false,日本語,453,1252,88973069,25868
12,false,地理学,1705,278,88998937,922
14,false,EU_(曖昧さ回避),1983,27,88999859,2
...

左からページID、自動リダイレクトページかどうかの真偽値、タイトル、正リンク位置、正リンク数、逆リンク位置、逆リンク数です。

Wikpediaには2019年2月現在、日本語版で180万、英語版で1400万ページあります。1ページ1行対応のCSVファイルになっており、日本語版で105MB, 英語版で832MBあります。

やっぱり英語Wikpediaはデータでかいなあ、とか思ってそのときにはメモリ食いまくってることもほったらかしておいたんですが、そのあとしばらく経ってちまちまとメモリ削減してたところどうも計算よりメモリを消費していることに気づきました。

起動直後に強制GC runtime.GC() 呼んで runtime.ReadMemStats でHeapAlloc見てみたところ本来使うであろうメモリよりも大きい(読み込んでるファイルサイズより消費メモリのほうが大きい)ことに気づき、さすがにこれはなんかだめだ、ということで調査をはじめました。

pprofでのヒープ調査

Golangには pprof というくっそ便利なプロファイラが標準でついているので、まずはこれを使ってCSV読み込んで runtime.GC() した後に何にそんなにメモリ食ってるのか見ることにしました。

pprofのページにある通り、 net/http/pprof でHTTPサーバーを立てておき、 go tool pprof で見てみました。

$ go tool pprof 'http://localhost:6060/debug/pprof/heap?debug=1'
Fetching profile over HTTP from http://localhost:6060/debug/pprof/heap?debug=1
Saved profile in /Users/user/pprof/pprof.alloc_objects.alloc_space.inuse_objects.inuse_space.001.pb.gz
Type: inuse_space
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) text
Showing nodes accounting for 3439.74MB, 100% of 3440.89MB total
Dropped 7 nodes (cum <= 17.20MB)
      flat  flat%   sum%        cum   cum%
 1504.58MB 43.73% 43.73%  1504.58MB 43.73%  encoding/csv.(*Reader).readRecord
 1140.78MB 33.15% 76.88%  1708.81MB 49.66%  github.com/mtgto/pediaroute-go/internal/app/web.loadLowercaseTitleToIndices
  794.38MB 23.09%   100%  1730.93MB 50.30%  github.com/mtgto/pediaroute-go/internal/app/core.LoadPages
         0     0%   100%  1504.58MB 43.73%  encoding/csv.(*Reader).Read
         0     0%   100%  3439.74MB   100%  github.com/mtgto/pediaroute-go/internal/app/web.Load
         0     0%   100%  3440.89MB   100%  main.main
         0     0%   100%  3440.89MB   100%  runtime.goexit
         0     0%   100%  3440.89MB   100%  runtime.main

やっぱり本来使うであろうメモリよりも使用量が大きいこと、そして encoding/csv.(*Reader).readRecord がかなりのメモリを持ったままになっていることに気づきました。なんで・・・?

メモリを食い過ぎるコード

この時点で私が書いてたコードはこんな感じです。読みやすくするためエラー処理だけ省いて載せてます。完全版はGitHubへのリンクをどうぞ

core.go
type Page struct {
    Id                 int32
    Title              string
    IsRedirect         bool
    ForwardLinkIndex   int32
    ForwardLinkLength  uint32
    BackwardLinkIndex  int32
    BackwardLinkLength uint32
}

// in (CSVファイル) からPage構造体のスライスを読み込んで返す
func LoadPages(in string) []Page {
    file, _ := os.Open(in)
    defer file.Close()
    reader := csv.NewReader(file)
    records, err := reader.ReadAll()
    if err != nil {
        panic(err)
    }
    pages := make([]Page, 0, len(records))
    for _, record := range records {
        pageID, _ := strconv.Atoi(record[0])
        pageIsRedirect, _ := strconv.ParseBool(record[1])
        forwardLinkIndex, _ := strconv.Atoi(record[3])
        forwardLinkLength, _ := strconv.Atoi(record[4])
        backwardLinkIndex, _ := strconv.Atoi(record[5])
        backwardLinkLength, _ := strconv.Atoi(record[6])
        pages = append(pages, Page{
            Id:                 int32(pageID),
            Title:              record[2],
            IsRedirect:         pageIsRedirect,
            ForwardLinkIndex:   int32(forwardLinkIndex),
            ForwardLinkLength:  uint32(forwardLinkLength),
            BackwardLinkIndex:  int32(backwardLinkIndex),
            BackwardLinkLength: uint32(backwardLinkLength),
        })
    }
    return pages
}

1行に7列あるCSVのうち、3番目以外はstrconvで数値/真偽値に変換していたんですが、 record[2] だけ無変換でそのままPage構造体が持つようにしていました。

こんなコードでなんで encoding/csv.(*Reader).readRecord がヒープをもっていると言われるのかわからず、csvのReaderのコードを読んでみました。

すると readRecord 内でパース中の行全体の文字列をもつsrcの部分文字列から返値を作っていることがわかりました。

reader.go
// Create a single string and create slices out of it.
// This pins the memory of the fields together, but allocates once.
str := string(r.recordBuffer) // Convert to string once to batch allocations
dst = dst[:0]
if cap(dst) < len(r.fieldIndexes) {
    dst = make([]string, len(r.fieldIndexes))
}
dst = dst[:len(r.fieldIndexes)]
var preIdx int
for i, idx := range r.fieldIndexes {
    dst[i] = str[preIdx:idx]
    preIdx = idx
}

https://github.com/golang/go/blob/go1.11.5/src/encoding/csv/reader.go#L376-L388

原因はわかったので、encoding/csv.(*Reader).Read の返値をディープコピーしてから持つようにすることで行全体の文字列を持ち続けてしまうことはなくなりました。実際の修正 (コミット)

$ go tool pprof 'http://localhost:6060/debug/pprof/heap'
Fetching profile over HTTP from http://localhost:6060/debug/pprof/heap
Saved profile in Type: inuse_space
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) text
Showing nodes accounting for 2735.02MB, 100% of 2735.02MB total
      flat  flat%   sum%        cum   cum%
 1149.29MB 42.02% 42.02%  1566.30MB 57.27%  github.com/mtgto/pediaroute-go/internal/app/web.loadLowercaseTitleToIndices
  842.03MB 30.79% 72.81%   842.03MB 30.79%  github.com/mtgto/pediaroute-go/internal/app/core.CopyString (inline)
  743.71MB 27.19%   100%  1168.72MB 42.73%  github.com/mtgto/pediaroute-go/internal/app/core.LoadPages
         0     0%   100%  2735.02MB   100%  github.com/mtgto/pediaroute-go/internal/app/web.Load
         0     0%   100%  2735.02MB   100%  main.main
         0     0%   100%  2735.02MB   100%  runtime.main

readerのソースコメントには確かにひとつの文字列からスライスで切り出していることは書かれていますが、 encoding/csvのGodoc にはそのことが書かれておらず、私のようなプログラムを書くときに罠になってるっぽいなと思ったので本家にIssue投げてみたんですが、「(不要なメモリまで参照が残り続けることとのトレードオフで)速度優先でこういう実装になってる」との回答をもらいました。

バグではないとはいえフィールド数が多いCSVなんかだとメモリのムダが発生しやすくなるので、Readerに「CSVのフィールド値をスライスで切り出さない」というフラグを新たにもたせてもいいんじゃないのかなあと個人的には思ったりしました。

まとめ

  • encoding/csv のReader.Read/ReadAllは一行全体から部分文字列で切り出すため返値が行全体文字列への参照を持っている。Readの返値の一部だけをメモリに持つつもりでも行全部への参照を持ったままになってしまうので特にでかいCSVを読むときには気をつけよう
  • メモリリークかと思ったらpprofでヒープを消費しているか調べてみるのがよい

PediaRoute は2GBメモリのVPS上で動かしてるんですが、さらにメモリを節約しないとスワップなしでは英語版Wikipediaを検索できないのでもうちょっと改修が必要そうです。

今回の話は詳しい人からしたら「数百MBのCSVはGoで読んじゃだめ」ってことかもしれないですが、Go初心者としてはpprof使っての調査をしてみたりと勉強になりました。

スライスの一部分を参照で持ってると全体が解放されないことは知ってたんですがGoの文字列も内部的にはUTF-8形式でのバイトのスライスなんですかね。csv readerの実装見るとそんな感じに見えるんですが、実はそこはちゃんとわかってません。誰か教えてください。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away