いろいろ備忘録日記

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

Goメモ-174 (sync.Mutexのサンプル)

概要

たまにGoでMutexの使い方どうやるのかを聞かれるので、ここにメモメモ。。。

Goでの Mutex は、sync.Mutex という型で用意されています。

使い方は、他の言語の場合と同じでクリティカルセクションで排他制御したい場合に使ったりします。

ちなみに、Mutexは mutual exclusion (排他制御) の略ですね。

Mutexを利用するシーンとしては、共有して操作されるリソースがあり、そのリソースに対して複数の処理が同時にアクセスする可能性がある場合に、排他制御して一度に一つの処理のみが通るようにするという使い方が主です。

よく、銀行の「預け入れ (deposit)」と「引き出し (withdraw)」の例で説明されることが多いので、以下のサンプルもそれにしています。

Mutexを使わない場合

以下のようなソースがあったとします。

package nomutex

import (
    "sync"

    "github.com/devlights/gomy/output"
)

const (
    execCount = 10000
)

var (
    balance = 1000
    countCh = make(chan struct{}, execCount*2)
)

func deposit(wg *sync.WaitGroup, v int) {
    defer wg.Done()

    balance += v
    countCh <- struct{}{}
}

func withdraw(wg *sync.WaitGroup, v int) {
    defer wg.Done()

    balance -= v
    countCh <- struct{}{}
}

// NoMutex -- Mutexを利用しない場合のサンプルです.
func NoMutex() error {
    var (
        wg sync.WaitGroup
    )
    wg.Add(execCount * 2)

    // 10 引き出して 10 預けるというのを非同期で 10000 回繰り返し
    for i := 0; i < execCount; i++ {
        go withdraw(&wg, 10)
        go deposit(&wg, 10)
    }

    wg.Wait()
    close(countCh)

    var count int
    for range countCh {
        count++
    }

    output.Stdoutl("[execCount]", count)
    output.Stdoutl("[balance]", balance)

    return nil
}

上では、元金として 1000 を持っている状態で、10預けて10引き出すを同時に行うようにしています。

足し引きされる値は グローバルな変数 としていますので、共有リソースとなります。

単純に考えると、10預けて10引き出すのを繰り返すだけなので、最終的な結果は 1000 とならないといけません。が、、。

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

gitpod /workspace/try-golang $ make run
go run github.com/devlights/try-golang/cmd/trygolang -onetime -example ""

ENTER EXAMPLE NAME: mutex_nomutex

[Name] "mutex_nomutex"
[execCount]          20000
[balance]            730


[Elapsed] 9.171121ms

値は 730 になってしまいました。(この結果は実行するたびに変わります。)

共有リソースに対して「同時に」アクセスしている goroutine がいたためですね。

どの言語でも、マルチスレッドな処理を書いた際によくバグを生んでしまうパターンの一つです。

今回の場合でいうと、共有リソース balance へのアクセスはアトミック、つまり、排他制御しないといけません。

Mutexを使った場合

次は sync.Mutex を使った場合です。

package usemutex

import (
    "sync"

    "github.com/devlights/gomy/output"
)

const (
    execCount = 10000
)

var (
    mutex   sync.Mutex
    balance = 1000
    countCh = make(chan struct{}, execCount*2)
)

func deposit(wg *sync.WaitGroup, v int) {
    defer wg.Done()

    mutex.Lock()
    defer mutex.Unlock()

    balance += v
    countCh <- struct{}{}
}

func withdraw(wg *sync.WaitGroup, v int) {
    defer wg.Done()

    mutex.Lock()
    defer mutex.Unlock()

    balance -= v
    countCh <- struct{}{}
}

// UseMutex -- NoMutexと同じ挙動で Mutex を使った版です.
func UseMutex() error {
    var (
        wg sync.WaitGroup
    )
    wg.Add(execCount * 2)

    // 10 引き出して 10 預けるというのを非同期で 10000 回繰り返し
    for i := 0; i < 10000; i++ {
        go withdraw(&wg, 10)
        go deposit(&wg, 10)
    }

    wg.Wait()
    close(countCh)

    var count int
    for range countCh {
        count++
    }

    output.Stdoutl("[execCount]", count)
    output.Stdoutl("[balance]", balance)

    return nil
}

