Go 并发编程 - 第 24 课:sync 包与互斥锁
1. 学习目标
- 理解为什么在并发编程中需要使用互斥锁(Mutex)
- 掌握
sync.Mutex和sync.RWMutex的使用方法 - 学会使用锁保护共享资源,避免数据竞争(Data Race)
- 了解互斥锁的常见使用场景和最佳实践
2. 核心概念
2.1 为什么需要互斥锁?
在并发编程中,当多个 Goroutine 同时读写同一个变量时,可能会出现数据竞争。就像多个人同时修改同一个文件,结果会变得混乱。
问题示例:
package main
import (
"fmt"
"sync"
)
func main() {
counter := 0
var wg sync.WaitGroup
// 启动1000个Goroutine,每个增加counter
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // 这里存在数据竞争!
}()
}
wg.Wait()
fmt.Println("最终结果:", counter) // 结果可能不是1000
}
2.2 互斥锁(Mutex)
互斥锁是一种简单的锁机制:同一时间只允许一个 Goroutine 访问被保护的代码块。
- Lock():获取锁。如果锁已被占用,则阻塞等待
- Unlock():释放锁
2.3 读写锁(RWMutex)
读写锁是更精细的锁控制:
- 读锁(RLock/RUnlock):多个 Goroutine 可以同时读取
- 写锁(Lock/Unlock):写操作时独占,读操作时共享
适用场景:读多写少的情况,如缓存系统、配置读取等。
3. 代码示例
3.1 使用 Mutex 保护计数器
package main
import (
"fmt"
"sync"
"time"
)
type SafeCounter struct {
mu sync.Mutex // 互斥锁
count int // 共享资源
}
// 增加计数器
func (c *SafeCounter) Increment() {
c.mu.Lock() // 获取锁
defer c.mu.Unlock() // 函数结束后释放锁(安全做法)
c.count++
}
// 获取当前值
func (c *SafeCounter) Get() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
func main() {
counter := SafeCounter{}
var wg sync.WaitGroup
// 启动1000个Goroutine并发增加计数器
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter.Increment()
}()
}
// 启动读取Goroutine
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(10 * time.Millisecond)
fmt.Printf("当前值: %d\n", counter.Get())
}()
}
wg.Wait()
fmt.Println("最终结果:", counter.Get()) // 总是1000
}
3.2 使用 RWMutex 实现缓存
package main
import (
"fmt"
"sync"
"time"
)
type Cache struct {
mu sync.RWMutex // 读写锁
data map[string]string
}
// 读取缓存
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock() // 获取读锁
defer c.mu.RUnlock() // 释放读锁
val, ok := c.data[key]
return val, ok
}
// 写入缓存
func (c *Cache) Set(key, value string) {
c.mu.Lock() // 获取写锁
defer c.mu.Unlock() // 释放写锁
c.data[key] = value
}
func main() {
cache := Cache{
data: make(map[string]string),
}
var wg sync.WaitGroup
// 并发写入
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
key := fmt.Sprintf("key%d", id)
value := fmt.Sprintf("value%d", id)
cache.Set(key, value)
fmt.Printf("写入: %s = %s\n", key, value)
time.Sleep(50 * time.Millisecond)
}(i)
}
// 并发读取
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
time.Sleep(100 * time.Millisecond) // 等待一些写入完成
key := fmt.Sprintf("key%d", id%5)
if val, ok := cache.Get(key); ok {
fmt.Printf("读取: %s = %s\n", key, val)
}
}(i)
}
wg.Wait()
}
4. 实践练习
练习1:线程安全的字符串构建器
创建一个 SafeStringBuilder 结构体,包含以下方法:
Append(s string):安全地追加字符串String() string:安全地获取当前字符串
要求:使用 sync.Mutex 保护所有操作。
预期输出:
最终结果: abcdefghijklmnopqrstuvwxyz(26个字母,顺序可能不同但完整)
练习2:并发缓存系统
实现一个简单的并发缓存,支持:
- 并发读写操作
- 设置过期时间
- 自动清理过期缓存
要求:
- 使用
sync.RWMutex保护缓存访问 - 使用 Goroutine 定期清理过期项
- 提供
Get,Set,Delete方法
提示:可以使用 time.AfterFunc 或定时器实现自动清理。
5. 常见错误
-
忘记解锁:导致其他 Goroutine 永远阻塞
func bad() { mutex.Lock() // 如果这里发生panic或提前return,锁不会释放 } // 正确做法:始终使用defer func good() { mutex.Lock() defer mutex.Unlock() } -
在持有锁时调用可能阻塞的函数:
// 危险!可能导致死锁 mutex.Lock() ch <- data // 如果channel满,会阻塞 mutex.Unlock() -
复制互斥锁:Mutex 不能被复制
type BadStruct struct { mu sync.Mutex data int } var s1 BadStruct s2 := s1 // 错误!复制了Mutex -
读写锁使用不当:
// 错误:写锁内读取 rwmu.Lock() // 写锁 defer rwmu.Unlock() if rwmu.RTryLock() { // 错误尝试获取读锁 // 这会导致死锁! }
6. 小结
关键要点:
- Mutex(互斥锁):保护临界区,同一时间只有一个 Goroutine 可以访问
- RWMutex(读写锁):读操作共享,写操作独占,适合读多写少场景
- 最佳实践:
- 使用
defer mutex.Unlock()确保锁释放 - 保持临界区尽可能小
- 避免在持有锁时调用可能阻塞的函数
- 使用
- 使用场景:
- 计数器、状态管理 → Mutex
- 缓存、配置读取 → RWMutex
何时使用哪种锁?
- Mutex:当需要保护简单的读写操作,或不确定访问模式时
- RWMutex:当明确读取操作远多于写入操作时
下一步预告:
下一课将学习 sync.WaitGroup 和并发控制模式,掌握如何协调多个 Goroutine 的执行顺序和完成情况。
练习编辑器
go
Loading...