跟著 GPT-4 從0到1學習 Golang 並行機制(二)

2023-07-12 12:02:31

btw: 我的個人部落格網站

一、前言

話接上回《跟著 GPT-4 從0到1學習 Golang 並行機制(一)》,我想試試看 GPT-4 能不能當好一個程式設計老師,循循善誘,由淺入深,深入淺出…… 給我講解下 Golang 裡的並行機制。

今天繼續當白紙,GPT 為筆,程式碼為顏料,揮筆潑墨,接著作畫。

二、開聊

下文通過 DevChat 和 GPT-4 互動,如果你對 DevChat 感興趣,可以跳轉下文瀏覽:

2.1 Golang 中的 sync 包 - Mutex, RWMutex 和 WaitGroup

  • 我和 GPT 說:

我們繼續來聊 sync 包吧,請結合程式碼範例介紹下 sync 包的主要功能。

  • GPT 說:

Go 語言的 sync 包提供了基本的同步原語,如互斥鎖(Mutex)和讀寫鎖(RWMutex),以及一些高階的同步原語,如 WaitGroup 和 Cond。

以下是一些使用 sync 包的範例:

  1. 使用 Mutex:Mutex 用於在多個 goroutine 之間同步存取共用資源。
package main

import (
	"fmt"
	"sync"
)

var (
	mutex   sync.Mutex
	balance int
)

func deposit(value int, wg *sync.WaitGroup) {
	mutex.Lock()
	fmt.Printf("Depositing %d to account with balance: %d\n", value, balance)
	balance += value
	mutex.Unlock()
	wg.Done()
}

func main() {
	balance = 1000
	var wg sync.WaitGroup
	wg.Add(2)
	go deposit(200, &wg)
	go deposit(100, &wg)
	wg.Wait()
	fmt.Printf("New Balance %d\n", balance)
}

在這個範例中,我們使用一個 Mutex 來同步存取一個銀行賬戶的餘額。每次存款時,我們都會鎖定 Mutex,然後更新餘額,最後解鎖 Mutex。

  1. 使用 WaitGroup:WaitGroup 用於等待一組 goroutine 完成。
package main

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

func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done()
	fmt.Printf("Worker %d starting\n", id)
	time.Sleep(time.Second)
	fmt.Printf("Worker %d done\n", id)
}

func main() {
	var wg sync.WaitGroup
	for i := 1; i <= 5; i++ {
		wg.Add(1)
		go worker(i, &wg)
	}
	wg.Wait()
}

在這個範例中,我們建立了 5 個 worker goroutines,然後使用一個 WaitGroup 來等待它們全部完成。

  1. 使用 RWMutex:RWMutex 是一個讀/寫互斥鎖。它可以被多個讀者鎖定或一個寫者鎖定,但不能同時被讀者和寫者鎖定。
package main

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

var (
	rwMutex sync.RWMutex
	value   int
)

func readValue(wg *sync.WaitGroup) {
	defer wg.Done()
	rwMutex.RLock()
	fmt.Printf("Read value: %d\n", value)
	time.Sleep(1 * time.Second)
	rwMutex.RUnlock()
}

func writeValue(newValue int, wg *sync.WaitGroup) {
	defer wg.Done()
	rwMutex.Lock()
	fmt.Printf("Write value: %d\n", newValue)
	value = newValue
	rwMutex.Unlock()
}

func main() {
	var wg sync.WaitGroup
	wg.Add(1)
	go writeValue(10, &wg)
	for i := 0; i < 5; i++ {
		wg.Add(1)
		go readValue(&wg)
	}
	wg.Wait()
}

在這個範例中,我們使用一個 RWMutex 來同步讀寫一個共用變數。我們建立了一個寫者 goroutine 和多個讀者 goroutines。寫者會鎖定 RWMutex 進行寫操作,讀者會鎖定 RWMutex 進行讀操作。由於 RWMutex 允許多個讀者同時鎖定,所以這些讀者 goroutines 可以並行執行。

GPT 在這裡介紹了互斥鎖 Mutex、讀寫鎖 RWMutex 和等待組 WaitGroup 的用法,都還算挺直觀。不過在一開始 GPT 還提到了 Cond 條件變數,但是沒有給出範例,可能是篇幅的原因,我們繼續追問下。

2.2 條件變數 sync.Cond

  • 我又和 GPT 說:

在開始的時候你還提到了 sync 包中的 Cond,請繼續結合程式碼範例介紹 Cond

  • GPT 回答:

sync.Cond 是一個條件變數,它可以讓一組 goroutine 等待或者宣佈某事件的發生。條件變數總是與互斥鎖(sync.Mutexsync.RWMutex)一起使用。

