持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第3天,点击查看活动详情
🎐 放在前面说的话
大家好,我是北 👧🏻
本科在读,此为日常捣鼓.
如有不对,请多指教,也欢迎大家来跟我讨论鸭 👏👏👏
今天是我们「Go并发」系列的第三篇:「sync包并发同步原语(1)」;
Let’s get it!
sync包
- 再并发编程中同步原语也就是我们日常说的锁
- 保证多线程或多
goroutine在访问同一内存时,不出现混乱问题 - Go语言提供的
sync包提供了常见的并发编程同步原语sync.Mutexsync.RWMutexsync.WaitGroupsync.Mapsync.Poolsync.Oncesync.Cond
一、sync.Mutex
在Go并发任务中,容易出现多个goroutine同时操作一个资源,这就会产生竞态问题。这时互斥锁就可以体现出它真正的作用了
1.sync.Mutex概念
Mutex 也称为互斥锁,互斥锁就是互相排斥的锁,它可以用作保护临界区的共享资源,保证同一时刻只有一个 goroutine 操作临界区中的共享资源。
- 控制共享资源访问方法
- 保证同一时间有且仅有一个
goroutine操作资源(临界区),其他goroutine只能等待锁,直到前面的互斥锁释放,下一个goroutine才能获取锁去操作资源,往后同理 - 多线程,随机唤醒一个
2. sync.Mutex有以下方法
| 方法 | 功能 |
|---|---|
| Lock() | 写锁定 |
| Unlock() | 写解锁 |
- Mutex 的
Lock方法和Unlock方法要成对使用,不要忘记将锁定的互斥锁解锁,一般做法是使用 defer。
3.sync.Mutex 栗子
不加锁栗子
func main() {
var count = 0
var wg sync.WaitGroup
// 开启十个协程
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// 一万叠加
for j := 0; j < 10000; j++ {
count++
}
}()
}
wg.Wait()
fmt.Println(count)
}
打印:73662
正确打印:100000
原因:多个goroutine在同一片资源出现竟态问题,叠加错误(有时候可能能够正常执行,打印正确,但并不代表后面不会出现错误)
加锁优化栗子
func main() {
var count = 0
var wg sync.WaitGroup
var m sync.Mutex
// 开启十个协程
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// 一万叠加
for j := 0; j < 10000; j++ {
m.Lock()
count++
m.Unlock()
}
}()
}
wg.Wait()
fmt.Println(count)
}
打印:100000
4. sync.Mutex和sync.RWMutex比较
- RWMutex是基于Mutex的,在Mutex的基础之上增加了读、写的信号量,并使用了类似引用计数的读锁数量,RWMutex 将对临界区的共享资源的读写操作做了区分,RWMutex 可以针对读写操作做不同级别的锁保护。
- Mutex在大并发环境下,容易造成锁等待,对性能的影响较大。若某个读/写操作协程加了锁,其他协程就没必要处于等待状态了,也应该可以并发地访问共享变量,这时候,应使用RWMutex,让读/写操作并行,提高性能
二、sync.RWMutex
适用于并发读读,不能进行并发读写
1. sync.RWMutex概念
读锁与读锁兼容,读锁与写锁互斥,写锁与写锁互斥,只有在锁释放后才可以继续申请互斥的锁
- 可以同时申请多个读锁
- 有读锁时申请写锁将阻塞,有写锁时申请读锁将阻塞
- 只要有写锁,后续申请读锁和写锁都将阻塞
2.sync.RWMutex有以下方法
RWMutex 也称为读写互斥锁,读写互斥锁就是读取/写入互相排斥的锁。它可以由任意数量的读取操作的 goroutine 或单个写入操作的 goroutine 持有。
| 方法 | 功能 |
|---|---|
| Lock() | 申请写锁 |
| Unlock() | 申请释放写锁 |
| RLock() | 申请读锁 |
| RUnlock() | 申请释放读锁 |
| RLocker() | 返回一个实现了Lock()和Unlock()方法的Locker接口 |
3.sync.RWMutex栗子
func main() {
var rm sync.RWMutex
for i := 0; i < 3; i++ {
go read(&rm, i)
}
time.Sleep(time.Second * 2)
}
func read(rm *sync.RWMutex, i int) {
fmt.Println(i, "reader start")
rm.RLock()
fmt.Println(i, "reading")
time.Sleep(time.Second * 1)
rm.RUnlock()
fmt.Println(i, "reader over")
}
打印:
打印结果看得出来,2开始,还没有读完,1和0就相继跟上开始读了
三、sync.WaitGroup
1. time.sleep()和sync.WaitGroup比较
理论上wait和sleep是完全没有可比性的,一个用于线程间的通信,另一个用于线程阻塞一段时间。唯一相同的就是,sleep方法和wait方法都是用来使线程进入休眠状态的,对于中断信号,都可以进行响应和中断
不同:
- 语法使用:
wait方法必须配合synchronized一起使用,sleep可以单独使用 - 唤醒方式:
sleep需要传递一个超时时间,因此,sleep具有主动唤醒功能,而不需要传递任何参数的wait只能被动唤醒 - 释放锁资源:
wait方法主动释放锁,sleep不释放锁 - 线程进入状态:
sleep有时限等待状态,wait无时限等待状态
2.sync.WaitGroup有以下方法
在并发操作中,生硬使用time.Sleep并不合适,反观前面的比较,sync.WaitGroup更适用于并发任务的同步实现
| 方法名 | 功能 |
|---|---|
| Add(num int) | 计数器+num |
| Done() | 计数器-1 |
| Wait() | 阻塞main函数直到计数器为0 |
sync.WaitGroup内部维护着一个计数器,可增可减。当我们要启动Z个并发任务时,计时器总数要增加到Z,可以通过for循环Add()单个增加,也可以一下子增加到Z;每当一个任务结束时,Done()就要在计数器里-1,直到计数器为0时,调用Wait()表示等待并发任务执行完成。
3. sync.WaitGroup栗子
func main() {
wg := sync.WaitGroup{}
n := 10
wg.Add(n) // 计数器累加至n
for i := 0; i <= n; i++ {
go f(i, &wg)
}
wg.Wait() // 等待计数器值为0,告知main函数的主协程,其他协程执行完毕
}
func f(i int, wg *sync.WaitGroup) {
fmt.Println(i)
wg.Done() // 完成该协程后,计数器-1
}
sync.WaitGroup一定要通过指针传值,不然进程会进入死锁状态
🎉 放在后面的话
本文我们介绍了 Go 语言中的基本同步原语sync.Mutex、sync.RWMutex、sync.WaitGroup的概念和简单应用,并分别对sync.Mutex和sync.RWMutex,time.sleep和sync.WaitGroup进行了比较。读写互斥锁可以对临界区的共享资源做更加细粒度的访问控制,不限制对临界区的共享资源的并发读,所以在读多写少的场景,我们可以使用读写互斥锁替代互斥锁,提升应用程序的性能。在并发中,我们并不知道完成这一应用程序,我们需要多长时间,time.sleep时有时限的且功能等在并发中,并不算优雅,故sync.WaitGroup更适用于任务编排,等待多个 goroutine 全部完成。
sync包中还有三个比较常用的锁,我们将会在下一篇细说。