いろいろ備忘録日記

主に .NET とか Go とか Flutter とか Python絡みのメモを公開しています。

Goメモ-224 (スライスはスレッドセーフではない)

概要

Goは、非同期処理が比較的簡単に書けるので、ちょちょいと書いたりしているとやってしまいがちなミスかもしれません。

Goも他の言語の場合と同じく、同じ値に対して複数の処理がアクセスしていて、そのどれか一つでも書き込みの場合は同期化が必要です。

んで、この話は当然ながらスライスにも適用されます。正しくは、スライスの各要素ではなくて、スライスヘッダの方です。

Goroutineの中で、 append() を使ってスライスを処理している場合、正しく処理していないとデータ競合が発生します。

サンプル

Taskfile.yml

version: '3'

tasks:
  fmt:
    cmds:
      - go fmt ./...
  vet:
    cmds:
      - go vet ./...
  run:
    cmds:
      - cmd: for i in {1..10} ; do go run race/main.go; done
        silent: true
  run-notrace:
    cmds:
      - cmd: for i in {1..10} ; do go run -race notrace/main.go; done
        silent: true
  run-notrace2:
    cmds:
      - cmd: for i in {1..10} ; do go run -race notrace2/main.go; done
        silent: true
  run-with-raceoption:
    cmds:
      - cmd: go run -race race/main.go
        ignore_error: true

データ競合が発生する版

// スライス操作 (スライスヘッダの書き換え)はスレッドセーフでは無いというのを示すサンプルです。
// 本サンプルはデータ競合が発生しています。
//
// REFERENCES:
//   - https://stackoverflow.com/questions/44152988/append-not-thread-safe
//   - https://stackoverflow.com/questions/49879322/can-i-concurrently-write-different-slice-elements
package main

import (
    "flag"
    "fmt"
    "strconv"
    "sync"
)

type data struct {
    value string
}

func (me data) String() string {
    return me.value
}

func main() {
    var (
        verbose = flag.Bool("verbose", false, "verbose output")
    )
    flag.Parse()

    var (
        src = make([]data, 100)
        dst = make([]data, 0)
    )

    for i := 0; i < len(src); i++ {
        src[i].value = strconv.Itoa(i)
    }

    wg := sync.WaitGroup{}
    for _, v := range src {
        wg.Add(1)
        go func(v data) {
            defer wg.Done()

            var tmp data
            tmp.value = v.value

            dst = append(dst, tmp)
        }(v)
    }

    wg.Wait()

    fmt.Printf("src-len=%d\tdst-len=%d\n", len(src), len(dst))

    if *verbose {
        fmt.Println("=========== SRC ===========")
        for _, v := range src {
            fmt.Println(v)
        }
        fmt.Println("=========== DST ===========")
        for _, v := range dst {
            fmt.Println(v)
        }
    }
}

スライスに対して append() しているところで、同期させていないので、上はデータ競合が発生します。

実行すると以下のようになります。

$ task run
src-len=100     dst-len=92
src-len=100     dst-len=82
src-len=100     dst-len=100
src-len=100     dst-len=100
src-len=100     dst-len=99
src-len=100     dst-len=70
src-len=100     dst-len=56
src-len=100     dst-len=69
src-len=100     dst-len=66
src-len=100     dst-len=87

結果が合いません。つまりデータ競合が発生しているということになります。

-race オプションをつけるとちゃんと報告されます。

$ task run-with-raceoption
task: [run-with-raceoption] go run -race race/main.go
==================
WARNING: DATA RACE
Read at 0x00c0001ae018 by goroutine 8:
  main.main.func1()
      /workspace/try-golang/examples/singleapp/slice_is_not_threadsafe/race/main.go:48 +0xb9
  main.main.func2()
      /workspace/try-golang/examples/singleapp/slice_is_not_threadsafe/race/main.go:49 +0x58

Previous write at 0x00c0001ae018 by goroutine 7:
  main.main.func1()
      /workspace/try-golang/examples/singleapp/slice_is_not_threadsafe/race/main.go:48 +0x16a
  main.main.func2()
      /workspace/try-golang/examples/singleapp/slice_is_not_threadsafe/race/main.go:49 +0x58

Goroutine 8 (running) created at:
  main.main()
      /workspace/try-golang/examples/singleapp/slice_is_not_threadsafe/race/main.go:42 +0x3f3

Goroutine 7 (finished) created at:
  main.main()
      /workspace/try-golang/examples/singleapp/slice_is_not_threadsafe/race/main.go:42 +0x3f3
==================
==================
WARNING: DATA RACE
Read at 0x00c000012040 by goroutine 9:
  runtime.growslice()
      /home/gitpod/go/src/runtime/slice.go:166 +0x0
  main.main.func1()
      /workspace/try-golang/examples/singleapp/slice_is_not_threadsafe/race/main.go:48 +0xf1
  main.main.func2()
      /workspace/try-golang/examples/singleapp/slice_is_not_threadsafe/race/main.go:49 +0x58

Previous write at 0x00c000012040 by goroutine 7:
  main.main.func1()
      /workspace/try-golang/examples/singleapp/slice_is_not_threadsafe/race/main.go:48 +0x11e
  main.main.func2()
      /workspace/try-golang/examples/singleapp/slice_is_not_threadsafe/race/main.go:49 +0x58

