📚 原创系列: “Go语言学习系列”
🔄 转载说明: 本文最初发布于"Gopher部落"微信公众号,经原作者授权转载。
🔗 关注原创: 欢迎扫描文末二维码,关注"Gopher部落"微信公众号获取第一手Go技术文章。
📑 Go语言学习系列导航
🚀 第三阶段:进阶篇本文是【Go语言学习系列】的第31篇,当前位于第三阶段(进阶篇)
- 并发编程(一):goroutine基础
- 并发编程(二):channel基础
- 并发编程(三):select语句
- 并发编程(四):sync包 👈 当前位置
- 并发编程(五):并发模式
- 并发编程(六):原子操作与内存模型
- 数据库编程(一):SQL接口
- 数据库编程(二):ORM技术
- Web开发(一):路由与中间件
- Web开发(二):模板与静态资源
- Web开发(三):API开发
- Web开发(四):认证与授权
- Web开发(五):WebSocket
- 微服务(一):基础概念
- 微服务(二):gRPC入门
- 日志与监控
- 第三阶段项目实战:微服务聊天应用
📖 文章导读
在本文中,您将了解:
- Go语言sync包提供的各种同步原语及其使用场景
- 互斥锁(Mutex)和读写锁(RWMutex)的正确使用方法
- WaitGroup、Once和Cond等工具的实际应用
- Map和Pool等并发安全的数据结构
- 使用sync包的最佳实践和常见陷阱
虽然Go推崇"通过通信来共享内存"的CSP模型,但在某些场景下,使用传统的同步原语控制共享资源访问更加直接高效。本文将帮助你掌握sync包中的工具,以便在适当的场景下选择正确的并发控制机制。
并发编程(四):sync包
在前面的文章中,我们深入探讨了Go语言的并发基础:goroutine、channel和select语句。这些机制主要基于CSP(通信顺序进程)模型,强调"通过通信来共享内存"。然而,在某些情况下,我们需要直接控制对共享资源的访问。这时,Go语言标准库中的sync包就派上用场了。
本文将详细介绍sync包中提供的同步原语,帮助你在适当的场景下选择正确的同步工具。
一、Mutex与RWMutex
互斥锁(Mutex)和读写锁(RWMutex)是sync包中最常用的同步原语,用于保护共享资源免受并发访问的干扰。
1.1 Mutex(互斥锁)
Mutex提供了一种互斥机制,确保同一时间只有一个goroutine可以访问共享资源。
基本用法:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var mutex sync.Mutex
counter := 0
// 并发更新counter
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mutex.Lock() // 加锁
defer mutex.Unlock() // 解锁
counter++
}()
}
wg.Wait()
fmt.Println("计数器最终值:", counter) // 输出: 计数器最终值: 1000
}
重要方法:
Lock()
:获取锁。如果锁已被其他goroutine获取,则阻塞直到锁可用Unlock()
:释放锁。应在与Lock()相同的goroutine中调用TryLock()
:(Go 1.18+)尝试获取锁,如果锁不可用则立即返回false而不阻塞
最佳实践:
- 总是使用
defer mutex.Unlock()
确保锁被释放 - 尽量减小临界区(加锁和解锁之间的代码)
- 避免在持有锁的情况下调用可能阻塞的操作
- 不要在goroutine A中锁定,然后在goroutine B中解锁
常见错误:
// 错误1: 忘记解锁
func increment(counter *int, mu *sync.Mutex) {
mu.Lock()
*counter++
// 忘记调用mu.Unlock(),导致死锁
}
// 错误2: 重复解锁
func decrement(counter *int, mu *sync.Mutex) {
mu.Lock()
*counter--
mu.Unlock()
mu.Unlock() // 错误: 再次解锁已经解锁的互斥量会导致panic
}
// 错误3: 复制互斥锁
func copyMutex() {
var mu sync.Mutex
mu.Lock()
muCopy := mu // 错误: 复制了互斥锁
muCopy.Unlock() // 未定义行为
}
1.2 RWMutex(读写锁)
RWMutex允许多个读操作并发执行,但写操作是互斥的。当有写锁时,所有的读操作都会被阻塞。
基本用法:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var rwMutex sync.RWMutex
data := make(map[string]string)
// 写入操作
go func() {
for i := 0; i < 10; i++ {
rwMutex.Lock() // 写锁
key := fmt.Sprintf("key%d", i)
data[key] = fmt.Sprintf("value%d", i)
time.Sleep(100 * time.Millisecond) // 模拟写入耗时
rwMutex.Unlock()
time.Sleep(200 * time.Millisecond) // 给读操作时间
}
}()
// 多个并发读取操作
var wg sync.WaitGroup
for r := 0; r < 5; r++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for i := 0; i < 10; i++ {
rwMutex.RLock() // 读锁
for k, v := range data {
fmt.Printf("读取者 %d: %s = %s\n", id, k, v)
}
rwMutex.RUnlock()
time.Sleep(150 * time.Millisecond)
}
}(r)
}
wg.Wait()
}
重要方法:
Lock()/Unlock()
:获取/释放写锁。与Mutex相同,写操作是互斥的RLock()/RUnlock()
:获取/释放读锁。多个goroutine可以同时持有读锁TryLock()/TryRLock()
:(Go 1.18+)尝试获取写锁/读锁,非阻塞
使用场景:
当共享资源的读操作远多于写操作时,RWMutex比Mutex更有效。例如:
- 配置信息:频繁读取,偶尔更新
- 缓存系统:大量读取,少量写入
- 统计数据:持续读取,定期更新
性能考虑:
- 如果读写比例接近1:1,普通的Mutex可能更高效
- RWMutex有额外的开销来跟踪读锁
- 写锁会阻塞所有的新读锁请求,可能导致"写饥饿"
二、WaitGroup
WaitGroup用于等待一组goroutine完成执行。它提供了一种简单的方式来协调多个并发操作的完成。
2.1 基本用法
WaitGroup的使用非常直观:
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // 工作完成时通知WaitGroup
fmt.Printf("Worker %d starting\n", id)
time.Sleep(time.Second) // 模拟工作
fmt.Printf("Worker %d done\n", id)
}
func main() {
var wg sync.WaitGroup
// 启动5个worker
for i := 1; i <= 5; i++ {
wg.Add(1) // 增加计数器
go worker(i, &wg)
}
// 等待所有worker完成
wg.Wait()
fmt.Println("All workers completed")
}
2.2 重要方法
WaitGroup只有三个方法:
Add(delta int)
:增加WaitGroup的计数器值Done()
:减少WaitGroup的计数器值,相当于Add(-1)
Wait()
:阻塞直到计数器变为0
2.3 最佳实践
-
正确设置计数器:在启动goroutine之前调用
Add()
// 正确 wg.Add(1) go func() { defer wg.Done() // 工作... }() // 错误 - 竞态条件 go func() { wg.Add(1) // 可能在主goroutine调用Wait()后才执行 defer wg.Done() // 工作... }()
-
总是通过指针传递WaitGroup:WaitGroup不应被复制
// 正确 func worker(wg *sync.WaitGroup) { defer wg.Done() // 工作... } // 错误 - 复制了WaitGroup func worker(wg sync.WaitGroup) { defer wg.Done() // 在复制的WaitGroup上操作,不会影响原始WaitGroup // 工作... }
-
使用defer语句确保Done()被调用:
func worker(wg *sync.WaitGroup) { defer wg.Done() // 即使发生panic也能确保Done()被调用 // 工作代码... // 如果这里发生panic,Done()仍会被调用 }
2.4 实际应用
WaitGroup常用于以下场景:
-
并行任务处理:等待多个并发任务全部完成
func processItems(items []Item) error { var wg sync.WaitGroup errCh := make(chan error, len(items)) // 收集错误 for _, item := range items { wg.Add(1) go func(i Item) { defer wg.Done() if err := processItem(i); err != nil { errCh <- err } }(item) } wg.Wait() // 等待所有处理完成 close(errCh) // 检查是否有错误 for err := range errCh { if err != nil { return err // 返回第一个遇到的错误 } } return nil }
-
分阶段同步:确保一个阶段完成后再开始下一个阶段
func processPipeline(data []Data) { var wg1, wg2 sync.WaitGroup intermediate := make([]Intermediate, len(data)) result := make([]Result, len(data)) // 阶段1: 处理所有数据 for i, d := range data { wg1.Add(1) go func(idx int, input Data) { defer wg1.Done() intermediate[idx] = stage1(input) }(i, d) } wg1.Wait() // 等待阶段1完成 // 阶段2: 处理中间结果 for i, d := range intermediate { wg2.Add(1) go func(idx int, input Intermediate) { defer wg2.Done() result[idx] = stage2(input) }(i, d) } wg2.Wait() // 等待阶段2完成 // 所有处理完成,使用result }
三、Once
Once用于确保某个函数只被执行一次,即使在多个goroutine中并发调用也是如此。它通常用于单例模式、延迟初始化或执行只需要一次的设置操作。
3.1 基本用法
package main
import (
"fmt"
"sync"
)
func main() {
var once sync.Once
done := make(chan bool)
// 尝试在多个goroutine中执行初始化
for i := 0; i < 10; i++ {
go func(id int) {
fmt.Printf("Goroutine %d trying to initialize\n", id)
once.Do(func() {
fmt.Printf("Initialization done by goroutine %d\n", id)
})
done <- true
}(i)
}
// 等待所有goroutine完成
for i := 0; i < 10; i++ {
<-done
}
}
在这个例子中,尽管有10个goroutine尝试执行初始化函数,但once.Do()
确保只有一个goroutine能够执行它。
3.2 特性与限制
- Once实例只能用于执行一个指定的函数一次
- 如果需要确保多个不同的函数都只执行一次,需要为每个函数创建单独的Once实例
- 一旦Do()方法完成调用,对同一个Once实例的后续Do()调用将不会执行提供的函数
- 如果在Do()调用的函数中发生panic,Once将认为操作已完成
- Once没有重置机制,一旦使用就不能重新使用
3.3 常见应用场景
- 单例模式:确保只创建一个实例
type Singleton struct {
// 单例结构字段
}
var (
instance *Singleton
once sync.Once
)
func GetInstance() *Singleton {
once.Do(func() {
instance = &Singleton{}
// 初始化代码...
})
return instance
}
- 延迟初始化:首次需要时才执行耗时初始化
type Resource struct {
data []byte
}
var (
resource *Resource
initOnce sync.Once
)
func GetResource() *Resource {
initOnce.Do(func() {
fmt.Println("Loading resource...")
// 耗时的资源加载
resource = &Resource{
data: loadLargeFile(),
}
})
return resource
}
- 只执行一次的操作:如配置加载、日志初始化等
var loggerInit sync.Once
func initLogger() {
loggerInit.Do(func() {
// 设置日志系统
fmt.Println("Initializing logging system...")
// 打开日志文件
// 设置日志级别
// 配置日志格式
})
}
四、Cond(条件变量)
Cond实现了一个条件变量,它是等待或宣布事件发生的goroutine的会合点。它允许goroutine等待某个条件成立,然后在条件成立时得到通知。
4.1 基本概念
条件变量总是与互斥锁关联,并通过锁来保护条件的检查和更新。Cond提供了三个主要方法:
Wait()
:释放关联的锁,等待通知,被唤醒后重新获取锁Signal()
:唤醒一个等待的goroutineBroadcast()
:唤醒所有等待的goroutine
4.2 基本用法
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var mu sync.Mutex
cond := sync.NewCond(&mu)
ready := false
// 消费者goroutine
for i := 0; i < 3; i++ {
go func(id int) {
mu.Lock()
defer mu.Unlock()
fmt.Printf("Consumer %d is waiting...\n", id)
for !ready { // 使用循环检查条件
cond.Wait() // 等待信号
}
fmt.Printf("Consumer %d received signal\n", id)
}(i)
}
// 给消费者一点时间启动
time.Sleep(time.Second)
// 生产者goroutine
go func() {
mu.Lock()
defer mu.Unlock()
fmt.Println("Producer is ready")
ready = true
fmt.Println("Producer broadcasts signal")
cond.Broadcast() // 通知所有等待的goroutine
}()
// 等待足够时间让所有goroutine完成
time.Sleep(3 * time.Second)
}
在这个例子中:
- 多个消费者goroutine等待
ready
条件变为true - 每个消费者获取锁,检查条件,如果不满足则调用
Wait()
等待 - 生产者将条件设置为true,然后调用
Broadcast()
通知所有等待的消费者 - 所有消费者被唤醒,重新获取锁,再次检查条件,然后继续执行
4.3 使用模式
- 始终在循环中使用Wait():这是避免虚假唤醒的重要模式
mu.Lock()
for !condition {
cond.Wait() // 可能被虚假唤醒,所以需要循环检查
}
// 条件为真,处理...
mu.Unlock()
- Signal vs Broadcast:选择适当的通知机制
Signal()
:唤醒单个等待者,适用于任务队列等场景,只需一个工作者处理Broadcast()
:唤醒所有等待者,适用于状态变化等场景,需要所有人都知道
- 确保锁的正确使用:在调用Signal或Broadcast时通常需要持有锁
mu.Lock()
// 改变条件
condition = true
cond.Signal() // 或 cond.Broadcast()
mu.Unlock()
4.4 实际应用
- 有界队列:生产者-消费者场景
type BoundedQueue struct {
queue []interface{}
size int
mu sync.Mutex
notEmpty *sync.Cond
notFull *sync.Cond
}
func NewBoundedQueue(size int) *BoundedQueue {
bq := &BoundedQueue{
queue: make([]interface{}, 0, size),
size: size,
}
bq.notEmpty = sync.NewCond(&bq.mu)
bq.notFull = sync.NewCond(&bq.mu)
return bq
}
func (bq *BoundedQueue) Put(v interface{}) {
bq.mu.Lock()
defer bq.mu.Unlock()
// 队列已满,等待有空间
for len(bq.queue) == bq.size {
bq.notFull.Wait()
}
// 添加元素
bq.queue = append(bq.queue, v)
// 通知可能等待的消费者
bq.notEmpty.Signal()
}
func (bq *BoundedQueue) Get() interface{} {
bq.mu.Lock()
defer bq.mu.Unlock()
// 队列为空,等待有元素
for len(bq.queue) == 0 {
bq.notEmpty.Wait()
}
// 获取元素
v := bq.queue[0]
bq.queue = bq.queue[1:]
// 通知可能等待的生产者
bq.notFull.Signal()
return v
}
- 资源池:等待可用资源
type Pool struct {
mu sync.Mutex
cond *sync.Cond
closed bool
resources []interface{}
}
func NewPool(resources []interface{}) *Pool {
p := &Pool{
resources: resources,
}
p.cond = sync.NewCond(&p.mu)
return p
}
func (p *Pool) Acquire() (interface{}, error) {
p.mu.Lock()
defer p.mu.Unlock()
for len(p.resources) == 0 && !p.closed {
p.cond.Wait()
}
if p.closed {
return nil, errors.New("pool is closed")
}
resource := p.resources[0]
p.resources = p.resources[1:]
return resource, nil
}
func (p *Pool) Release(resource interface{}) {
p.mu.Lock()
defer p.mu.Unlock()
if p.closed {
return
}
p.resources = append(p.resources, resource)
p.cond.Signal()
}
func (p *Pool) Close() {
p.mu.Lock()
defer p.mu.Unlock()
p.closed = true
p.cond.Broadcast() // 通知所有等待的goroutine
}
五、Map
sync.Map是Go 1.9引入的一种并发安全的映射实现,专为高并发场景设计,不需要额外的锁就可以安全地被多个goroutine同时访问。
5.1 与传统map+mutex的区别
传统的map不是并发安全的,需要使用mutex保护:
var (
mu sync.Mutex
counters = make(map[string]int)
)
func Increment(key string) {
mu.Lock()
defer mu.Unlock()
counters[key]++
}
func Get(key string) int {
mu.Lock()
defer mu.Unlock()
return counters[key]
}
而sync.Map则是专门为并发设计的:
var counters sync.Map
func Increment(key string) {
value, _ := counters.LoadOrStore(key, 0)
counters.Store(key, value.(int) + 1)
}
func Get(key string) int {
value, ok := counters.Load(key)
if !ok {
return 0
}
return value.(int)
}
5.2 主要方法
sync.Map提供以下关键方法:
Store(key, value)
:存储键值对Load(key) (value, ok)
:加载给定键的值Delete(key)
:删除给定键LoadOrStore(key, value) (actual, loaded)
:如果键存在,返回存在的值;否则,存储提供的值Range(f func(key, value) bool)
:遍历映射中的所有键值对LoadAndDelete(key) (value, loaded)
:加载并删除键(Go 1.15+)
5.3 适用场景
sync.Map适用于以下场景:
- 读多写少:当读操作远多于写操作时性能最佳
- 键的生命周期:当键被写入一次后很长时间不会被删除
- 使用情景:常用于缓存、配置存储等场景
5.4 使用示例
package main
import (
"fmt"
"sync"
)
func main() {
var m sync.Map
// 存储键值对
m.Store("name", "John")
m.Store("age", 30)
m.Store("city", "New York")
// 读取
name, ok := m.Load("name")
if ok {
fmt.Println("Name:", name)
}
// 加载或存储
email, loaded := m.LoadOrStore("email", "john@example.com")
if !loaded {
fmt.Println("Email set to:", email)
}
// 遍历所有键值对
m.Range(func(key, value interface{}) bool {
fmt.Printf("%v: %v\n", key, value)
return true // 返回false会停止遍历
})
// 加载并删除
if value, loaded := m.LoadAndDelete("age"); loaded {
fmt.Printf("Deleted age: %v\n", value)
}
// 删除
m.Delete("city")
// 验证删除
_, ok = m.Load("city")
fmt.Println("City exists:", ok)
}
六、Pool
sync.Pool提供了一个可以重复使用临时对象的池,有助于减少垃圾回收压力,特别是在高并发环境下。
6.1 基本概念
Pool的主要特性:
- 线程安全:可以在多个goroutine之间共享
- 无容量限制:可以存储任意数量的对象
- 临时存储:池中的对象可能在任何时候被自动移除(特别是在GC发生时)
- 高效复用:避免频繁分配和回收对象,减轻GC压力
6.2 核心方法
Pool提供了两个主要方法:
Get() interface{}
:从池中获取对象。如果池为空,则调用New函数创建一个新对象Put(x interface{})
:将对象放回池中以供后续重用
6.3 使用示例
package main
import (
"bytes"
"fmt"
"sync"
)
func main() {
// 创建一个池,用于复用bytes.Buffer
var bufferPool = sync.Pool{
New: func() interface{} {
fmt.Println("Creating a new buffer")
return new(bytes.Buffer)
},
}
// 获取一个Buffer
buffer1 := bufferPool.Get().(*bytes.Buffer)
buffer1.WriteString("Hello")
fmt.Println("Buffer1:", buffer1.String())
// 清空并放回池中
buffer1.Reset()
bufferPool.Put(buffer1)
// 获取一个Buffer(可能是刚才放回的那个)
buffer2 := bufferPool.Get().(*bytes.Buffer)
buffer2.WriteString("World")
fmt.Println("Buffer2:", buffer2.String())
// 清空并放回池中
buffer2.Reset()
bufferPool.Put(buffer2)
// 同时获取多个Buffer
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
// 获取Buffer
buf := bufferPool.Get().(*bytes.Buffer)
// 使用Buffer
buf.WriteString(fmt.Sprintf("Goroutine %d", id))
fmt.Printf("Goroutine %d: %s\n", id, buf.String())
// 清空并放回
buf.Reset()
bufferPool.Put(buf)
}(i)
}
wg.Wait()
}
6.4 最佳实践
- 清除对象状态:在将对象放回池之前,确保清除其状态(例如使用Reset()方法)
buffer := bufferPool.Get().(*bytes.Buffer)
// 使用buffer...
buffer.Reset() // 清除内容
bufferPool.Put(buffer)
-
避免存储带有复杂状态的对象:池中的对象可能被GC回收,不适合保存带有状态的资源
-
类型断言:在获取对象时使用正确的类型断言
obj := pool.Get()
buf, ok := obj.(*bytes.Buffer)
if !ok {
// 处理类型不匹配
}
- 适合的使用场景:
- 高频创建的临时对象
- 大小相似的对象
- 创建成本较高的对象
- 生命周期短的对象
6.5 实际应用
- JSON编码/解码:复用编码器/解码器
var encoderPool = sync.Pool{
New: func() interface{} {
return json.NewEncoder(io.Discard)
},
}
func encodeJSON(w io.Writer, v interface{}) error {
encoder := encoderPool.Get().(*json.Encoder)
encoder.SetWriter(w)
err := encoder.Encode(v)
encoder.SetWriter(io.Discard) // 重置
encoderPool.Put(encoder)
return err
}
- HTTP请求处理:复用请求/响应缓冲区
var bufferPool = sync.Pool{
New: func() interface{} {
return make([]byte, 1024)
},
}
func handleRequest(w http.ResponseWriter, r *http.Request) {
buf := bufferPool.Get().([]byte)
defer bufferPool.Put(buf)
n, err := r.Body.Read(buf)
if err != nil && err != io.EOF {
http.Error(w, "Error reading body", http.StatusBadRequest)
return
}
// 处理请求...
}
六、sync包与channel的选择
在Go并发编程中,我们经常面临选择使用sync包中的同步原语还是channel的问题。这两种方法各有优缺点,选择哪种取决于具体场景。
6.1 选择依据
-
数据流向:
- 如果需要传递数据,使用channel
- 如果只需要同步或保护共享资源,使用sync原语
-
耦合度:
- channel将生产者和消费者解耦
- sync原语使goroutine通过共享内存直接耦合
-
性能考虑:
- 对于简单的同步操作,sync原语通常更高效
- channel有额外的开销,但提供更强的抽象
-
代码清晰度:
- 哪种方式让你的代码更易理解?
- 哪种方式更符合你的程序逻辑?
-
比较:
特性 | Channel | Sync原语 |
---|---|---|
数据传递 | 是 | 否 |
共享内存保护 | 间接 | 直接 |
耦合度 | 低 | 高 |
适用场景 | 消息传递、事件通知 | 共享数据访问控制 |
复杂性 | 较高 | 较低 |
调试难度 | 较难 | 相对简单 |
👨💻 关于作者与Gopher部落
"Gopher部落"专注于Go语言技术分享,提供从入门到精通的完整学习路线。
🌟 为什么关注我们?
- 系统化学习路径:本系列56篇文章循序渐进,带你完整掌握Go开发
- 实战驱动教学:理论结合实践,每篇文章都有可操作的代码示例
- 持续更新内容:定期分享最新Go生态技术动态与大厂实践经验
- 专业技术社区:加入我们的技术交流群,与众多Go开发者共同成长
📱 关注方式
- 微信公众号:搜索 “Gopher部落” 或 “GopherTribe”
- 优快云专栏:点击页面右上角"关注"按钮
💡 读者福利
关注公众号回复 “Go学习” 即可获取:
- 完整Go学习路线图
- Go面试题大全PDF
- Go项目实战源码
- 定制学习计划指导
期待与您在Go语言的学习旅程中共同成长!