隙あらば寝る

うぇぶのかいしゃではたらくえんじにあがかいています

golang race detector

race detector?

golang には race detector というデータ操作の競合を検出してくれる機能がある。

https://golang.org/doc/articles/race_detector.html

マルチスレッドなプログラムを書いたことがある人は経験があると思うが、2つ以上のスレッドが同じデータを操作する事によって壊れたりするアレ。

go では goroutine を気軽に作成するので、データ競合はよく起きる。

この機能を使うには go run -race XXX.go のように race オプションを付けるだけで、競合が見つかると報告してくれる。

問題

このプログラムは ./race.go(自分自身) を読み込んで /dev/null に書き込む。

読み込み -> channel -> 書き込み という流れ。

そして、このプログラムには data race があった。

package main

import (
    "os"
    "sync"
)

func read(f *os.File, c chan []byte) {
    readbuf := make([]byte, 128)
    for {
        n, err := f.Read(readbuf)
        if err != nil {
            close(c)
            return
        }
        c <- readbuf[:n]
    }
}

func write(c chan []byte, f *os.File) {
    for data := range c {
        f.Write(data)
    }
    f.Close()
}

func main() {
    c := make(chan []byte)
    var wg sync.WaitGroup
    wg.Add(2)
    go func() {
        f, _ := os.Open("./race.go")
        read(f, c)
        wg.Done()
    }()
    go func() {
        f, _ := os.Create("/dev/null")
        write(c, f)
        wg.Done()
    }()
    wg.Wait()
}

race detector を使ってみる

おわかり頂けただろうか...

早速 race detector で確認してみる。

初めて見る人は読み方がわからないと思うので日本語で補足を入れている。

$ go run -race race.go
==================
WARNING: DATA RACE
Write by goroutine 6: # 6 は goroutine の番号、6番目の goroutine が書き込みをした事がわかる
  internal/race.WriteRange()
      /Users/yoru9zine/.anyenv/envs/goenv/versions/1.6/src/internal/race/race.go:49 +0x38
  syscall.Read()
      /Users/yoru9zine/.anyenv/envs/goenv/versions/1.6/src/syscall/syscall_unix.go:164 +0x9d
  os.(*File).read()
      /Users/yoru9zine/.anyenv/envs/goenv/versions/1.6/src/os/file_unix.go:228 +0xb6
  os.(*File).Read()
      /Users/yoru9zine/.anyenv/envs/goenv/versions/1.6/src/os/file.go:95 +0xbc
  main.read()
      /Users/yoru9zine/tmp/go-race-bytes/race.go:11 +0x93
  main.main.func1()
      /Users/yoru9zine/tmp/go-race-bytes/race.go:33 +0x51

Previous read by goroutine 7: # 7番目の goroutine が直前に読み込みを行っていたらしい
  internal/race.ReadRange()
      /Users/yoru9zine/.anyenv/envs/goenv/versions/1.6/src/internal/race/race.go:45 +0x38
  syscall.Write()
      /Users/yoru9zine/.anyenv/envs/goenv/versions/1.6/src/syscall/syscall_unix.go:182 +0xad
  os.(*File).write()
      /Users/yoru9zine/.anyenv/envs/goenv/versions/1.6/src/os/file_unix.go:249 +0x119
  os.(*File).Write()
      /Users/yoru9zine/.anyenv/envs/goenv/versions/1.6/src/os/file.go:136 +0xbc
  main.write()
      /Users/yoru9zine/tmp/go-race-bytes/race.go:22 +0xad
  main.main.func2()
      /Users/yoru9zine/tmp/go-race-bytes/race.go:38 +0x51

Goroutine 6 (running) created at: # 6番目の goroutine 生成箇所
  main.main()
      /Users/yoru9zine/tmp/go-race-bytes/race.go:35 +0xb6

Goroutine 7 (running) created at: # 7番目の goroutine 生成箇所
  main.main()
      /Users/yoru9zine/tmp/go-race-bytes/race.go:40 +0xe2
==================
Found 1 data race(s)
exit status 66

スタックはユーザコードの行を見ると良い。今回は race.go に問題があるはず。 整理すると以下の情報が得られる。

  • goroutine6 は read の goroutine(生成行からわかる)
  • goroutine7 は write の goroutine(同上)
  • 競合箇所(スタック最深部のユーザコード)
    • f.Read(readbuf) (11行目)
    • f.Write(data) (22行目)

race detector の仕事は競合の事実を教える所までなので、なぜ競合しているのかは当然ながら自分で考える必要がある。。。

しばらく悩み込んでしまった。

答え

readbuf == data

  1. readbuf は read 内で確保して Read に渡る
  2. chan byte に slice して渡す
  3. chan byte から取り出して Write に渡る

[]byte はアドレス渡しなので実体は同じ。つまり channel 経由で同じ領域を共有していた。

[:n] で一部の取り出しをしているが、これは新規割当ではないのでやはり同じ領域を見たまま。

これにより、取り出した人が中身を書き込む間に次の Read 内で内容が変更されていたという話。

修正

channel に渡す前にメモリコピーして渡すようにした。

copy が同じ中身で新規に領域を割り当ててくれる。

--- race.go
+++ race_fixed.go
@@ -6,6 +6,7 @@
 )

 func read(f *os.File, c chan []byte) {
+   var sendbuf []byte
    readbuf := make([]byte, 128)
    for {
        n, err := f.Read(readbuf)
@@ -13,7 +14,8 @@
            close(c)
            return
        }
-      c <- readbuf[:n]
+       copy(sendbuf, readbuf[:n])
+       c <- sendbuf
    }
 }

雑感

はやりマルチスレッドは難しい。この手のバグを完全に発生させずにコードを書くようになれるものなのだろうか。

おそらくそれは無理(精度は高められても 100% は無理でしょう)なので、race detector があるのだと思う。

積極的な -race の利用、特に test では付けていくようにしたい。