1. ホーム
  2. スクリプト・コラム
  3. ゴラン

データ競争のためのGo並行プログラミング

2022-02-15 17:35:01

1. 前書き

goでは同時並行プログラミングは非常に簡単で、go func()を使ってゴルーチンを起動して何かをさせるだけです。最も一般的なバグは、同じマップに書き込むなどのスレッドセーフの問題に関連するものです。

2. データ競合

スレッドセーフを検出する方法はありますか?

その答えは、1.1という早い段階で導入されたデータレースタグです。テスト実行時やコンパイル時に-raceフラグを追加するだけで、データレースの検出が可能になるのです

使い方は以下の通りです。

go test -race main.go
go build -race


実運用でビルドする場合、データ競合検出をオンにすることはお勧めしません。もちろん、デバッグが必要な場合を除き、多少のパフォーマンス低下(通常、メモリ5~10倍、実行時間2~20倍)が発生する可能性があるからです。
ユニットテスト実行時には、常にデータ競合検出をオンにすることをお勧めします。

2.1 例1

以下のコードを実行し、毎回同じ結果になるかどうかを確認します。

2.1.1 テスト

コード

package main
 
import (
 "fmt"
 "sync"
)
 
var wg sync.WaitGroup
var counter int
 
func main() {
 // Run it a few more times to see the result
 for i := 0; i < 100000; i++ {
  run()
 }
 fmt.Printf("Final Counter: %d\n", counter)
}
 
 
func run() {
    // open two threads that operate
 for i := 1; i <= 2; i++ {
  wg.Add(1)
  go routine(i)
 }
 wg.Wait()
}
 
func routine(id int) {
 for i := 0; i < 2; i++ {
  value := counter
  value++
  counter = value
 }
 wg.Done()
}


3回実行すると、次のような結果が得られます。

最終的なカウンター 399950
ファイナルカウンター 399989
ファイナルカウンター 400000

解析する。各実行ではgo routine(i)を用いて2つのgoroutineを起動するが、その実行順序を制御しておらず、逐次一貫性メモリモデルを満たしていない。

もちろん、この2つ以外のケースもあるはずです。

2.1.2 データ競合の検出

上記の問題は、本番稼動後にバグがあった場合、何が問題なのかが正確に分からないため、データレースツールと連携してテスト段階で事前に問題を発見する必要があり、非常に困難な作業となることがあります。

使用方法

go run -race . /main.go


出力します。結果を実行すると、出力レコードが長すぎてデバッグ時に直感的でないことがわかります、結果は次のようになります。

main.main()
      D:/gopath/src/Go_base/daily_test/data_race/demo.go:14 +0x44
==================
ファイナルカウンター 399987
1件のデータレースが見つかりました。
終了ステータス 66

2.1.3 データレースコンフィギュレーション

公式ドキュメントでは、GORACE環境変数を以下の形式で設定することで、データレースの挙動を制御することができるとしています。

GORACE="option1=val1 option2=val2"


オプションの構成は以下の通りです。

設定

GORACE="halt_on_error=1 strip_path_prefix=/mnt/d/gopath/src/Go_base/daily_test/data_race/01_data_race" go run -race . /demo.go

出力してください。

==================
警告:データ競合
ゴルーチン 8 で 0x00000064d9c0 を読み込んでいます。
  main.routine()
      /mnt/d/gopath/src/Go_base/daily_test/data_race/demo.go:31 +0x47
ゴルーチン 7 による、0x00000064d9c0 での前回の書き込み。
  main.routine()
      /mnt/d/gopath/src/Go_base/daily_test/data_race/demo.go:33 +0x64
で作成されたゴルーチン8(実行中)。
  main.run()
      /mnt/d/gopath/src/Go_base/daily_test/data_race/demo.go:24 +0x75
  main.main()

      /mnt/d/gopath/src/Go_base/daily_test/data_race/demo.go:14 +0x3c
で作成したゴルーチン7(終了)。
  main.run()
      /mnt/d/gopath/src/Go_base/daily_test/data_race/demo.go:24 +0x75
  main.main()
      /mnt/d/gopath/src/Go_base/daily_test/data_race/demo.go:14 +0x3c
==================
終了ステータス 66

説明:この結果から、31行目でデータを読んでいるゴルーチンがあり、次に33行目で書き込んでいるゴルーチンがあるため、データの競合が発生していることがわかります。
2つのゴルーチンがいつ作成され、現在実行されているかは次のとおりです。

