同時実行自体は複雑ではありませんが、リソースの競合の問題により、多くの不可解な問題が発生するため、優れた同時実行プログラムの開発が複雑になります。
競合状態は次のコードで発生します。
package main
import (
"fmt"
"runtime"
"sync"
)
var (
count int32
wg sync.WaitGroup
)
func main() {
wg.Add(2)
go incCount()
go incCount()
wg.Wait()
fmt.Println(count)
}
func incCount() {
defer wg.Done()
for i := 0; i < 2; i++ {
value := count
runtime.Gosched()
value++
count = value
}
}
これはリソース競合の例であり、プログラムを数回実行すると、結果が 2、3、または 4 になる可能性があることがわかります。これは、count 変数には同期保護がないため、両方のゴルーチンが読み取りと書き込みを行うため、計算結果が上書きされ、結果が正しくなくなります。
コード内の runtime.Gosched() は、リソース競合の結果をより明確にするために、現在の goroutine を一時停止し、実行キューに戻り、待機中の他の goroutine を実行させることを意味します。
2 つのゴルーチンがそれぞれ g1 と g2 であると仮定して、プログラムの実行プロセスを分析してみましょう。
- g1 は count の値が 0 であることを読み取ります。
- 次に、g1 は一時停止し、g2 に切り替えて実行しました。g2 によって読み取られたカウントの値も 0 でした。
- g2 は一時停止し、g1 に切り替えます。g1 は count+1 をペアにし、count の値は 1 になります。
- G1 は一時停止し、g2 に切り替えます。g2 は値 0 を取得し、それに +1 を加え、最後にその値を count に割り当てます。結果は依然として 1 です。
- g1 の count+1 の結果が g2 によって上書きされ、両方のゴルーチンが +1 になり、結果は 1 のままであることがわかります。
上記の分析から、2 つのゴルーチンが互いの結果を上書きするために上記の問題が発生することがわかります。
したがって、同じリソースの読み取りと書き込みはアトミックである必要があります。つまり、共有リソースへの読み取りと書き込みを同時に許可されるのは 1 つのゴルーチンだけです。
共有リソースの競合の問題は非常に複雑で、検出するのが困難です。幸いなことに、Go には、 go build -race
コマンドという、チェックに役立つツールが用意されています。プロジェクト ディレクトリでこのコマンドを実行して実行可能ファイルを生成し、その実行可能ファイルを実行して印刷された検出情報を確認します。
追加の-race
フラグがgo build
コマンドに追加されるため、生成された実行可能プログラムにはリソースの競合を検出する独自の機能が備わり、生成された実行可能ファイルを実行すると、次のような効果が得られます。
==================
WARNING: DATA RACE
Read at 0x000000619cbc by goroutine 8:
main.incCount()
D:/code/src/main.go:25 +0x80
Previous write at 0x000000619cbc by goroutine 7:
main.incCount()
D:/code/src/main.go:28 +0x9f
Goroutine 8 (running) created at:
main.main()
D:/code/src/main.go:17 +0x7e
Goroutine 7 (finished) created at:
main.main()
D:/code/src/main.go:16 +0x66
==================
4
Found 1 data race(s)
実行結果から、ゴルーチン 8 はコードの 25 行目で共有リソースのvalue := count
を読み取り、この時点でゴルーチン 7 はコードの 28 行目で共有リソースのcount = value
を変更していることがわかります。 goroutines は 16 からあり、17 行目は go キーワードで始まります。
共有リソースをロックする
Go 言語は、共有リソースをロックするという、ゴルーチンを同期するための従来のメカニズムを提供します。アトミック パッケージと同期パッケージの一部の関数は、共有リソースをロックすることができます。
原子関数
アトミック関数は、非常に低レベルのロック メカニズムを使用して、整数変数とポインターへのアクセスを同期できます。サンプル コードは次のとおりです。
package main
import (
"fmt"
"runtime"
"sync"
"sync/atomic"
)
var (
counter int64
wg sync.WaitGroup
)
func main() {
wg.Add(2)
go incCounter(1)
go incCounter(2)
wg.Wait() // goroutineの終了を待つ
fmt.Println(counter)
}
func incCounter(id int) {
defer wg.Done()
for count := 0; count < 2; count++ {
atomic.AddInt64(&counter, 1) // 安全な方法でcounterを1加える
runtime.Gosched()
}
}
上記のコードは、atmoic パッケージの AddInt64 関数を使用しています。この関数は、一度に 1 つの goroutie のみを強制的に実行して加算操作を完了することで、整数値の加算を同期します。ゴルーチンがアトミック関数を呼び出そうとすると、これらのゴルーチンは参照された変数に従って自動的に同期します。
その他の 2 つの便利なアトミック関数は、LoadInt64 と StoreInt64 です。これら 2 つの関数は、整数値を安全に読み書きする方法を提供します。次のコードは、LoadInt64 関数と StoreInt64 関数を使用して、プログラム内の複数のゴルーチンに特定の状態を通知できる同期フラグを作成します。
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
var (
shutdown int64
wg sync.WaitGroup
)
func main () {
wg.Add(2)
go doWork("A")
go doWork("B")
time.Sleep(1 * time.Second)
fmt.Println("Shutdown Now")
atomic.StoreInt64(&shutdown, 1)
wg.Wait()
}
func doWork(name string) {
defer wg.Done()
format.Printf("%s Work を実行中\n", name)
time.Sleep(250 * time.Millisecond)
if atomic.LoadInt64(&shutdown) == 1 {
format.Printf("%s をシャットダウン中\n", name)
break
}
}
上記のコードの main 関数は、StoreInt64 関数を使用してシャットダウン変数の値を安全に変更します。メイン関数が StoreInt64 を呼び出している間に doWork ゴルーチンが LoadInt64 関数を呼び出そうとした場合、アトミック関数はこれらの呼び出しを相互に同期して、これらの操作が安全であり、競合状態に入らないことを保証します。
ミューテックス
共有リソースへのアクセスを同期するもう 1 つの方法は、ミューテックスを使用することです。ミューテックスの名前は、相互排除の概念に由来しています。ミューテックスはコード上に重要なセクションを作成するために使用され、一度に 1 つのゴルーチンだけがこの重要なコードを実行できるようにします。
サンプルコードは次のとおりです。
package main
import (
"fmt"
"runtime"
"sync"
)
var (
counter int64
wg sync.WaitGroup
mutex sync.Mutex
)
func main() {
wg.Add(2)
go incCounter(1)
go incCounter(2)
wg.Wait()
fmt.Println(counter)
}
func incCounter(id int) {
defer wg.Done()
for count := 0; count < 2; count++ {
mutex.Lock()
{
value := counter
runtime.Gosched()
value++
counter = value
}
mutex.Unlock()
}
}
クリティカル セクションに一度に入ることができる goroutine は 1 つだけです。その後、Unlock 関数が呼び出されるまで、他のゴルーチンはクリティカル セクションに入ることができなくなります。 runtime.Gosched 関数を呼び出して現在の goroutine を現在のスレッドから強制的に終了すると、スケジューラはこの goroutine を再度割り当てて実行を継続します。