以下是一個使用 sync.Cond 的範例:

package main

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

func main() {
	var m sync.Mutex
	c := sync.NewCond(&m)
	queue := make([]interface{}, 0, 10)

	removeFromQueue := func(delay time.Duration) {
		time.Sleep(delay)
		c.L.Lock()
		queue = queue[1:]
		fmt.Println("Removed from queue")
		c.L.Unlock()
		c.Signal()
	}

	for i := 0; i < 10; i++ {
		c.L.Lock()
		for len(queue) == 2 {
			c.Wait()
		}
		fmt.Println("Adding to queue")
		queue = append(queue, struct{}{})
		go removeFromQueue(1 * time.Second)
		c.L.Unlock()
	}
}

在這個範例中,我們建立了一個佇列和一個條件變數。我們在迴圈中向佇列新增元素,如果佇列的長度達到 2,我們就呼叫 c.Wait() 等待條件變數的訊號。在另一個 goroutine 中,我們在一段時間後從佇列中移除元素,並呼叫 c.Signal() 傳送訊號,這會喚醒等待的 goroutine。

注意,呼叫 c.Wait() 時,我們需要持有條件變數的鎖。c.Wait() 會自動釋放鎖,並將 goroutine 放入等待佇列。當 c.Signal() 被呼叫時,c.Wait() 會自動重新獲取鎖。這是為了保證在條件變數的訊號被傳送和接收之間,共用資料的狀態不會改變。

我猜結合生產者-消費者模式會讓 Cond 更好理解,試下吧:

  • 我又和 GPT 說:

看起來 Cond 在生產者-消費者模式中會很有用,能不能給一個相關的例子?

  • GPT 回答:

當然可以。以下是一個使用 sync.Cond 實現的生產者-消費者模型的範例:

package main

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

type Item struct {
	value string
}

func produce(queue *[]*Item, lock *sync.Mutex, cond *sync.Cond) {
	for i := 0; i < 10; i++ {
		lock.Lock()
		item := &Item{value: fmt.Sprintf("item-%d", i)}
		*queue = append(*queue, item)
		fmt.Printf("Produced %s\n", item.value)
		lock.Unlock()
		cond.Signal()
		time.Sleep(1 * time.Second)
	}
}

func consume(queue *[]*Item, lock *sync.Mutex, cond *sync.Cond) {
	for {
		lock.Lock()
		for len(*queue) == 0 {
			cond.Wait()
		}
		item := (*queue)[0]
		*queue = (*queue)[1:]
		fmt.Printf("Consumed %s\n", item.value)
		lock.Unlock()
	}
}

func main() {
	var lock sync.Mutex
	cond := sync.NewCond(&lock)
	queue := make([]*Item, 0, 10)

	go produce(&queue, &lock, cond)
	go consume(&queue, &lock, cond)

	time.Sleep(15 * time.Second)
}

在這個範例中,我們建立了一個生產者 goroutine 和一個消費者 goroutine。生產者每秒產生一個新的 Item 並新增到佇列中,然後通過 cond.Signal() 喚醒消費者。消費者在佇列為空時通過 cond.Wait() 等待,當收到生產者的訊號時,消費者從佇列中取出 Item 並處理。

注意,我們在呼叫 cond.Wait()cond.Signal() 時都需要持有鎖,這是為了保證在條件變數的訊號被傳送和接收之間,佇列的狀態不會改變。

結合這兩個範例,Cond 的用法就基本清晰了。這裡面有幾個小細節需要留一下,一個是 GPT 提到的 c.Wait() 會自動釋放鎖,並將 goroutine 放入等待佇列,然後當 c.Signal() 被呼叫時,c.Wait() 會自動重新獲取鎖;另外一個是:

for len(*queue) == 0 {
	cond.Wait()
}

這裡的邏輯是:

  1. 如果 len(*queue) == 0 條件成立,則執行 cond.Wait()
  2. c.Signal() 被呼叫時,c.Wait() 方法結束阻塞,並且嘗試重新獲得鎖;
  3. c.Wait() 成功獲得鎖時,繼續判斷 len(*queue) == 0,從而決定是繼續等待還是往下執行;

如果改成:

if len(*queue) == 0 {
	cond.Wait()
}

那就少了上述第三步判斷,也就是不會再次嘗試檢測 len(*queue) == 0。這一步檢測其實是必須的,因為從 c.Signal() 被呼叫後到另外一個 goroutine 中的 c.Wait() 重新獲取鎖的過程中,保證不了沒有其他 goroutine 修改了 queue。當然在當前程式碼範例中並不容易看出來使用 if 的問題,因為消費者只有一個。