Goroutine 9 (running) created at:
  main.main()
      /workspace/try-golang/examples/singleapp/slice_is_not_threadsafe/race/main.go:42 +0x3f3

Goroutine 7 (finished) created at:
  main.main()
      /workspace/try-golang/examples/singleapp/slice_is_not_threadsafe/race/main.go:42 +0x3f3
==================
src-len=100     dst-len=93
Found 2 data race(s)
exit status 66

データ競合が発生しないようにする(1)

保護するために、 sync.Mutex を利用している版

// スライス操作 (スライスヘッダの書き換え)はスレッドセーフでは無いというのを示すサンプルです。
// 本サンプルはデータ競合が発生しません。
//
// REFERENCES:
//   - https://stackoverflow.com/questions/44152988/append-not-thread-safe
//   - https://stackoverflow.com/questions/49879322/can-i-concurrently-write-different-slice-elements
package main

import (
    "flag"
    "fmt"
    "strconv"
    "sync"
)

type data struct {
    value string
}

func (me data) String() string {
    return me.value
}

func main() {
    var (
        verbose = flag.Bool("verbose", false, "verbose output")
    )
    flag.Parse()

    var (
        mu  = sync.Mutex{}
        src = make([]data, 100)
        dst = make([]data, 0)
    )

    for i := 0; i < len(src); i++ {
        src[i].value = strconv.Itoa(i)
    }

    wg := sync.WaitGroup{}
    for _, v := range src {
        wg.Add(1)
        go func(v data) {
            defer wg.Done()

            var tmp data
            tmp.value = v.value

            mu.Lock()
            dst = append(dst, tmp)
            mu.Unlock()
        }(v)
    }

    wg.Wait()

    fmt.Printf("src-len=%d\tdst-len=%d\n", len(src), len(dst))

    if *verbose {
        fmt.Println("=========== SRC ===========")
        for _, v := range src {
            fmt.Println(v)
        }
        fmt.Println("=========== DST ===========")
        for _, v := range dst {
            fmt.Println(v)
        }
    }
}

ちゃんと同期化させているので、こちらはデータ競合が発生しません。

$ task run-notrace
src-len=100     dst-len=100
src-len=100     dst-len=100
src-len=100     dst-len=100
src-len=100     dst-len=100
src-len=100     dst-len=100
src-len=100     dst-len=100
src-len=100     dst-len=100
src-len=100     dst-len=100
src-len=100     dst-len=100
src-len=100     dst-len=100

データ競合が発生しないようにする(2)

同期化が必要かどうかのルールは以下のstackoverflowのページにわかりやすく記載されています。非同期処理を作る場合にとても大事なルールです。

stackoverflow.com

The rule is simple: if multiple goroutines access a variable concurrently, and at least one of the accesses is a write, then synchronization is required.

ルールは簡単で、複数のゴルーチンが同時に変数にアクセスし、そのうちの少なくとも1つが書き込みである場合、同期が必要であるというものです。

なので、各々のGoroutineがそれぞれ独立したデータにアクセスしている場合は同期化は必要ないということになります。

// スライス操作 (スライスヘッダの書き換え)はスレッドセーフでは無いというのを示すサンプルです。
// 本サンプルはデータ競合が発生しません。
//
// REFERENCES:
//   - https://stackoverflow.com/questions/44152988/append-not-thread-safe
//   - https://stackoverflow.com/questions/49879322/can-i-concurrently-write-different-slice-elements
package main

import (
    "flag"
    "fmt"
    "strconv"
    "sync"
)

type data struct {
    value string
}

func (me data) String() string {
    return me.value
}

func main() {
    var (
        verbose = flag.Bool("verbose", false, "verbose output")
    )
    flag.Parse()

    var (
        src = make([]data, 100)
        dst = make([]data, len(src))
    )

    for i := 0; i < len(src); i++ {
        src[i].value = strconv.Itoa(i)
    }

    wg := sync.WaitGroup{}
    for i, v := range src {
        wg.Add(1)
        go func(i int, v data) {
            defer wg.Done()

            var tmp data
            tmp.value = v.value

            dst[i] = tmp
        }(i, v)
    }

    wg.Wait()

    fmt.Printf("src-len=%d\tdst-len=%d\n", len(src), len(dst))

    if *verbose {
        fmt.Println("=========== SRC ===========")
        for _, v := range src {
            fmt.Println(v)
        }
        fmt.Println("=========== DST ===========")
        for _, v := range dst {
            fmt.Println(v)
        }
    }
}

sync.Mutexを使っていませんが、この場合はデータ競合が発生しません。

$ task run-notrace2
src-len=100     dst-len=100
src-len=100     dst-len=100
src-len=100     dst-len=100
src-len=100     dst-len=100
src-len=100     dst-len=100
src-len=100     dst-len=100
src-len=100     dst-len=100
src-len=100     dst-len=100
src-len=100     dst-len=100
src-len=100     dst-len=100

データ競合が発生しないようにする(3)

Goには、このような場合に同期化を考えなくてもデータのやり取りが出来る構造として channel が用意されています。

なので、上記の処理は channel を利用して処理すれば同然ながらデータ競合しません。サンプルは割愛。

参考情報

stackoverflow.com

stackoverflow.com


過去の記事については、以下のページからご参照下さい。

サンプルコードは、以下の場所で公開しています。