2.2 ループでゴルーチンを使って一時変数を参照する

コードはこのようになります。

func main() {
 var wg sync.WaitGroup
 wg.Add(5)
 for i := 0; i < 5; i++ {
  go func() {
   fmt.Println(i) 
   wg.Done()
  }()
 }
    wg.Wait()
}


出力してください。よくある答えは、forループのi++が速く実行されるため、最後に出力される結果が5となり、5を5つ出力することです
この答えは、本当に実行されれば同じ結果になる確率が高いので、間違っているはずがないのですが、完全ではありません。なぜなら、ここでは本質的に、新しく起動したゴルーチンでiの値を読み、メインで書き込むというデータレースが発生しており、ゴルーチン内のprintが外のi++より必ずしも遅くないと仮定できないので予測できないはずで、このような習慣的な仮定は並行プログラミングでは間違う可能性が高いのです。

正しい例:iを引数として渡すだけで、各ゴルーチンがデータのコピーを取得するようにする。

func main() {
 var wg sync.WaitGroup
 wg.Add(5)
 for i := 0; i < 5; i++ {
  go func(i int) {
   fmt.Println(i)
   wg.Done()
  }(i)
 }
 wg.Wait()
}


2.3 変数共有の原因

コード

package main
 
import "os"
 
func main() {
 ParallelWrite([]byte("xxx"))
}
 
// ParallelWrite writes data to file1 and file2, returns the errors.
func ParallelWrite(data []byte) chan error {
 res := make(chan error, 2)
 
 // Create/write the first file
 f1, err := os.Create("/tmp/file1")
 
 if err ! = nil {
  res <- err
 } else {
  go func() {
   // The following function is executed with err, but the err variable is a shared variable
   _, err = f1.Write(data)
   res <- err
   f1.Close()
  }()
 }
 
  // Create a second file to write to n
 f2, err := os.Create("/tmp/file2")
 if err ! = nil {
  res <- err
 } else {
  go func() {
   _, err = f2.Write(data)
   res <- err
   f2.Close()
  }()
 }
 return res
}


解析してみましょう。go run -race main.goを使用すると、エラーが21行目と28行目で報告されていることがわかります。これはデータ競合が発生しており、主に変数errが共有されていることが原因です。

root@failymao:/mnt/d/gopath/src/Go_base/daily_test/data_race# go run -race demo2.go
==================
WARNING: DATA RACE
Write at 0x00c0001121a0 by main goroutine:
  ParallelWrite()
      /mnt/d/gopath/src/Go_base/daily_test/data_race/demo2.go:28 +0x1dd
  main.main()
      /mnt/d/gopath/src/Go_base/daily_test/data_race/demo2.go:6 +0x84
 
Previous write at 0x00c0001121a0 by goroutine 7:
  main.ParallelWrite.func1()
      /mnt/d/gopath/src/Go_base/daily_test/data_race/demo2.go:21 +0x94
 
Goroutine 7 (finished) created at:
  main.ParallelWrite()
      /mnt/d/gopath/src/Go_base/daily_test/data_race/demo2.go:19 +0x336
  main.main()
      /mnt/d/gopath/src/Go_base/daily_test/data_race/demo2.go:6 +0x84
==================
Found 1 data race(s)
exit status 66


修正: 両方のゴルーチンで新しい一時変数を使用するようにしました。

_, err := f1.Write(data)
...
_, err := f2.Write(data)
...

2.4 プロテクトされていないグローバル変数

グローバル変数は、複数の関数のスコープ外で定義され、複数の関数やメソッドから呼び出すことができ、一般的にマップデータ型として使用されます

// Define a global variable map data type
var service = map[string]string{}
 
// RegisterService RegisterService
// Used to write or update key-value
func RegisterService(name, addr string) {
 service[name] = addr
}
 
// LookupService LookupService
// Used to look up a key-value
func LookupService(name string) string {
 return service[name]
}


よりテストしやすいコードを書くためには、グローバル変数の使用を減らすか避けるべきです。グローバル変数としてmapを使うより一般的なケースのひとつは、設定情報です。グローバル変数に対する一般的なアプローチは、ロックを追加することですが、sync.Maを使うこともできます。

var (
service map[string]string
serviceMu sync.Mutex
)
 
func RegisterService(name, addr string) {
 serviceMu.Lock()
 defer serviceMu.Unlock()
 service[name] = addr
}
 
func LookupService(name string) string {
 serviceMu.Lock()
 defer serviceMu.Unlock()
 return service[name]
}