看下這個範例場景吧:

  1. 消費者 A 檢查佇列,發現佇列為空,於是呼叫 c.Wait() 進入等待狀態。
  2. 生產者向佇列中新增一個元素,然後呼叫 c.Signal()。
    消費者 A 收到訊號,喚醒並嘗試重新獲取鎖。然而,此時消費者 B 可能搶先一步獲取了鎖,並從佇列中取走了元素。
  3. 消費者 B 釋放鎖。
  4. 消費者 A 獲取到鎖,然後嘗試從佇列中取出元素。但是,此時佇列可能已經為空,因為消費者 B 可能已經取走了唯一的元素。

這裡需要想清楚一個小細節,就是消費者 A 呼叫了 c.Wait() 進入等待狀態時,並不是其他消費者都必須也在等待狀態。

2.3 sync.Cond 的 Broadcast() 方法

我還想問一個和 sync.Cond 相關的問題,繼續:

  • 我又問 GPT:

Signal() 方法會喚醒一個隨機的 goroutine,那麼對應的喚醒所有等待的 goroutine 應該用什麼方法呢?具體的使用場景是什麼?請舉例介紹

  • GPT 回答:

sync.CondBroadcast() 方法可以喚醒所有等待的 goroutine。當你有一個事件會影響所有等待的 goroutine 時,你應該使用 Broadcast()

以下是一個使用 Broadcast() 的範例:

package main

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

func worker(id int, cond *sync.Cond) {
	cond.L.Lock()
	cond.Wait()
	fmt.Printf("Worker %d fired\n", id)
	cond.L.Unlock()
}

func main() {
	var wg sync.WaitGroup
	var m sync.Mutex
	cond := sync.NewCond(&m)

	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			worker(id, cond)
		}(i)
	}

	fmt.Println("Ready...Set...Go!")
	time.Sleep(2 * time.Second)
	cond.Broadcast()
	wg.Wait()
}

在這個範例中,我們建立了 10 個 worker goroutines,它們都在等待一個 "開始" 的訊號。當我們呼叫 cond.Broadcast() 時,所有的 worker goroutines 都會收到這個訊號,並開始執行。

這個模式在你需要等待所有 goroutines 同時開始執行時非常有用,例如在平行計算或者基準測試中。

挺有趣,哈哈~

2.4 原子操作 Atomic

  • 我和 GPT 說:

請繼續介紹下 Golang 的 Atomic

  • GPT 回答:

Go 語言的 sync/atomic 包提供了底層的原子級記憶體操作,包括對整數型別和指標的原子載入(Load)、儲存(Store)、增加(Add)、比較並交換(Compare and Swap,簡稱 CAS)等。

以下是一些使用 sync/atomic 的範例:

  1. 原子增加:我們可以使用 atomic.AddInt64 來原子地增加一個 int64 的值。
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

var (
	sum int64
)

func worker(wg *sync.WaitGroup) {
	defer wg.Done()
	atomic.AddInt64(&sum, 1)
}

func main() {
	var wg sync.WaitGroup
	wg.Add(1000)
	for i := 0; i < 1000; i++ {
		go worker(&wg)
	}
	wg.Wait()
	fmt.Println(sum) // Output: 1000
}

在這個範例中,我們建立了 1000 個 goroutine,每個 goroutine 都會原子地增加 sum 的值。

  1. 原子比較並交換:我們可以使用 atomic.CompareAndSwapInt64 來原子地比較並交換一個 int64 的值。
package main

import (
	"fmt"
	"sync/atomic"
)

func main() {
	var value int64 = 20
	fmt.Println(atomic.CompareAndSwapInt64(&value, 20, 21)) // Output: true
	fmt.Println(value) // Output: 21
}

在這個範例中,我們原子地比較 value 是否等於 20,如果等於,就將其設定為 21。

這些原子操作在並行程式設計中非常有用,特別是在你需要對一個變數進行復雜的並行操作,而不想使用互斥鎖時。然而,請注意,原子操作通常需要更復雜的錯誤處理和更細緻的設計。

  • 我又問 GPT:

atomic 操作是不是隻是為了簡化部分的互斥鎖使用場景

原子操作和互斥鎖都是用於在並行環境中保護共用資料的工具,但它們的使用場景和特性是不同的。

原子操作通常用於簡單的、單一的讀寫操作,例如增加一個計數器或者更新一個標誌。原子操作的優點是它們非常快速且不會導致 goroutine 阻塞。然而,原子操作不能用於保護多個操作構成的臨界區,也不能用於同步多個 goroutine 的執行順序。

