🔖 Go 语言学习教程 · 第 16 篇
📌 本系列基于 Go 1.23 编写,代码兼容 Go 1.21+
全文约 4500 字 | 阅读约 15 分钟
欢迎来到并发编程下篇 👋
上一篇学习了 goroutine 和 GMP,知道了任务如何被调度。本篇进入 Go 并发编程最常用的工具箱:channel、select、互斥锁以及 sync 包中的常见原语。
一句话概括:channel 适合传递数据和协调流程,锁适合保护共享状态。选择哪一种,取决于数据的所有权和代码的通信方式。
✅ 上一篇练习题答案
以下是上一篇练习题的参考答案,欢迎在评论区分享你的解法~
1. 并发计算平方
go
var wg sync.WaitGroup
for i := 1; i <= 100; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Println(n * n)
}(i)
}
wg.Wait()
如果需要汇总结果,还要为结果切片增加锁,或者让 goroutine 把结果发送到 channel,不能让多个任务无保护地同时修改共享切片。
2. 最多四个任务的 worker pool
可以创建 4 个 worker 从任务 channel 中循环读取,生产者关闭 channel 后 worker 自然退出。关键是明确谁负责关闭 channel,并等待所有 worker 完成。
3. GOMAXPROCS 实验
CPU 密集任务通常会随着并行处理器增加而缩短耗时,但不会无限提升;任务拆分、缓存、调度和内存带宽都会成为新的限制。
4. 设计退出信号
可以增加 done channel:
go
select {
case <-done:
return
default:
// 继续工作
}
退出信号必须能被所有阻塞点观察到,否则 goroutine 仍可能泄漏。
🧐 一、无缓冲 channel
channel 用于在 goroutine 之间传递值:
go
messages := make(chan string)
go func() {
messages <- "任务完成"
}()
message := <-messages
fmt.Println(message)
无缓冲 channel 的发送和接收必须同时准备好,因此它天然提供同步点:
text
发送方 ──等待──→ 接收方准备好
接收方 ──等待──→ 发送方发送
如果只有发送没有接收,发送方会阻塞;如果只有接收没有发送,接收方会阻塞。调试死锁时,先检查每个发送和接收是否都有对应的对端。
📖 二、有缓冲 channel
有缓冲 channel 可以暂存指定数量的值:
go
jobs := make(chan int, 3)
jobs <- 1
jobs <- 2
jobs <- 3
// 缓冲区已满,下一次发送会阻塞
缓冲区不是"越大越好"。它可以吸收短时峰值,但也可能掩盖消费者变慢的问题。缓冲大小应该结合任务耗时、内存占用和流量特征,通过测试验证。
使用 len 和 cap 可以观察当前缓冲状态,但它们只适合监控或调试,不应拿来做并发正确性判断:
go
fmt.Println(len(jobs), cap(jobs))
💡 三、关闭 channel 与 range
发送方通常负责关闭 channel:
go
jobs := make(chan int)
go func() {
defer close(jobs)
for i := 1; i <= 3; i++ {
jobs <- i
}
}()
for job := range jobs {
fmt.Println("处理任务:", job)
}
关闭后仍然可以接收缓冲区中的值;缓冲区耗尽后继续接收会得到元素类型的零值。若需要区分"零值"和"已关闭",使用第二个返回值:
go
value, ok := <-jobs
if !ok {
fmt.Println("channel 已关闭")
}
不要关闭一个仍由多个发送者使用的 channel,也不要让接收者擅自关闭它,否则容易发生 send on closed channel panic。重复关闭同样会 panic。
🧩 四、select 多路复用
select 可以同时等待多个 channel 操作:
go
select {
case value := <-results:
fmt.Println("收到结果:", value)
case <-time.After(time.Second):
fmt.Println("等待超时")
}
如果多个 case 同时就绪,运行时会伪随机选择其中一个。default 会让 select 立即返回:
go
select {
case value := <-results:
fmt.Println(value)
default:
fmt.Println("当前没有结果")
}
无条件地在循环中使用 default 可能形成忙等。可以配合定时器或合理的阻塞 case:
go
timer := time.NewTimer(2 * time.Second)
defer timer.Stop()
select {
case result := <-results:
fmt.Println(result)
case <-timer.C:
fmt.Println("超时")
}
🛠️ 五、使用 Mutex 保护共享状态
当多个 goroutine 访问同一份可变数据时,需要同步:
go
type Counter struct {
mu sync.Mutex
value int
}
func (c *Counter) Add(delta int) {
c.mu.Lock()
defer c.mu.Unlock()
c.value += delta
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
锁的临界区应该尽量短,且必须保证所有访问路径使用同一把锁。不要复制包含 Mutex 的结构体;通常让方法使用指针接收者。
RWMutex
读多写少时可以使用读写锁:
go
type ConfigStore struct {
mu sync.RWMutex
config map[string]string
}
func (s *ConfigStore) Get(key string) (string, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
value, ok := s.config[key]
return value, ok
}
func (s *ConfigStore) Set(key, value string) {
s.mu.Lock()
defer s.mu.Unlock()
s.config[key] = value
}
读写锁并不一定比互斥锁快。只有在读操作确实占多数、临界区足够长且竞争明显时,才值得用基准测试验证。
📖 六、sync.Once、sync.Pool 与 sync.Map
sync.Once
保证初始化逻辑只执行一次:
go
type Client struct{}
var (
once sync.Once
client *Client
)
func getClient() *Client {
once.Do(func() {
client = &Client{}
})
return client
}
sync.Pool
用于临时对象复用,适合降低频繁分配带来的压力:
go
var bufferPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
buffer := bufferPool.Get().(*bytes.Buffer)
buffer.Reset()
defer bufferPool.Put(buffer)
Pool 中的对象可能在 GC 时被清理,不能把它当成缓存,也不能依赖对象一定还在。
sync.Map
sync.Map 适合读多写少、key 集合较稳定等特定场景。普通业务 Map 通常使用 map + Mutex 更容易理解和控制,不要因为"并发"两个字就默认选择 sync.Map。
⚠️ 七、常见死锁与竞态
最常见的死锁包括:
- 无缓冲 channel 没有接收方;
- channel 关闭时机错误;
- 多把锁加锁顺序不一致;
- 在持锁期间等待另一个需要同一把锁的操作;
- goroutine 等待永远不会发送的信号。
可以使用下面的习惯降低风险:
- 明确 channel 的所有者和关闭者;
- 为阻塞操作设计超时或取消路径;
- 统一多把锁的加锁顺序;
- 用
go test -race检测数据竞争; - 让并发单元有清晰的生命周期。
📝 八、本篇小结
| 要点 | 总结 |
|---|---|
| 无缓冲 channel | 发送与接收同步握手 |
| 有缓冲 channel | 暂存有限数据,吸收短时峰值 |
| 关闭 channel | 通常由发送方负责 |
select |
等待多个通信操作和超时 |
Mutex |
保护共享可变状态 |
RWMutex |
适合读多写少,但要实测 |
Once/Pool/Map |
分别解决一次初始化、对象复用和特定并发 Map |
💬 一句话总结:
用 channel 传递所有权和协调流程,用锁保护共享状态;每个阻塞点都要有退出方案,才能写出可维护的并发代码。
✏️ 九、练习题
- 🖥️ 动手实践:实现一个生产者和三个消费者组成的 worker pool,要求生产者关闭任务 channel。
- 🔧 超时控制:给任务处理增加 2 秒超时,超时后返回错误。
- 🤔 思考题:为什么不建议由接收方关闭一个有多个发送者的 channel?
- 📚 竞态检测 :写一个故意存在数据竞争的计数器,用
go test -race找出问题并修复。
💡 练习题答案会在下一篇中公布,欢迎在评论区分享你的答案~
关注我,一起学编程
如果本文对你有帮助,欢迎 点赞、在看、转发、关注。