【Golang】共有された変数を平行的に安全するには(単一のゴルーチンに閉じ込める、相互排他)

プログラミング言語Go」オンライン読書会で学んだ第9章「共有された変数による平行性」に関して共有したいと思います。

並行的に安全(concurrency-safe)とは

プログラミング言語Go」の一部を引用すると

二つ以上のゴルーチンから同期を加えることなく呼び出された場合であっても正しく動作を続けるのであれば、その関数は並行的に安全(concurrency-safe)です。

という記載があります。

例えば

  • 月予算
  • 週予算
  • 今月中に使った金額

という3つの変数を扱うデータ構造を実装することを考えてみます。

このデータ構造は

  • 月予算 >= 週予算
  • 今月中に使った金額 <= 月予算
  • 今月中に使った金額が月予算に達していれば、週予算も達している

という条件を満たす必要があり、複数のゴルーチンでこのデータ構造に操作を加えてもこれらの条件が必ず満たせれば並行的に安全と言えます。

実装

以下のような実装にしたとすると、これは並行的に安全ではありません。

type CostManager struct {
    MonthlyBudget uint
    WeeklyBudget  uint
    Spent         uint
}

// 週予算と月予算を設定
func (cm *CostManager) SetBudget(weeklyBudget uint, monthlyBudget uint) error {
    if weeklyBudget > monthlyBudget {
        return errors.New("週予算が月予算を超えています")
    }
    if cm.Spent > monthlyBudget {
        return errors.New("既に月予算より多い金額を使っています")
    }
    cm.WeeklyBudget = weeklyBudget // ①
    cm.MonthlyBudget = monthlyBudget
    return nil
}

// 使った金額を設定
func (cm *CostManager) SetSpent(spent uint) error {
    if cm.MonthlyBudget < spent {
        return errors.New("使った金額が既に月予算を超えています")
    }

    cm.Spent = spent
    return nil
}

// 使った金額が月予算に達している
func (cm *CostManager) IsReachedToMonthlyBudget() bool {
    return cm.MonthlyBudget <= cm.Spent
}

// 使った金額が週予算に達している
func (cm *CostManager) IsReachedToWeeklyBudget() bool {
    return cm.WeeklyBudget <= cm.Spent
}

並行的に安全ではない理由

複数のゴルーチンから同期を加えることなく操作した場合、変数が必要な条件を満たさなくなるので、並行的に安全ではありません。

1つのゴルーチンが SetBudget(週予算と月予算を設定)を実行した時にプログラム中の①の時点まで実行した時に、他のゴルーチンが予算に達したかをチェックする関数を実行した時に必要な機能を満たさなくなります。

具体的には、最初の状態が

  • 週予算: 100
  • 月予算: 300
  • 今月中に使った金額: 300

の時に別々のゴルーチンから

  • SetBudget(400, 1000) (週予算: 400、月予算: 1,000を設定)を実行し、プログラム中の①の時点(週予算を設定)まで実行
  • 予算に達したかチェックする関数を実行

すると以下の図の状態になり、「今月中に使った金額が月予算に達していれば、週予算も達している」という要件を満たしていません。

f:id:moritamorie:20210312233329p:plain

対処方法

この問題に対処する方法を2つあります。

  • 変数を単一のゴルーチンに閉じ込める
  • 相互排他(mutual exclusion)の高度な不変式 (invariant) を維持する

変数を単一のゴルーチンに閉じ込める

1つ目の方法は、以下のように変数を特定のゴルーチンからしかアクセスさせなくすることで、安全にするというシンプルな方法です。

func SomeFunc() {
    // 月予算: 1,000、週予算: 100、今月使った金額: 0で初期化
    cm := CostManager{
        MonthlyBudget: 1000,
        WeeklyBudget:  100,
        Spent:         0,
    }
    // 変数 cm はこのゴルーチン内からしかアクセスさせない。
}

func main() {
    go SomeFunc()
}

また、類似の方法として、単一のゴルーチンの代わりに、パイプラインの中にアクセスする変数を閉じ込めるという方法に関しても言及されていました。パイプラインは以下のGo blogの記事で紹介されている手法です。

相互排他(mutual exclusion)の高度な不変式 (invariant) を維持する

もう1つの方法は、相互排他を用いて不変式を維持する方法です。

不変式とは

不変式とは、常に真である条件または関係です。

上記のデータ構造が持つ特有の不変式としては

  • 月予算 >= 週予算
  • 今月中に使った金額 <= 月予算
  • 今月中に使った金額が月予算に達していれば、週予算も達している

があります。

相互排他とは

不変式を維持する方法として、相互排他( sync.Mutex: ミューテックス )が提供されています。

プログラミング言語Goの一部を引用すると

ミューテックスの目的は、共有された変数のある種の不変式がプログラム実行中の重要な時点で維持されるのを保証することです。

という記載があります。

ミューテックスロックの使い方と効果

ミューテックスのロックを使うことによって、以下のように不変式を維持することができます。

var mu sync.Mutex

func SomeFunc() {
    # ①不変式が維持された状態
    mu.Lock()
    # ②一時的に不変式が破られた状態
    mu.Unlock()
    # ③不変式が維持された状態
}

複数のゴルーチンからデータ構造にアクセスできなくなり、ロックする時・ロックを解除した時に不変式が維持された状態を保証することができます。

月予算・週予算サンプルの例

最初の週予算と月予算のプログラムにミューテックスを導入するには、それぞれの関数の最初に

mu.Lock()
defer mu.Unlock()

を追加します。既にロックを獲得しているゴルーチンが存在する場合、他のゴルーチンはロックの解除待ちになります。

var mu sync.Mutex

// 週予算と月予算を設定
func (cm *CostManager) SetBudget(weeklyBudget uint, monthlyBudget uint) error {
    mu.Lock()
    defer mu.Unlock()
    if weeklyBudget > monthlyBudget {
        return errors.New("週予算が月予算を超えています")
    }
    if cm.Spent > monthlyBudget {
        return errors.New("既に月予算より多い金額を使っています")
    }
    cm.WeeklyBudget = weeklyBudget
    cm.MonthlyBudget = monthlyBudget
    return nil
}

// 使った金額を設定
func (cm *CostManager) SetSpent(spent uint) error {
    mu.Lock()
    defer mu.Unlock()
    if cm.MonthlyBudget < spent {
        return errors.New("使った金額が既に月予算を超えています")
    }

    cm.Spent = spent
    return nil
}

// 使った金額が月予算に達している
func (cm *CostManager) IsReachedToMonthlyBudget() bool {
    mu.Lock()
    defer mu.Unlock()
    return cm.MonthlyBudget <= cm.Spent
}

// 使った金額が週予算に達している
func (cm *CostManager) IsReachedToWeeklyBudget() bool {
    mu.Lock()
    defer mu.Unlock()
    return cm.WeeklyBudget <= cm.Spent
}

ミューテーションロックが再入可能(re-retrant)ではない

再入可能(re-retrant)とは以下のようにmu.Lock()、mu.Unlock()入れ子の状態にできることを指しますが、実行時にエラー(fatal error: all goroutines are asleep - deadlock)になります。

mu.Lock()
// 処理①
mu.Lock()
// 処理②
mu.Unlock()
// 処理③
mu.Unlock()

プログラミング言語Goの一部を引用すると

再入可能なミューテックスは他のゴルーチンが共有された変数へアクセスしないことを保証するでしょうが、それらの変数の追加の不変式を保護することはできません。

という記載があり、このような背景から明示的に再入できない仕様にしているものと考えられます。

参考資料