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

Golang開発における共有変数問題の解決方法

2022-02-15 11:07:53

Goのゴルーチン+チャンネルは、通信によってメモリを共有し、並行プログラミングを可能にします。

しかし、Goは変数を共有することによって、つまりメモリを共有することによって並行処理を実現する伝統的な方法も提供しています。この記事では、Goがそのために提供するメカニズムについて説明します。

1. レースとは

Goプログラムを実行しているとき、同時に多くのゴルーチンが実行され、各ゴルーチン内のコードの実行は、2つのゴルーチンのコードの実行順序が決定できない場合、シーケンシャルになります。2つのゴルーチンのコードの実行順序を決定できない場合、2つのゴルーチンは同時に実行されていると言うことができます。

あるコードが順次に実行されても、同時に実行されても正しいことが判明した場合、そのコードは同時安全であると言われます。

デッドロック、ライブロック、レースステートなど、コンカレントアンセーフコードにはさまざまな問題があります。デッドロックとライブロックはどちらもコードが実行できなくなったことを示し、レース状態はコードは実行可能だが不正な結果になる可能性があることを示している。

典型的な例として、銀行口座への入金がある。

var balance int
func Deposit(amount int) {
    balance = balance + amount
}
func Balance() int {
    return balance
}

プログラムが正しければ、最終的な出力は20000になるはずですが、複数回実行すると、結果は19700、19800、19900、または他の値になる可能性があります。この時点で、このプログラムはデータ競合を起こしていると言えるでしょう。

この問題の根本的な原因は、balance = balance + amountのコード行がCPU上でアトミックでないため、実行が途中で中断される可能性があることです。

2. 競合する状態をなくすには

底辺の競争が起きたら、それを解決する方法を見つけなければならない。一般に、最下位争いを解決する方法は3つある。

  1. 変数を変更しない

  変数を変更する必要がなければ、どこにアクセスしても安全ですが、この方法では上記の問題は解決しません。

  2. 複数のゴルーチンで同じ変数にアクセスしない

  goroutine + channel はそのようなアイデアのひとつで、チャンネルブロッキングによって変数を更新します。これは、共有メモリで通信するのではなく、通信によってメモリを共有するという Go コードの設計思想に沿ったものです。

  3. 一度に1つのゴルーチンしか変数にアクセスできないようにする。

  一度に1つのゴルーチンしかアクセスできないようにすると、他のゴルーチンは現在のアクセスが終わるまで待たされることになり、これも底辺の競争をなくすことになります。

3. Goが提供する並行処理ツール

ここまで、トップ争いを解決する3つの方法についてお話しましたが、Goでは、一度に1つのゴルーチンしか変数にアクセスできないことを実現するために、次のようなツールを使っています。それぞれについて見ていきましょう。

3.1 相互排除ロック

これは、底辺への競争を解決するための最も古典的なツールである。この原理は、あるリソースにアクセスしたい場合、そのリソースのロックを取得する必要があり、ロックを取得した場合のみ、リソースにアクセスする資格があるというもので、他のゴルーチンがアクセスしたい場合は、現在のゴルーチンがロックを解放してリソースを取得するまで待たなければなりません

これを使用する前に、sync.Mutex を使用して、リソースにロックを適用する必要があります。

var mu sync.Mutex
var balance int

ロックを取得した各ゴルーチンは、例外が発生した場合でも、変数へのアクセスが完了した時点でロックを確実に解放する必要があり、ここではdeferを使用して最終的にロックを解放することを保証しています。

func Deposit(amount int) {
    mu.Lock()
    defer mu.Unlock()
    balance = balance + amount
}

func Balance() int {
    mu.Lock()
    defer mu.Unlock()
    return balance
}

コードを変更して上記のコードを実行すると、何度実行しても最終的には20000となり、ここで状態の競合の問題は解決しましたが、まだ小さな問題が残っています。

3.2 リード/ライトミューテックスロック

上記のミューテックスロックは、データにアクセスするための状態が競合する問題を解決しますが、残高を読むことが少し非効率であり、依然として残高を読みに来るたびにロックを取得する必要がある、という小さな問題があります。実際には、変数が変更されていなければ、複数のゴルーチンによって同時に読み込まれたとしても、同時実行安全性の問題は生じません。

私たちが望む理想的なシナリオは、変数への書き込みが行われていない場合、複数のゴルーチンを同時に読み込んで実行できることで、より効率的な運用が可能になります。

Goはこのツールも提供しており、それは読み取り/書き込みのロックです。このロックは読み書きの相互排他性はなく、簡単に言うと、同時に1つのゴルーチンしか書き込みができないようにするロックで、あるゴルーチンが書き込みをしている場合、他のゴルーチンは読みも書きもできないが、複数のゴルーチンは同時に読みができるようにするものです。

上のコードをもう一度、一箇所だけ変えてみましょう。

var mu sync.RWMutex // Replaces sync.Mutex
var balance int

この変更後、上記の入金コードは常に20000を出力しますが、複数のゴルーチンが同時に残高を読み取ることができるようになります。

Goの競合状態の問題のほとんどは、この2つのツールを使って解決することができます。

3.3 一度だけ

Go言語には、コードが一度だけ実行されるようにするためのツールも用意されており、主にリソースの初期化などのシナリオで利用されています。これも使い方は簡単です。

o := &sync.Once{}
for i := 0; i < 100; i++ {
    o.Do(func(){
        go func() {
            Deposit(100)
        }()

        go func() {
            Deposit(100)
        }()
    })
}
// Sleep for one second to let the above goroutine finish executing
Sleep(1 * time.Second)
fmt.Println(Balance())

上記のコードをOnceで制御した場合、全て一度に保存されるため、上記のコードは常に200を出力します。

3.4 競合する状態検知器

レースになっているエラーの多くは見つけにくいものです。Go言語には、コード内のレースをチェックするためのツールが用意されています。使い方は簡単で、次のコマンドの後に-race引数を追加するだけです。

$ go run -race

$ go build -race

$ go test -race

このパラメータを追加すると、コンパイラは実行中にコードのすべての共有変数へのアクセスをチェックします。もし、あるゴルーチンが同期操作なしに変数を書き込んだ後、別のゴルーチンがその変数を読み書きしていることがわかれば、ここでレースが発生していることを意味し、エラーが報告されます。例えば、次のようなコードです。

data := 1

go func() {
    data = 2
}()

go func() {
    data = 3
}()

time.Sleep(2 * time.Second)

go run -race main.goを実行すると、以下のエラーが表示されます。

1つのデータレースが見つかりました。
終了ステータス 66

4. 概要

Goが提供するインターフェイスは比較的シンプルですが、十分なパワーを備えています。

Golang開発における共有変数問題の解決方法に関するこの記事は以上です。Golangの共有変数に関する詳しい情報は、Scripting Houseの過去の記事を検索するか、以下の関連記事を引き続きご覧ください。