一つ目のサンプルとほぼ同じ構成ですが、deposit関数とwithdraw関数内で sync.Mutex を使って排他制御するようにしています。

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

gitpod /workspace/try-golang $ make run
go run github.com/devlights/try-golang/cmd/trygolang -onetime -example ""

ENTER EXAMPLE NAME: mutex_usemutex

[Name] "mutex_usemutex"
[execCount]          20000
[balance]            1000


[Elapsed] 12.056915ms

今度はバッチリな結果になりました。今回は何回実行しても常に結果は1000となります。

共有リソースを変更する部分で排他制御を書けているので、一度に一つのgoroutineしか処理できないようにしているからですね。

Mutexの代わりにチャネルを使う場合

上の例でうまくいきましたが、Goでは以下の格言があります。

Do not communicate by sharing memory; instead, share memory by communicating.

メモリを共有することで通信するのではなく、通信することでメモリを共有しましょう。

Share Memory By Communicating - The Go Programming Language

共有してアクセスできるリソースというのを使って、それぞれの処理が動くのではなく、リソース自体をやり取りしながら処理しましょうって感じです。

マルチスレッドな処理を何度も書いた事ある人なら認識あるかもしれませんが、複数からアクセスされるデータを守るというのはとても難しいです。どれだけ気をつけていても、よくバグを生みます。

複数からアクセスされるデータを用いるのではなく、データ自体が必要とされる処理へ渡っていけば必然的にあるタイミングにおいて、1つの処理だけがそのデータを扱うことになります。

なので、排他制御していることと同じことになります。Goの場合、チャネルがそれに当たります。

同じ動きをチャネルで行っているのが以下です。

package usechannel

import (
    "sync"

    "github.com/devlights/gomy/output"
)

const (
    execCount = 10000
)

var (
    balance = make(chan int, 1)
    countCh = make(chan struct{}, execCount*2)
)

func deposit(wg *sync.WaitGroup, v int) {
    defer wg.Done()

    balance <- (<-balance+v)
    countCh <- struct{}{}
}

func withdraw(wg *sync.WaitGroup, v int) {
    defer wg.Done()

    balance <- (<-balance-v)
    countCh <- struct{}{}
}

// UseChannel -- Mutexの代わりにチャネルを利用したサンプルです.
func UseChannel() error {
    var (
        wg sync.WaitGroup
    )
    wg.Add(execCount * 2)

    // 最初の値を設定
    balance <- 1000

    // 10 引き出して 10 預けるというのを非同期で 10000 回繰り返し
    for i := 0; i < 10000; i++ {
        go withdraw(&wg, 10)
        go deposit(&wg, 10)
    }

    wg.Wait()
    close(balance)
    close(countCh)

    var count int
    for range countCh {
        count++
    }

    output.Stdoutl("[execCount]", count)
    output.Stdoutl("[balance]", <-balance)

    return nil
}

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

gitpod /workspace/try-golang $ make run
go run github.com/devlights/try-golang/cmd/trygolang -onetime -example ""

ENTER EXAMPLE NAME: mutex_usechannel

[Name] "mutex_usechannel"
[execCount]          20000
[balance]            1000


[Elapsed] 31.660426ms

同じ結果になりますね。

とかいって、sync.Mutexを使う必要はないという結論にはなりません。必要なシチュエーションもいっぱいあります。ケースバイケースで使い分けですね。

あと、チャネルでやるとMutexで処理するよりほんの少しだけ時間が掛かります。

参考情報

pkg.go.dev

go.dev

youtu.be


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

  • いろいろ備忘録日記まとめ

devlights.github.io

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

  • いろいろ備忘録日記サンプルソース置き場

github.com

github.com

github.com