互斥鎖則可以用於保護複雜的臨界區,例如一個操作序列或者一個資料結構的多個欄位。互斥鎖也可以用於同步多個 goroutine 的執行順序。然而,互斥鎖的操作比原子操作要慢,且可能導致 goroutine 阻塞。

所以,原子操作並不只是為了簡化互斥鎖的使用,而是為了提供一種更輕量級的同步機制。在選擇使用原子操作還是互斥鎖時,你需要根據你的具體需求和場景來決定。

  • GPT 回答:

原子操作和互斥鎖都是用於在並行環境中保護共用資料的工具,但它們的使用場景和特性是不同的。

原子操作通常用於簡單的、單一的讀寫操作,例如增加一個計數器或者更新一個標誌。原子操作的優點是它們非常快速且不會導致 goroutine 阻塞。然而,原子操作不能用於保護多個操作構成的臨界區,也不能用於同步多個 goroutine 的執行順序。

互斥鎖則可以用於保護複雜的臨界區,例如一個操作序列或者一個資料結構的多個欄位。互斥鎖也可以用於同步多個 goroutine 的執行順序。然而,互斥鎖的操作比原子操作要慢,且可能導致 goroutine 阻塞。

所以,原子操作並不只是為了簡化互斥鎖的使用,而是為了提供一種更輕量級的同步機制。在選擇使用原子操作還是互斥鎖時,你需要根據你的具體需求和場景來決定。

2.5 context 包

  • 我又問 GPT:

請繼續介紹下 Golang 裡的 Context

  • GPT 回答:

Go 語言的 context 包提供了一種在 API 邊界之間傳遞請求範圍的值、取消訊號和超時資訊的方式。

以下是一些使用 context 的範例:

  1. 使用 WithCancel 建立可取消的 Context
package main

import (
	"context"
	"fmt"
	"time"
)

