
训练营里有位学员提出了一个很典型的问题:
“goroutine和Channel我都理解了,但为什么有些例子要加锁,有些又不用?for range在Channel里到底起什么作用?”
这个问题问到了Go并发编程的核心,今天我们就来彻底讲清楚。
学员的困惑主要集中在两点:
假设有100张车票,1000个用户同时抢购。核心逻辑很简单:
ticketCount := 100
// 1000个goroutine同时执行:
if ticketCount > 0 {
ticketCount-- // 不加锁会导致数据竞争
}
问题所在:检查票数和减少票数是两个独立操作,中间可能被其他goroutine打断。goroutine A看到还剩1张票,正准备扣减时,goroutine B也看到了这1张票,结果两人都购买成功,票数变为-1。锁的作用就是确保这两个操作成为一个原子性的整体,同一时间只允许一个goroutine执行。
sum := 0
for _, num := range numbers {
go func(n int) {
sum += n // 不加锁,结果不可预测
}(num)
}
问题所在:虽然这不是资源扣减,但sum += n实际上包含三个步骤:读取sum值 → 执行加法 → 写回结果。两个goroutine可能同时读取到100,各自加5后都写回105,而正确结果应该是110。这就是数据竞争——不是资源不足,而是更新操作被覆盖了。
Go语言的哲学是“不要通过共享内存来通信,而要通过通信来共享内存”。我们可以用Channel重构上面的求和示例:
func sumWithChannel(numbers []int) int {
ch := make(chanint)
var wg sync.WaitGroup
// 启动所有计算goroutine
for _, num := range numbers {
wg.Add(1)
gofunc(n int) {
defer wg.Done()
ch <- n // 每个goroutine只发送自己的结果
}(num)
}
// 启动一个goroutine在完成后关闭channel
gofunc() {
wg.Wait()
close(ch) // 所有发送完成后安全关闭通道
}()
// 主goroutine负责收集结果
sum := 0
for n := range ch { // 自动循环直到channel关闭
sum += n
}
return sum
}
关键改进:
for n := range ch自动读取直到channel关闭,代码更简洁设计优势:每个goroutine只处理自己的数据,通过Channel将结果发送到统一收集点,完全避免了数据竞争,代码也更清晰。
尽管Channel是推荐的方式,但某些场景下锁仍然是必要选择:
package main
import (
"fmt"
"sync"
)
func main() {
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
// 方案一:使用锁(传统共享内存方式)
var mu sync.Mutex
sum1 := 0
var wg1 sync.WaitGroup
for _, n := range numbers {
wg1.Add(1)
gofunc(x int) {
defer wg1.Done()
mu.Lock()
sum1 += x
mu.Unlock()
}(n)
}
wg1.Wait()
fmt.Println("加锁求和:", sum1) // 输出: 55
// 方案二:使用Channel(推荐方式)
sum2 := sumWithChannel(numbers)
fmt.Println("Channel求和:", sum2) // 输出: 55
// 方案三:Worker Pool模式(处理大量数据时更高效)
sum3 := sumWithWorkerPool(numbers, 3) // 使用3个worker
fmt.Println("Worker Pool求和:", sum3) // 输出: 55
}
func sumWithWorkerPool(numbers []int, workerCount int) int {
tasks := make(chanint, len(numbers))
results := make(chanint, len(numbers))
var wg sync.WaitGroup
// 启动worker
for i := 0; i < workerCount; i++ {
wg.Add(1)
gofunc() {
defer wg.Done()
for n := range tasks {
results <- n // 实际场景中这里可能有更复杂的计算
}
}()
}
// 发送任务
for _, n := range numbers {
tasks <- n
}
close(tasks)
// 等待所有worker完成
gofunc() {
wg.Wait()
close(results)
}()
// 收集结果
sum := 0
for n := range results {
sum += n
}
return sum
}
for range在Go中有两种完全不同的用法:
// 1. 遍历切片/数组/映射
for i, v := range slice {
// i是索引,v是值副本
// 循环次数在开始时确定
}
// 2. 从Channel接收值
for value := range ch {
// 不断从ch接收值,直到channel被关闭
// 如果ch未被关闭,这里会永久阻塞
}
重要区别:遍历Channel时,循环不会预先知道次数,而是持续接收直到发送方关闭channel。忘记关闭channel是常见的错误来源。
编写并发代码时,可以问自己以下几个问题来做出选择:
当你写并发代码时,可以遵循这个流程:
是否需要共享状态?
├── 否 → 使用Channel传递数据
└── 是 → 是否可以拆分为独立任务?
├── 是 → 使用Channel + 结果合并
└── 否 → 使用锁保护共享状态
记住Go并发设计的黄金法则:通过通信共享内存,而不是通过共享内存进行通信。优先使用Channel来组织数据流,只有在确实需要时才使用锁。这样写出的代码不仅更安全,也更具有Go语言的特色。
最后自检:写完并发代码后,问问自己:"如果两个goroutine同时执行这段代码,它们会冲突吗?"