16-并发编程(下):channel、select 与同步原语

🔖 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 等待永远不会发送的信号。

可以使用下面的习惯降低风险:

  1. 明确 channel 的所有者和关闭者;
  2. 为阻塞操作设计超时或取消路径;
  3. 统一多把锁的加锁顺序;
  4. 用 go test -race 检测数据竞争;
  5. 让并发单元有清晰的生命周期。

📝 八、本篇小结

要点 总结
无缓冲 channel 发送与接收同步握手
有缓冲 channel 暂存有限数据,吸收短时峰值
关闭 channel 通常由发送方负责
select 等待多个通信操作和超时
Mutex 保护共享可变状态
RWMutex 适合读多写少,但要实测
Once/Pool/Map 分别解决一次初始化、对象复用和特定并发 Map

💬 一句话总结:

用 channel 传递所有权和协调流程,用锁保护共享状态;每个阻塞点都要有退出方案,才能写出可维护的并发代码。


✏️ 九、练习题

  1. 🖥️ 动手实践:实现一个生产者和三个消费者组成的 worker pool,要求生产者关闭任务 channel。
  2. 🔧 超时控制:给任务处理增加 2 秒超时,超时后返回错误。
  3. 🤔 思考题:为什么不建议由接收方关闭一个有多个发送者的 channel?
  4. 📚 竞态检测 :写一个故意存在数据竞争的计数器,用 go test -race 找出问题并修复。

💡 练习题答案会在下一篇中公布,欢迎在评论区分享你的答案~


关注我,一起学编程

如果本文对你有帮助,欢迎 点赞、在看、转发、关注。

相关推荐
猪猪拆迁队20 小时前
跨电脑复制共享-局域网直连踩坑
前端·后端·go
tachibana221 小时前
Go 语言的 Map 是否是并发安全的?
开发语言·数据结构·后端·golang·go·语言
tachibana221 小时前
Go 语言 Map 的扩容过程是怎样的?
开发语言·后端·golang·go·语言
共饮一杯无1 天前
LiveKit本地录制落盘路径排查与修复实践
java·go·livekit
探索云原生1 天前
KServe 工作流程:从 InferenceService 到 vLLM 服务
docker·ai·云原生·kubernetes·go
人人都是软件工程师1 天前
13-错误处理:把失败变成可读、可判断的结果
go
林壮Allen2 天前
用 Go 从零实现多 Agent 协作框架:两个让我踩坑的设计问题
go·ai编程
代码方舟2 天前
Go数据工程:利用天远柠檬查出险优化二手车批发拍卖车源出险核查合规体验
人工智能·go
不是万能的豆学长2 天前
排查Go服务40天后的内存泄漏问题
开发语言·后端·golang·go