Go 言語におけるデッドロック、ライブロック、スターベーションの概要

Go 言語におけるデッドロック、ライブロック、スターベーションの概要

 
 
このセクションでは、デッドロック、ライブロック、スターベーションの 3 つの概念を紹介します。

 

デッドロック

デッドロックとは、複数のプロセス(スレッド)が実行中にリソースの奪い合いにより待ち合い、外部からの力がないと先に進めなくなる現象のことを指します。このとき、システムがデッドロック状態にある、またはシステム内でデッドロックが発生しているといい、このように常に待ち続けているプロセスをデッドロックプロセスと呼びます。

デッドロックが発生する条件は次のとおりです。

1) 相互に排他的な条件

スレッドはリソースに排他的にアクセスできるため、スレッドが特定のリソースを占有している場合、他のスレッドはリソースが解放されるまで待機する必要があります。

2) リクエストとホールドの条件

スレッド T1 は、少なくとも 1 つのリソース R1 を占有したままにしていますが、別のリソース R2 を使用することを提案しています。この時点で、リソース R2 は他のスレッド T2 によって占有されているため、このスレッド T1 も待機する必要がありますが、自身が保持しているリソース R1 は待機していません。満足、解放されました。

3) 条件剥奪の禁止

スレッドが獲得したリソースは、使い果たされる前に他のスレッドによって奪われることはできず、使用後にのみ解放できます。

4) ループ待ち状態

デッドロックが発生した場合、「プロセスとリソースのリング チェーン」、つまり {p0,p1,p2,…pn} が存在する必要があります。プロセス p0 (またはスレッド) は p1 が占有するリソースを待機し、p1 は待機します。 p2 が占有しているリソースについては、 pn は p0 が占有しているリソースを待ちます。

最も直観的に理解すると、p0 は p1 が占有するリソースを待機し、p1 は p0 が占有するリソースを待機するため、2 つのプロセスは相互に待機することになります。

デッドロックの解決策:

  • 複数のテーブルが同時にクエリされる場合は、アクセス順序について合意します。
  • 同じトランザクション内で、必要なリソースのロックと取得を 1 回試行します。
  • デッドロックが発生しやすいビジネス シナリオの場合は、ロックの粒度をアップグレードし、テーブル レベルのロックを使用してみてください。
  • 分散トランザクション ロックまたはオプティミスティック ロックを使用します。

デッドロックされたプログラムとは、すべての同時プロセスが互いに待機しており、外部からの介入なしにはプログラムが回復できない状態になっているプログラムです。

デッドロックとは何かを誰もが理解しやすくするために、まず例を見てみましょう (コード内の未知の型、関数、メソッド、またはパッケージは無視して、デッドロックが何であるかを理解するだけにしてください)。コードは次のとおりです。

 パッケージ main

import (
    "fmt"
    "runtime"
    "sync"
    "time"
)

type value struct {
    memAccess sync.Mutex
    value     int
}

func main() {
    runtime.GOMAXPROCS(3)
    var wg sync.WaitGroup
    sum := func(v1, v2 *value) {
        defer wg.Done()
        v1.memAccess.Lock()
        time.Sleep(2 * time.Second)
        v2.memAccess.Lock()
        fmt.Printf("sum = %d\n", v1.value+v2.value)
        v2.memAccess.Unlock()
        v1.memAccess.Unlock()
    }

    product := func(v1, v2 *value) {
        defer wg.Done()
        v2.memAccess.Lock()
        time.Sleep(2 * time.Second)
        v1.memAccess.Lock()
        fmt.Printf("product = %d\n", v1.value*v2.value)
        v1.memAccess.Unlock()
        v2.memAccess.Unlock()
    }

    var v1, v2 value
    v1.value = 1
    v2.value = 1
    wg.Add(2)
    go sum(&v1, &v2)
    go product(&v1, &v2)
    wg.Wait()
} 

上記のコードを実行すると、次のように表示される場合があります。

fatal error: all goroutines are asleep – deadlock!

なぜ?よく見ると、このコードにタイミングの問題があることがわかります。

ライブロック

