Golang 并发控制
一、临界资源
临界资源是指并发环境中,多个进程、线程或协程共享的资源。在并发编程中,若对临界资源处理不当,往往会导致数据不一致的问题,即临界资源的安全问题。
二、CSP 并发模型
CSP(Communicating Sequential Processes)并发模型是 Go 语言的核心并发设计哲学,由 Tony Hoare 于 1978 年提出,强调“通过通信共享内存,而非共享内存来通信”。这一模型通过 goroutine 和 channel 实现,显著简化了并发编程的复杂度。
1. 核心思想
(1)通信优于共享内存
- 传统并发:线程通过锁保护共享内存,易引发死锁、竞态条件等问题。
- Go 的 CSP 模型:goroutine 之间通过 channel 传递数据,每个协程独立处理自身状态,避免直接共享内存,从而消除锁的需求。
2. 关键组件
ch := make(chan int, 3) // 缓冲大小为 3 的整型通道
go func() { ch <- 42 }() // 异步发送
value := <-ch // 接收数据
(1)goroutine
是一种轻量线程,它不是操作系统线程,而是将一个操作系统线程分段使用,通过调度器实现协作式调度。
(2)channel
类似 Unix 的 Pipe,用于协程之间通讯和同步。
3. Go 对 CSP 的实现
实际上,Go 并没有完全实现 CSP 模型的所有理论,仅仅是借用了 process(在 Go 上表现为 goroutine)和 channel 这两个概念。
(1)goroutine 的调度机制(GMP 模型)
(2)channel 的同步与通信
- 同步阻塞:无缓冲通道的发送、接收需双方就绪,天然实现同步。
- Select 多路复用:监听多个 channel,响应最先就绪的操作,支持超时控制。
4. CSP 与传统共享内存模型对比
对比

三、并发控制场景
1. channel
主协程启动 N 个子协程,主协程等待所有子协程退出后再继续后续流程。
(1)优点
实现简单。
(2)缺点
- 当需要大量创建协程时,需要相同数量的 channel。
- 对子协程派生出来的协程不方便控制。
(3)示例代码
package main
import (
"fmt"
"time"
)
// channel 控制子协程
func process(ch chan int) {
time.Sleep(time.Second)
ch <- 1
}
func main() {
chs := make([]chan int, 10)
for i := 0; i < 10; i++ {
chs[i] = make(chan int)
go process(chs[i])
}
for i, ch := range chs {
<-ch
fmt.Printf("routine %d quit\n", i)
}
}
2. waitgroup
等待一组协程结束。
(1)WaitGroup 实现协程间的同步
- 声明一个 WaitGroup:var wg sync.WaitGroup
- 相关函数:
- func (wg *WaitGroup) Add(delta int):队列计数 +1
- func (wg *WaitGroup) Done():队列计数 -1
- func (wg *WaitGroup) Go(f func()):协程开始时队列计数 +1,协程结束时自动 -1(Go 1.25 新增,用来简化 Add() 和 Done())
- func (wg *WaitGroup) Wait():等待队列中所有协程完成
(2)优点
实现简单。
(3)缺点
- 计数器不匹配会导致死锁(Add 和 Done 的调用次数必须一致)。
- 无法处理超时和错误传递。
(4)示例代码
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("goroutine 1")
}()
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("goroutine 2")
}()
// go 1.25 以后的简便写法 func (wg *WaitGroup) Go(f func())
//wg.Go(func() {
// fmt.Println("goroutine 3")
//})
wg.Wait()
fmt.Println("all goroutine finish")
}
3. context
对于派生 goroutine 有很强的控制力,可以控制多级派生 goroutine。
(1)示例代码
package main
import (
"context"
"fmt"
"time"
)
func HandelRequest(ctx context.Context) {
go WriteRedis(ctx)
go WriteDatabase(ctx)
for {
select {
case <-ctx.Done():
fmt.Println("Done.")
return
default:
fmt.Println("HandelRequest running...")
time.Sleep(2 * time.Second)
}
}
}
func WriteRedis(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("WriteRedis Done.")
return
default:
fmt.Println("WriteRedis running...")
time.Sleep(2 * time.Second)
}
}
}
func WriteDatabase(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("WriteDatabase Done.")
return
default:
fmt.Println("WriteDatabase running...")
time.Sleep(2 * time.Second)
}
}
}
func main() {
ctx, _ := context.WithTimeout(context.Background(), 5*time.Second)
go HandelRequest(ctx)
fmt.Println("main Done.")
}
4. 锁
(1)两类锁
1)互斥锁(sync.Mutex)
var lock sync.Mutex
lock.Lock()
lock.Unlock()
- 特性:独占。
- 适用场景:写操作频繁,或临界区较长的场景(如全局配置更新)。
2)读写锁(sync.RWMutex)
- 特性:读时共享,写时独占。
- 适用场景:读多写少(如缓存系统)。
(2)自旋锁(spin lock)
1)核心思想
自旋锁的核心思想是“忙等待”:当一个线程尝试获取锁时,如果锁已被其他线程持有,该线程会持续循环检查锁的状态,直到成功获取锁为止。
2)适用场景
- 锁持有时间非常短(纳秒或微秒级别)。
- 锁竞争不激烈,通常只有少数几个 goroutine 需要访问共享资源。
- 希望避免上下文切换开销的场景。
- 对延迟要求严格的实时系统。
(3)模式
1)正常模式(Normal)
- 默认模式。
- 锁竞争流程:新请求锁的协程,先尝试自旋(4 次),通过原子操作直接获取锁;自旋失败后,进入等待队列休眠。
2)饥饿模式(Starving)
在饥饿模式下,不会启动自旋过程,一旦有协程释放了锁,那么一定会唤醒协程,被唤醒的协程将成功获取锁。
- 设计目的:解决长期等待的公平问题,避免尾端延迟。
- 锁分配机制:新请求的 goroutine 直接加入队列尾部,禁止自旋或竞争;锁释放时,所有权直接转移给队列头部。
3)切换逻辑
- 正常模式切换到饥饿模式:队列中协程等待超时(1ms)。
- 饥饿模式切换到正常模式:当前持有者等待时间 < 1ms(队列压力缓解),或队列为空(无等待者)。