btw: 我的個人部落格網站
話接上回《跟著 GPT-4 從0到1學習 Golang 並行機制(一)》,我想試試看 GPT-4 能不能當好一個程式設計老師,循循善誘,由淺入深,深入淺出…… 給我講解下 Golang 裡的並行機制。
今天繼續當白紙,GPT 為筆,程式碼為顏料,揮筆潑墨,接著作畫。
下文通過 DevChat 和 GPT-4 互動,如果你對 DevChat 感興趣,可以跳轉下文瀏覽:
我們繼續來聊 sync 包吧,請結合程式碼範例介紹下 sync 包的主要功能。
Go 語言的
sync
包提供了基本的同步原語,如互斥鎖(Mutex)和讀寫鎖(RWMutex),以及一些高階的同步原語,如 WaitGroup 和 Cond。以下是一些使用
sync
包的範例:
- 使用 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。
- 使用 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 來等待它們全部完成。
- 使用 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 條件變數,但是沒有給出範例,可能是篇幅的原因,我們繼續追問下。
在開始的時候你還提到了 sync 包中的 Cond,請繼續結合程式碼範例介紹 Cond
sync.Cond
是一個條件變數,它可以讓一組 goroutine 等待或者宣佈某事件的發生。條件變數總是與互斥鎖(sync.Mutex
或sync.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 更好理解,試下吧:
看起來 Cond 在生產者-消費者模式中會很有用,能不能給一個相關的例子?
當然可以。以下是一個使用
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()
}
這裡的邏輯是:
len(*queue) == 0
條件成立,則執行 cond.Wait()
;c.Signal()
被呼叫時,c.Wait()
方法結束阻塞,並且嘗試重新獲得鎖;c.Wait()
成功獲得鎖時,繼續判斷 len(*queue) == 0
,從而決定是繼續等待還是往下執行;如果改成:
if len(*queue) == 0 {
cond.Wait()
}
那就少了上述第三步判斷,也就是不會再次嘗試檢測 len(*queue) == 0
。這一步檢測其實是必須的,因為從 c.Signal()
被呼叫後到另外一個 goroutine 中的 c.Wait()
重新獲取鎖的過程中,保證不了沒有其他 goroutine 修改了 queue。當然在當前程式碼範例中並不容易看出來使用 if 的問題,因為消費者只有一個。
看下這個範例場景吧:
這裡需要想清楚一個小細節,就是消費者 A 呼叫了 c.Wait() 進入等待狀態時,並不是其他消費者都必須也在等待狀態。
我還想問一個和 sync.Cond 相關的問題,繼續:
Signal() 方法會喚醒一個隨機的 goroutine,那麼對應的喚醒所有等待的 goroutine 應該用什麼方法呢?具體的使用場景是什麼?請舉例介紹
sync.Cond
的Broadcast()
方法可以喚醒所有等待的 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 同時開始執行時非常有用,例如在平行計算或者基準測試中。
挺有趣,哈哈~
請繼續介紹下 Golang 的 Atomic
Go 語言的
sync/atomic
包提供了底層的原子級記憶體操作,包括對整數型別和指標的原子載入(Load)、儲存(Store)、增加(Add)、比較並交換(Compare and Swap,簡稱 CAS)等。以下是一些使用
sync/atomic
的範例:
- 原子增加:我們可以使用
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
的值。
- 原子比較並交換:我們可以使用
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。這些原子操作在並行程式設計中非常有用,特別是在你需要對一個變數進行復雜的並行操作,而不想使用互斥鎖時。然而,請注意,原子操作通常需要更復雜的錯誤處理和更細緻的設計。
atomic 操作是不是隻是為了簡化部分的互斥鎖使用場景
原子操作和互斥鎖都是用於在並行環境中保護共用資料的工具,但它們的使用場景和特性是不同的。
原子操作通常用於簡單的、單一的讀寫操作,例如增加一個計數器或者更新一個標誌。原子操作的優點是它們非常快速且不會導致 goroutine 阻塞。然而,原子操作不能用於保護多個操作構成的臨界區,也不能用於同步多個 goroutine 的執行順序。
互斥鎖則可以用於保護複雜的臨界區,例如一個操作序列或者一個資料結構的多個欄位。互斥鎖也可以用於同步多個 goroutine 的執行順序。然而,互斥鎖的操作比原子操作要慢,且可能導致 goroutine 阻塞。
所以,原子操作並不只是為了簡化互斥鎖的使用,而是為了提供一種更輕量級的同步機制。在選擇使用原子操作還是互斥鎖時,你需要根據你的具體需求和場景來決定。
原子操作和互斥鎖都是用於在並行環境中保護共用資料的工具,但它們的使用場景和特性是不同的。
原子操作通常用於簡單的、單一的讀寫操作,例如增加一個計數器或者更新一個標誌。原子操作的優點是它們非常快速且不會導致 goroutine 阻塞。然而,原子操作不能用於保護多個操作構成的臨界區,也不能用於同步多個 goroutine 的執行順序。
互斥鎖則可以用於保護複雜的臨界區,例如一個操作序列或者一個資料結構的多個欄位。互斥鎖也可以用於同步多個 goroutine 的執行順序。然而,互斥鎖的操作比原子操作要慢,且可能導致 goroutine 阻塞。
所以,原子操作並不只是為了簡化互斥鎖的使用,而是為了提供一種更輕量級的同步機制。在選擇使用原子操作還是互斥鎖時,你需要根據你的具體需求和場景來決定。
請繼續介紹下 Golang 裡的 Context
Go 語言的
context
包提供了一種在 API 邊界之間傳遞請求範圍的值、取消訊號和超時資訊的方式。以下是一些使用
context
的範例:
- 使用 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,這會導致操作提前結束。
- 使用 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 就會被取消,這會導致操作提前結束。
- 使用 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
真實使用場景是怎樣的呢?
請進一步解釋下 context.WithValue 的使用場景,幫助我理解其和函數傳參的差異。
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 吧。
再介紹下 context.TODO() 和 context.Background() 吧。
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()
。
你提到了 context 的分級,請詳細介紹下相關知識點
在 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 洩露和記憶體模型這幾個話題明天再接著聊吧。(欲催更,關注公眾號「胡說雲原生」!)
相關文章