ライブロックは、スレッドをブロックしていないにもかかわらず、スレッドが同じ操作を繰り返し、常に失敗するため、続行できない別の形式のライブロックです。

たとえば、スレッド 1 はリソースを使用できますが、非常に礼儀正しく、他のスレッドに最初にリソースを使用させ、スレッド 2 もリソースを使用できますが、非常に紳士的で、他のスレッドに最初にリソースを使用させます。このようにして、あなたが私にさせても、私があなたにさせても、最後の 2 つのスレッドはリソースを使用できなくなります。

ライブロックは通常、トランザクション メッセージの処理中に発生します。メッセージが正常に処理できない場合、メッセージ処理メカニズムはトランザクションをロールバックし、キューの先頭に戻します。このようにして、エラーのあるトランザクションはロールバックされ、繰り返し実行されます。この形式のライブロックは通常、過剰なエラー回復コードによって発生します。これは、修正不可能なエラーを修正可能なエラーとして誤って扱うためです。

ライブロックは、複数の連携スレッドが相互に応答して状態を変更し、どのスレッドも実行を継続できなくなるときに発生します。それは、礼儀正しい二人の人が道で出会って、お互いに道を譲り、そして別の道で出会い、それを繰り返すようなものです。

このライブロックの問題を解決するには、再試行メカニズムにランダム性を導入する必要があります。たとえば、ネットワーク上でデータ パケットを送信する場合、衝突が検出された場合はパケットを停止し、一定時間後に再送信する必要がありますが、1 秒後に再送信しても衝突が発生するため、ランダム性を導入することでこのタイプを解決できます。問題の。

ライブロックを示す例は次のとおりです。

package main

import (
    "bytes"
    "fmt"
    "runtime"
    "sync"
    "sync/atomic"
    "time"
)

func main() {
    runtime.GOMAXPROCS(3)
    cv := sync.NewCond(&sync.Mutex{})
    go func() {
        for range time.Tick(1 * time.Second) { // tick 間隔で2人の歩調を制御する
            cv.Broadcast()
        }
    }()

    takeStep := func() {
        cv.L.Lock()
        cv.Wait()
        cv.L.Unlock()
    }

    tryDir := func(dirName string, dir *int32, out *bytes.Buffer) bool {
        fmt.Fprintf(out, " %+v", dirName)
        atomic.AddInt32(dir, 1)
        takeStep()                  // 歩を進める
        if atomic.LoadInt32(dir) == 1 { // 歩けたら成功して戻る
            fmt.Fprint(out, ".成功!")
            return true
        }
        takeStep() // 歩けなければ戻りながら歩く
        atomic.AddInt32(dir, -1)
        return false
    }

    var left, right int32
    tryLeft := func(out *bytes.Buffer) bool {
        return tryDir("左に行く", &left, out)
    }

    tryRight := func(out *bytes.Buffer) bool {
        return tryDir("右に行く", &right, out)
    }

    walk := func(walking *sync.WaitGroup, name string) {
        var out bytes.Buffer
        defer walking.Done()
        defer func() { fmt.Println(out.String()) }()
        fmt.Fprintf(&out, "%vはスクーティングを試みています:", name)

        for i := 0; i < 5; i++ {
            if tryLeft(&out) || tryRight(&out) {
                return
            }
        }
        fmt.Fprintf(&out, "\n%vは試みた!", name)
    }

    var trail sync.WaitGroup
    trail.Add(2)
    go walk(&trail, "男性") // 男性は道を歩いている
    go walk(&trail, "女性") // 女性は道を歩いている
    trail.Wait()
} 

 

この例は、ライブロック、つまり 2 つ以上の同時プロセスが調整なしでデッドロックを回避しようとする、ライブロックを使用する非常に一般的な理由を示しています。廊下にいる全員が 1 人だけが移動することに同意した場合、ライブロックは発生せず、1 人が静止し、もう 1 人が反対側に移動し、動き続けるようなものです。

ライブロックとデッドロックの違いは、ライブロックのエンティティは状態を常に変更する、いわゆる「ライブ」であるのに対し、デッドロックのエンティティは待機しており、ライブロックは自動的に解放される可能性がありますが、デッドロックは解放できないことです。