func operation(ctx context.Context) {
	select {
	case <-time.After(500 * time.Millisecond):
		fmt.Println("operation completed")
	case <-ctx.Done():
		fmt.Println("operation cancelled")
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	go operation(ctx)
	time.Sleep(200 * time.Millisecond)
	cancel()
	time.Sleep(500 * time.Millisecond)
}

在這個範例中,我們建立了一個可取消的 Context,並傳遞給一個長時間執行的操作。我們在主 goroutine 中取消 Context,這會導致操作提前結束。

  1. 使用 WithDeadline 建立有超時的 Context
package main

import (
	"context"
	"fmt"
	"time"
)

func operation(ctx context.Context) {
	select {
	case <-time.After(1 * time.Second):
		fmt.Println("operation completed")
	case <-ctx.Done():
		fmt.Println("operation timeout")
	}
}

func main() {
	ctx, _ := context.WithDeadline(context.Background(), time.Now().Add(500*time.Millisecond))
	go operation(ctx)
	time.Sleep(2 * time.Second)
}

在這個範例中,我們建立了一個有超時的 Context,並傳遞給一個長時間執行的操作。如果操作在超時時間內沒有完成,Context 就會被取消,這會導致操作提前結束。

  1. 使用 WithValue 傳遞請求範圍的值
package main

import (
	"context"
	"fmt"
)

func operation(ctx context.Context) {
	value := ctx.Value("key").(string)
	fmt.Println("operation completed with value:", value)
}

func main() {
	ctx := context.WithValue(context.Background(), "key", "some value")
	go operation(ctx)
	time.Sleep(1 * time.Second)
}

在這個範例中,我們使用 context.WithValue 建立了一個帶有值的 Context,並傳遞給一個操作。操作可以從 Context 中獲取這個> 值。

注意,context 包的設計目標是用於傳遞跨 API 邊界的請求範圍的資料,而不是在函數之間傳遞可選引數。如果你只是需要傳遞一些可選引數,你應該使用函數引數、結構體或者其他資料結構,而不是 context

挺直觀,取消和超時看起來都很有用。那麼「跨 API 邊界的請求範圍的資料」具體應該怎麼理解呢?context.WithValue 真實使用場景是怎樣的呢?

  • 我接著問 GPT:

請進一步解釋下 context.WithValue 的使用場景,幫助我理解其和函數傳參的差異。

  • GPT 回答:

context.WithValue 主要用於在 API 邊界之間傳遞請求範圍的資料。這些資料通常是和請求相關的,例如請求 ID、認證令牌、使用者 ID 等。這些資料在處理請求的過程中可能會被多個函數或者多個 goroutine 使用。

以下是一個使用 context.WithValue 的範例:

package main

import (
	"context"
	"fmt"
	"net/http"
)

func middleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ctx := context.WithValue(r.Context(), "userID", "123")
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func handler(w http.ResponseWriter, r *http.Request) {
	userID := r.Context().Value("userID").(string)
	fmt.Fprintf(w, "Hello, user %s\n", userID)
}

func main() {
	http.Handle("/", middleware(http.HandlerFunc(handler)))
	http.ListenAndServe(":8080", nil)
}

在這個範例中,我們建立了一個 HTTP 中介軟體,它會將使用者 ID 新增到請求的 Context 中。然後,我們的處理常式可以從 Context 中獲取使用者 ID。

與函數引數不同,Context 可以在 API 邊界之間傳遞資料,而不需要修改函數簽名。這使得我們可以在不改變函數簽名的情況下,向我們的程式新增新的跨 API 的資料。

然而,context.WithValue 應該謹慎使用。它不應該被用於在函數之間傳遞可選引數,或者在程式內部傳遞資料。如果你只是需要在函數之間傳遞一些資料,你應該使用函數引數、結構體或者其他資料結構,而不是 context

行。不過我想起來 context.TODO(),繼續問下 GPT 吧。

  • 我又問 GPT:

再介紹下 context.TODO() 和 context.Background() 吧。

  • GPT 回答:

context.Background()context.TODO() 都是用於建立新的 Context 的函數,但它們的用途略有不同。

context.Background() 返回一個空的 Context,這個 Context 不能被取消,沒有值,也沒有超時。它通常用作主函數、初始化函數和測試程式碼中的頂級 Context,也用作其他 Context 的預設值。如果你不確定應該使用哪個 Context,你可以使用 context.Background()

context.TODO() 也返回一個空的 Context。這個函數主要用在當函數需要一個 Context 引數,但是還不清楚應該使用哪個 Context 時,或者在你計劃將來要新增一個 Context 到函數簽名,但是還沒有新增時。context.TODO() 可以提醒你將來需要修改這部分程式碼。

以下是一個使用 context.TODO() 的範例:

package main

import (
	"context"
	"fmt"
)

func operation(ctx context.Context) {
	// ...
}

func main() {
	// TODO: create a proper Context
	operation(context.TODO())
	fmt.Println("operation completed")
}

在這個範例中,我們使用 context.TODO() 作為一個臨時的 Context。將來,我們可能會建立一個具有超時或者取消功能的 Context,並替換掉 context.TODO()

  • 我又問 GPT:

你提到了 context 的分級,請詳細介紹下相關知識點

  • GPT 回答:

在 Go 語言中,Context 是可以分級的,也就是說,你可以從一個已有的 Context 建立一個新的 Context。新的 Context 會繼承父 Context 的所有特性,包括取消訊號、超時時間和值。同時,你還可以為新的 Context 新增額外的特性。

以下是一些建立分級 Context 的函數:

  • context.WithCancel(parent Context) (ctx Context, cancel CancelFunc):建立一個新的 Context,這個 Context 可以被取消。當父 Context 被取消時,新的 Context 也會被取消。

  • context.WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc):建立一個新的 Context,這個 > Context 會在指定的時間點自動取消。當父 Context 被取消,或者當前時間超過了 deadline 時,新的 Context 會被取消。

  • context.WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc):建立一個新的 Context,這個 Context 會在指定的時間段後自動取消。當父 Context 被取消,或者從現在開始的 timeout 時間段過去後,新的 Context 會被取消。

  • context.WithValue(parent Context, key, val interface{}) Context:建立一個新的 Context,這個 Context 會包含一個與 key 關聯的值。新的 Context 會繼承父 Context 的所有值,並新增一個新的值。

以下是一個使用分級 Context 的範例:

package main

import (
	"context"
	"fmt"
	"time"
)

func operation(ctx context.Context) {
	select {
	case <-time.After(1 * time.Second):
		fmt.Println("operation completed")
	case <-ctx.Done():
		fmt.Println("operation cancelled")
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
	defer cancel()
	go operation(ctx)
	time.Sleep(2 * time.Second)
}

在這個範例中,我們從 context.Background() 建立了一個新的 Context,這個 Context 會在 500 毫秒後自動取消。然後,我們將這個 Context 傳遞給一個長時間執行的操作。當 Context 被取消時,操作會提前結束。

大致就是說當「子執行緒」需要管理「孫執行緒」的時候,可以通過分級 Context 的方式讓爺爺依舊能管到孫子。

三、總結

Race Detector、Goroutine 洩露和記憶體模型這幾個話題明天再接著聊吧。(欲催更,關注公眾號「胡說雲原生」!)

相關文章