博客 / 詳情

返回

for range和鎖,終於悟了

訓練營內部有位學員問:"goroutine和Channel我都搞懂了,但為啥有的例子要加鎖,有的又不用?那個for range在Channel裏到底是啥作用?" 這問題問到了點上,今天咱們就掰開揉碎聊聊。

先説説他卡在哪

概括下來就三個迷糊點:

  1. 會用sync.WaitGroup,但不清楚啥時候必須用,啥時候只是"保險起見"
  2. 知道有緩衝無緩衝Channel的區別,但看到for range跟Channel混用就懵,更鬧不明白為啥求和還要加鎖
  3. for range在切片和Channel裏表現完全兩樣,這個語法糖到底甜在哪?

鎖到底啥時候用?兩個場景一看就懂

場景一:搶火車票——不加鎖就等着超賣

想象就100張票,1000個人同時開搶。核心代碼就這麼幾行:

ticketCount := 100  

// 1000個goroutine同時跑:
if ticketCount > 0 {
    ticketCount--  // 如果不加鎖,這裏會亂成一鍋粥
}

坑在哪:判斷庫存和減庫存是兩步,中間會被打斷。A看到還剩1張,剛準備扣減,B也看到了那1張,結果兩人都能買,票就變成-1張。鎖的作用就是把這兩步焊死,變成"原子操作,一次只能進一個goroutine。

場景二:並行求和——你以為沒事,其實丟了數據

sum := 0
for _, num := range numbers {
    go func(n int) {
        sum += n  // 這兒不加鎖,結果準不準全憑運氣
    }(num)
}

坑在哪:這不是扣減固定資源,但sum += n本質上是三步:讀sum → 做加法 → 寫回sum。兩個goroutine可能同時讀到100,都加了5,最後寫回105,但正確結果應該是110。這就是"數據競爭"——不是資源不夠,是更新被覆蓋了

更地道的寫法:用Channel幹掉鎖

Go的哲學是"別通過共享內存通信,用通信替代共享內存"。改造後的代碼:

func sumWithChannel(numbers []int) int {
    ch := make(chan int)
    
    for _, num := range numbers {
        go func(n int) {
            ch <- n  // 各自把結果扔進來,誰也別碰誰的
        }(num)
    }
    
    sum := 0
    for range numbers {  // 收夠len(numbers)次就完事
        sum += <-ch
    }
    return sum
}

關鍵點:每個goroutine只操心自己的數字,主goroutine統一彙總。for range在這裏不是遍歷切片,而是反覆從Channel裏取值,直到收到指定次數。數據零競爭,代碼還清爽。

鎖的底線:這三類情況逃不掉

必須用鎖的場景:

  • 讀寫同一個變量:goroutine A在寫,B要讀或寫,必須鎖
  • 檢查再行動:像搶車票,得先判斷條件再操作,兩步不能拆
  • 多步操作要打包:轉賬得"扣A的錢 + 加B的錢",要麼全做要麼全不做

可以不用鎖的替代方案:

  • 各算各的:用Channel傳結果,別碰共享變量
  • 數據分片:把數組切開,每個goroutine算一塊,最後合併
  • 只讀不寫:大家都只讀,沒人改,安全得很

完整代碼對比:一眼看懂差異

package main

import (
    "fmt"
    "sync"
)

func main() {
    numbers := []int{1,2,3,4,5,6,7,8,9,10}
    
    // 方案一:鎖 + WaitGroup(直觀但笨重)
    var mu sync.Mutex
    sum1, wg := 0, sync.WaitGroup{}
    for _, n := range numbers {
        wg.Add(1)
        go func(x int) {
            defer wg.Done()
            mu.Lock()    // 進去先上鎖
            sum1 += x
            mu.Unlock()  // 出來記得開
        }(n)
    }
    wg.Wait()
    fmt.Println("加鎖求和:", sum1)  // 55
    
    // 方案二:Channel(推薦)
    ch := make(chan int, len(numbers))
    for _, n := range numbers {
        go func(x int) {
            ch <- x  // 只負責發,不用搶
        }(n)
    }
    
    sum2 := 0
    for i := 0; i < len(numbers); i++ {
        sum2 += <-ch  // 主線程統收
    }
    close(ch)  // 好習慣,用完關通道
    fmt.Println("Channel求和:", sum2)  // 55
}

for range的兩種面孔

  • for _, v := range numbers:遍歷切片,v是元素值
  • for v := range ch:從通道一直讀,直到通道關閉且已讀空

總結:一個自問就夠了

寫併發代碼時,心裏默唸: "如果兩個goroutine同時跑這行代碼,會掐架嗎?"

  • 會?上鎖或改用Channel
  • 不會?大膽寫

記住Go的黃金法則:Share memory by communicating, don't communicate by sharing memory. 優先用Channel把數據流理清楚,實在理不清再考慮鎖。這樣寫出來的代碼,不僅安全,還自帶Go的味。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.