2.5 プロテクトされていないメンバ変数

一般に、メンバ変数とは、データ型が構造体であるフィールドを指します。次のようなコードです。

type Watchdog struct{ 
    last int64
last int64 }
 
func (w *Watchdog) KeepAlive() {
    // First assignment operation
 w.last = time.Now().UnixNano() 
}
 
func (w *Watchdog) Start() {
 go func() {
  for {
   time.Sleep(time.Second)
   // It is likely that the w.last update is in progress when making the determination here
   if w.last < time.Now().Add(-10*time.Second).UnixNano() {
    fmt.Println("No keepalives for 10 seconds. Dying.")
    os.Exit(1)
   }
  }
 }()
}


アトミック操作atomiicの使用

type Watchdog struct{ 
    last int64 
    
last int64 }
 
func (w *Watchdog) KeepAlive() {
    // Modify or update
 atomic.StoreInt64(&w.last, time.Now().UnixNano())
}
 
func (w *Watchdog) Start() {
 go func() {
  for {
   time.Sleep(time.Second)
   // Read
   if atomic.LoadInt64(&w.last) < time.Now().Add(-10*time.Second).UnixNano() {
    fmt.Println("No keepalives for 10 seconds. Dying.")
    os.Exit(1)
   }
  }
 }()
}


2.6 インターフェースでのデータ競合

非常に興味深い例 アイスクリーム・メーカーとデータ・レース

package main
 
import "fmt"
 
type IceCreamMaker interface {
 // Great a customer.
 Hello()
Hello() }
 
type Ben struct {
 name string
name string }
 
func (b *Ben) Hello() {
 fmt.Printf("Ben says, \"Hello my name is %s\"\n", b.name)
}
 
type Jerry struct {
 name string
}
 
func (j *Jerry) Hello() {
 fmt.Printf("Jerry says, \"Hello my name is %s\"\n", j.name)
}
 
func main() {
 var ben = &Ben{name: "Ben"}
 var jerry = &Jerry{"Jerry"}
 var maker IceCreamMaker = ben
 
 var loop0, loop1 func()
 
 loop0 = func() {
  maker = ben
  go loop1()
 }
 
 loop1 = func() {
  maker = jerry
  go loop0()
 }
 
 go loop0()
 
 for {
  maker.Hello()
 }
}

この例で興味深いのは、最終的な出力がこのような例になることです。

Benは、「こんにちは、私の名前はJerryです。
Ben says, "Hello my name is Jerry"

これは、maker = jerry の代入がアトミックではないからです。前回の記事で述べたように、アトミックなのは単一の機械語の代入だけで、これは一行のように見えますが、実はインターフェースは go の構造体で、型とデータの両方を含んでいるので、そのコピーもアトミックではなく、問題を引き起こす可能性があるのです

type interface struct {
   Type uintptr // points to the type of the interface implementation
   Data uintptr // holds the data for the interface's receiver
}

このケースの面白いところは、2つの構造体のメモリレイアウトが全く同じなので、エラーがあってもパニックにならないのですが、そこに文字列フィールドを追加して読むとパニックになるところですが、そこがこのケースの怖いところでもありますね。

3. 概要

go build -race main.go と go test -race を使って、. / プログラムコード内のデータ競合をテストすることができます。

  • データレースツールを使って、並行処理のエラーを早期に発見することができます。
  • 未定義の動作について決めつけないこと。一行のコードを書くこともあるが、goコンパイラは後でいろいろなことをする可能性があり、一行が書かれたときがアトミックであるとは限らない
  • たとえアトミックであっても、データレースが安全であるという保証はありません。なぜなら、可視性の問題が残っており、前回の記事では、最近のCPUは基本的にいくつかのキャッシュ操作を持っているという話をしました。
  • データ競合のすべての発生を処理する必要があります。

4 参考資料

https://lailin.xyz/post/go-training-week3-data-race.html#典型案例
https://dave.cheney.net/2014/06/27/ice-cream-makers-and-data-races
http://blog.golang.org/race-detector
https://golang.org/doc/articles/race_detector.html
https://dave.cheney.net/2018/01/06/if-aligned-memory-writes-are-atomic-why-do-we-need-the-sync-atomic-package

これは、データ競争を達成するために、この記事Goの並行プログラミングの終わりです、より関連するGoのデータ競争の内容は、スクリプトハウスの前の記事を検索してくださいまたは次の関連記事を閲覧し続けることは、スクリプトハウスをサポートすることを願っています!.