飢え

スターベーションとは、実行可能なプロセスがスケジューラーによって無期限に無視され、実行を継続できるにもかかわらず、実行をスケジュールできない状況を指します。

デッドロックとは異なり、スターベーション ロックは、一定期間内に最終的に優先度の低いスレッドを実行します。たとえば、優先度の高いスレッドは実行後にリソースを解放します。

ライブロックではすべての同時プロセスが同等であり、作業は行われないため、ライブロックはスターベーションとは無関係です。より広義には、飢餓とは通常、できるだけ効率的に作業を行うために 1 つ以上の同時プロセスを不当にブロックする、またはすべての同時プロセスをブロックする、1 つ以上の貪欲な同時プロセスが存在することを意味します。

次のサンプル プログラムには、貪欲な goroutine と Peaceful goroutine が含まれています。

package main

import(
    "fmt"
    "runtime"
    "sync"
    "time"
)

func メイン(){
    runtime.GOMAXPROCS(3)

    var wg sync.WaitGroup
    const runtime = 1 * time.Second
    var sharedLock sync.Mutex

    greedyWorker := func(){
        defer wg.Done()
        var count int
        for begin := time.Now(); time.Since(begin) <= runtime; {
            sharedLock.Lock()
            time.Sleep(3 * time.Nanosecond)
            sharedLock.Unlock()
            count++
        }

        fmt.Printf("貪欲労働者は%v回の作業ループを実行できました\n", count)
    }

    politeWorker := func(){
        defer wg.Done()
        var count int
        for begin := time.Now(); time.Since(begin) <= runtime; {
            sharedLock.Lock()
            time.Sleep(1 * time.Nanosecond)
            sharedLock.Unlock()

            sharedLock.Lock()
            time.Sleep(1 * time.Nanosecond)
            sharedLock.Unlock()

            sharedLock.Lock()
            time.Sleep(1 * time.Nanosecond)
            sharedLock.Unlock()
            count++
        }
        fmt.Printf("礼儀正しい労働者は%v回の作業ループを実行できました\n", count)
    }

    wg.Add(2)
    go greedyWorker()
    go politeWorker()

    wg.Wait()
} 

 

貪欲な労働者は、作業サイクル全体を完了するために共有ロックを貪欲に取得しますが、平和的な労働者は必要な場合にのみロックしようとします。両方のワーカーは同じ量のシミュレーション作業 (睡眠時間は 3 ナノ秒) を実行します。同じ時間内で、貪欲なワーカーの仕事量は平和的なワーカーのほぼ 2 倍であることがわかります。

両方のワーカーに同じサイズのクリティカル セクションがあると仮定すると、グリーディー ワーカーのアルゴリズムの方が効率的である (または、Lock と Unlock を呼び出すときに遅くないと考えるのではなく) と考えるのではなく、グリーディー ワーカーはクリティカル セクションを必要としないと結論付けます。共有ロックを保持すると、自由に拡張でき、平和的なワーカーゴルーチンが生産的な作業を (飢えさせることで) 行うことができなくなります。

要約する

ロックを適用しないと、必ず問題が発生します。これを使用すると、これまでの問題は解決されるものの、さらに新たな問題が発生します。

  • デッドロック: ロックの間違った使用が原因で例外が発生します。
  • Livelock: これは飢餓の特殊なケースです。論理的には正しいと感じられ、プログラムは正常に実行されていますが、非効率で論理的に続行できません。
  • Hunger: ロック使用の粒度に関係し、カウントとサンプリングを通じてプロセスの作業効率を判断できます。

共有リソースへのアクセスがある限り、一貫したアクセスを保証するために論理的に順序付けされ、アトミック化されている必要があります。ロックの概念を回避することはできません。

 

「 Go 言語におけるデッドロック、ライブロック、スターベーションの概要」についてわかりやすく解説!絶対に観るべきベスト2動画

【初心者必見!】Go言語とは?できることや学ぶメリット・将来性について解説
Golang の同時実行性 – シグナルとブロードキャスト