iT邦幫忙

2023 iThome 鐵人賽

DAY 4
0
自我挑戰組

Concurrency in go 讀書心得系列 第 4

4.Deadlocks, Livelocks, and Starvation

  • 分享至 

  • xImage
  •  

死鎖(Deadlock):死鎖是所有併發進程(concurrent process)都在彼此等待的狀態。在這種情況下,如果沒有外部干預,程式將永遠不會恢復。

如同以下程式碼範例

type value struct {
    mu    sync.Mutex
    value int
}

var wg sync.WaitGroup

printSum := func(v1, v2 *value) {
    defer wg.Done()
    v1.mu.Lock()         // 1. 這裡我們試圖訪問帶鎖的部分
    defer v1.mu.Unlock() // 2. 這裡我們試圖調用defer關鍵字釋放鎖
    time.Sleep(2 * time.Second) // 3. 這裡我們添加休眠時間 以造成死鎖
    v2.mu.Lock()
    defer v2.mu.Unlock()
    fmt.Printf("sum=%v\n", v1.value+v2.value)
}

var a, b value
wg.Add(2)
go printSum(&a, &b)
go printSum(&b, &a)
wg.Wait()

如果你運行這段程式,會得到The fatal error: all goroutines are asleep - deadlock!
這段程式意思是啟動了兩個 goroutines。第一個呼叫 printSum(&a, &b),而第二個則呼叫 printSum(&b, &a)。
在 printSum 函數內,首先鎖定了 v1,然後延遲2秒,再鎖定 v2。
當兩個 goroutines 幾乎同時啟動時會發生什麼事?
第一個 goroutine (printSum(&a, &b)) 鎖定了 a 並開始等待2秒。
第二個 goroutine (printSum(&b, &a)) 鎖定了 b 並開始等待2秒。
2秒後,第一個 goroutine 嘗試鎖定 b,但不能,因為 b 已被第二個 goroutine 鎖定。
同時,第二個 goroutine 嘗試鎖定 a,但也不能,因為 a 已被第一個 goroutine 鎖定。
此時,每個 goroutine 都在等待另一個 goroutine 釋放它所需要的鎖。由於兩者都在等待對方,因此形成了死鎖。這是一個典型的循環等待情況,它是造成死鎖的一個主要因素。


科夫曼條件(Coffman Conditions):是幫助檢測、防止和糾正死鎖的技術基礎。

科夫曼條件如下:

  1. 相互排斥(Mutual Exclusion):
    併發進程在任何時候都擁有資源的獨佔權。
  2. 等待條件(Hold and Wait):
    併發進程必須同時持有資源並等待額外的資源。
  3. 沒有搶占(No Preemption):
    併發進程持有的資源只能由該進程釋放,因此它滿足了這種情況。
  4. 循環等待(Circular Wait):
    一個併發進程(P1)必須等待一系列其他並發進程(P2),這些並行進程(P1)等待進程(P1),因此 它也能滿足這個最終條件。

活鎖(Livelock):是正在主動執行併發操作的程式,但這些操作無法向前移動程式的狀態。

書中的舉例是:你有沒有在走廊走向另一個人? 她移動到一邊讓你通過,但你也是這樣做的。所以你轉移到另一邊,但她也是這樣做的。想像這會永遠持續下去,這就是活鎖。

程式碼範例:

func main() {
	cadence := sync.NewCond(&sync.Mutex{})
	go func() {
		for range time.Tick(1 * time.Millisecond) {
			cadence.Broadcast()
		}
	}()

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

	// <1> tryDir 允許一個人嘗試向某個方向移動並返回,無論他們是否成功。每個方向都表示為試圖朝這個方向移動的次數。
	tryDir := func(dirName string, dir *int32, out *bytes.Buffer) bool {
		fmt.Fprintf(out, " %v", dirName)
		
		// <2> 首先,我們通過將該方向遞增1來朝著某個方向移動。我們將在第3章詳細討論atomic包。現在,你只需要知道這個包的操作是原子操作。
		atomic.AddInt32(dir, 1)
		
		// <3> 每個人必須以相同的速度或節奏移動。takeStep模擬所有動作之間的恒定節奏。
		takeStep()

		if atomic.LoadInt32(dir) == 1 {
			fmt.Fprint(out, ". Success!")
			return true
		}

		takeStep()

		// <4> 這個人意識到他們不能在這個方向上放棄。我們通過將該方向遞減1來表示這一點。
		atomic.AddInt32(dir, -1)
		return false
	}

	var left, right int32
	tryLeft := func(out *bytes.Buffer) bool { return tryDir("left", &left, out) }
	tryRight := func(out *bytes.Buffer) bool { return tryDir("right", &right, out) }
	walk := func(walking *sync.WaitGroup, name string) {
		var out bytes.Buffer
		defer func() { fmt.Println(out.String()) }()
		defer walking.Done()
		fmt.Fprintf(&out, "%v is trying to scoot:", name)

		// <1> 我對嘗試次數進行了人為限制,以便該程序結束。在一個有活鎖的程序中,可能沒有這種限制,這就是為什麽它是一個現實工作中的問題。
		for i := 0; i < 5; i++ {
			// <2> 首先,這個人會試圖向左走,如果失敗了,會嘗試向右走。
			if tryLeft(&out) || tryRight(&out) {
				return
			}
		}
		fmt.Fprintf(&out, "\n%v tosses her hands up in exasperation!", name)
	}

	// <3> 這個變量為程序提供了等待,直到兩個人都能夠相互通過或放棄。
	var peopleInHallway sync.WaitGroup
	peopleInHallway.Add(2)
	go walk(&peopleInHallway, "Alice")
	go walk(&peopleInHallway, "Barbara")
	peopleInHallway.Wait()
}

最後會印出
Alice is trying to scoot: left right left right left right left right left right Ali
  ce tosses her hands up in exasperation!
  Barbara is trying to scoot: left right left right left right left right left right
  Barbara tosses her hands up in exasperation!

模擬的情況如下:
Alice 和 Barbara 同時開始嘗試移動。
她們可能同時嘗試向左或向右移動,從而阻礙對方。
如果她們碰巧同時選擇了相同的方向,她們會回到原來的位置並再次嘗試。
由於她們的動作是同步的(由 takeStep 控制),因此存在她們持續選擇相同方向而無法通過的風險。

這段代碼模擬了兩個人在走廊相遇並嘗試避開彼此的場景。兩個人(Alice 和 Barbara)都試圖向左或向右移動以避開對方,但由於它們的移動是在相同的節奏和模式下進行的,所以它們可能會在相同的時間朝相同的方向移動,從而不斷地互相阻礙而無法通過。

這就是活鎖的典型例子。活鎖是一種程序在執行中處於活躍狀態,但卻不能進行有意義的工作的狀態。換句話說,程序並沒有停止或凍結,但由於某種持續的互相阻礙,所以無法前進。

饑餓(Starvation):是指併發進程無法獲得執行工作所需的任何資源的情況。

饑餓描述了一種情境,當一個或多個協程由於資源競爭而無法獲得足夠的CPU時間或其他資源來進行工作。
以下這個例子展示了一個貪婪的goroutine和一個知足的goroutine:

func main() {
	var wg sync.WaitGroup
	var sharedLock sync.Mutex
	const runtime = 1 * time.Second

	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("Greedy worker was able to execute %v work loops\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("Polite worker was able to execute %v work loops.\n", count)
	}

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

	wg.Wait()
}
greedyWorker:當獲得 sharedLock 的鎖定時,這個工作協程會執行較長時間(3奈秒)的休眠。它的主要目的是嘗試在每次循環中獲得鎖,然後稍微持有鎖的時間比 politeWorker 長。

politeWorker:這個工作協程每次循環都會三次嘗試獲得鎖,每次都只持有鎖很短的時間(1奈秒)。這意味著它釋放鎖的頻率比 greedyWorker 高,從而給其他協程更多的機會獲得鎖。

在這個例子中,由於 greedyWorker 持有鎖的時間相對較長,它可能會比 politeWorker 獲得更多的工作循環機會。另一方面,politeWorker 儘管釋放鎖的頻率很高,但由於它每次循環都嘗試三次獲得鎖,這可能會使它的效率受到影響,特別是當 greedyWorker 持續地搶占鎖時。

當你運行這段程式碼時,你可能會發現 greedyWorker 能夠完成的工作循環次數比 politeWorker 多。這意味著 politeWorker 可能會遭受饑餓,因為它雖然試圖對其他工作協程 "有禮貌",但在競爭鎖時往往處於不利地位。

這種情境強調了同步和資源管理在多協程環境中的複雜性,以及如何小心設計協程的互動以避免饑餓和其他潛在問題。


上一篇
3.Race Condition, Atomicity, Memory Access Synchronization
下一篇
5.CSP & Go
系列文
Concurrency in go 讀書心得30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言