前言
有时候在go语言中存在多个goroutine去竞争同一个资源(临界区)。这种情况会发生竞态问题(数据竞态)。
比如:飞机上火车上、人就好比goroutine,去争夺同一个厕所资源,最好的情况就是一个一个来,但是着急的时候两个人一起上就发生数据竞态。
代码:
package main
import (
"fmt"
"sync"
)
var x int64
var wg sync.WaitGroup
func add() {
defer wg.Done()
for i := 0; i < 100000; i++ {
x++
}
}
func main() {
wg.Add(2)
go add()
go add()
wg.Wait()
fmt.Println(x)
}
结果:
上面的代码是开启了两个goroutine去累加x的值,他两同时去修改和访问x会导致数据竞争,导致结果与预期不符合。
此时我们想到使用互斥锁来解决此问题,保证数据共享的同时又不会竞争资源。
互斥锁
互斥锁是一种常用的控制共享资源的办法,它能够同时保证一个gotroutine可以访问共享资源。go语言中使用sync和metux类型来实现互斥锁。
代码如下:
package main
import (
"fmt"
"sync"
)
var x int64
var wg sync.WaitGroup
var lock sync.Mutex
func add() {
defer wg.Done()
for i := 0; i < 100000; i++ {
lock.Lock()
x++
lock.Unlock()
}
}
func main() {
wg.Add(2)
go add()
go add()
wg.Wait()
fmt.Println(x)
}
结果如下:
这样使用互斥锁能够保证同一时间有且只有一个goroutine进入临界区,其他的goroutine则在等待锁;当互斥锁释放后,等待的goroutine才可以获取锁进入临界区,多个goroutine同时等待一个锁时,唤醒策略是随机的。
读写互斥锁
互斥锁是完全互斥的,但是有很多实际的场景下是读多写少的,当我们并发的去读取一个资源不涉及资源修改的时候是没有必要加锁的,这种场景下使用读写锁是更好的一种选择。读写锁在Go语言中使用sync
包中的RWMutex
类型。
读写锁分为两种:读锁和写锁。当一个goroutine获取读锁之后,其他的goroutine如果获取读锁则会继续获取锁,如果是获取写锁就会等待;当一个goroutine获取写锁之后,其他的goroutine无论是读锁还是写锁都会等待。
代码展示:
package main
import (
"fmt"
"sync"
"time"
)
var x int64
var wg sync.WaitGroup
var lock sync.Mutex
var rwlock sync.RWMutex
func write() {
defer wg.Done()
// lock.Lock() //加互斥锁
rwlock.Lock() //加写锁
x++
time.Sleep(time.Microsecond * 10)
rwlock.Unlock() //解写锁
// lock.Unlock() //解互斥锁
}
func read() {
defer wg.Done()
// lock.Lock() //加互斥锁
rwlock.RLock() //加读锁
time.Sleep(time.Millisecond)
rwlock.RUnlock() //解读锁
// lock.Unlock() //解互斥锁
}
func main() {
start := time.Now()
for i := 0; i < 10; i++ {
wg.Add(1)
go write()
}
for i := 0; i < 1000; i++ {
wg.Add(1)
go read()
}
wg.Wait()
end := time.Now()
fmt.Println(end.Sub(start))
}
结果如下:
上面的是互斥锁的执行时间;下面的是读写锁的执行时间;可以看到读写锁效率提升很多。
但是读写锁适合读多写少的场景,需要注意。
sync.WaitGroup
go语言使用sync.WaitGroup来实现并发同步。主要由三个方法:
wg.Add() 计数器+1;
wg.Done() 计数器-1;
wg.Wait() 等到直到计数器变为0;
sync.WaitGroup
内部维护着一个计数器,计数器的值可以增加和减少。例如当我们启动了N 个并发任务时,就将计数器值增加N。每个任务完成时通过调用Done()方法将计数器减1。通过调用Wait()来等待并发任务执行完,当计数器值为0时,表示所有并发任务已经完成。
代码展示:
package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup
func hello() {
defer wg.Done()
fmt.Println("Hello Goroutine!")
}
func main() {
wg.Add(1)
go hello() // 启动另外一个goroutine去执行hello函数
fmt.Println("main goroutine done!")
wg.Wait()
}
结果显示:
sync.Once
在编程的很多场景下我们需要确保某些操作在高并发的场景下只执行一次,例如只加载一次配置文件、只关闭一次通道等。
Go语言中的sync
包中提供了一个针对只执行一次场景的解决方案–sync.Once
。
sync.Once
只有一个Do
方法,其签名如下:
func (o *Once) Do(f func()) {}
备注:如果要执行的函数f
需要传递参数就需要搭配闭包来使用。
sync.Map
go语言内置的map并不是并发安全的,当开启很多goroutine时就会报错;这样的情况就需要为map加锁来保证并发的安全性了,go语言提供了一个开箱即用的并发安全版sync.Map。开箱即用表示不用像内置的map一样使用make函数初始化就能直接使用。同时sync.Map
内置了诸如Store
、Load
、LoadOrStore
、Delete
、Range
等操作方法。
代码如下:
package main
import (
"fmt"
"strconv"
"sync"
)
var m = sync.Map{}
func main() {
wg := sync.WaitGroup{}
for i := 0; i < 30; i++ {
wg.Add(1)
go func(n int) {
key := strconv.Itoa(n)
m.Store(key, n)
value, _ := m.Load(key)
fmt.Printf("k=:%v,v:=%v\n", key, value)
wg.Done()
}(i)
}
wg.Wait()
}
原子操作
前面通过锁来实现同步操作。而锁机制的底层是基于原子操作的,其一般直接通过CPU指令实现。Go语言中原子操作有内置的标准库sync/atomic提供。
atomic
包提供了底层的原子级内存操作,对于同步算法的实现很有用。这些函数必须谨慎地保证正确使用。除了某些特殊的底层应用,使用通道或者sync包的函数/类型实现同步更好。
代码展示:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
//原子操作
var x int64
var wg sync.WaitGroup
var lock sync.Mutex
// func add() {
// defer wg.Done()
// lock.Lock()
// x++
// lock.Unlock()
// }
func add() {
defer wg.Done()
atomic.AddInt64(&x, 2)
}
func main() {
wg.Add(100000)
for i := 0; i < 100000; i++ {
go add()
}
wg.Wait()
fmt.Println(x)
}