欢迎小伙伴们观看我的文章,若文中有不正确的地方,小伙伴们可以在评论区处指出,希望能够与小伙伴们一起成长与进步,谢谢!
本文继续探讨Go并发编程基础,上篇戳如下链接~
一、并发安全与锁
在涉及到多个goroutine
运行时,难免会存在多个goroutine
同时对一个资源进行操作的情况发生,从而产生数据竞态问题。例如一台atm
,大家都需要排队等候使用,无法两个人同时使用。举个例子:
go
package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup
var x int
func increase() {
defer wg.Done()
for i := 0; i < 5000; i++ {
x++
}
}
func main() {
wg.Add(2)
go increase()
go increase()
wg.Wait()
fmt.Println(x)
}
上述代码的执行结果每次执行都会产生不同的结果,例如7741、6174、6063等,示例代码中开启两个goroutine
分别执行increase()
函数,两个goroutine
分别在访问和修改全局变量i时,由于x++
并非原子操作,某个goroutine
中对全局变量x的修改可能覆盖另一个goroutine
的操作,产生数据竞争,从而导致最后的结果与预期不符。
1、互斥锁
互斥锁介绍
互斥锁(mutex)
是一种用于多线程编程中对共享资源进行访问控制的常用机制。它提供了一种排他锁定机制,确保同一时间只能有一个线程访问临界区资源。
当多个线程需要访问共享资源时,如果没有进行有效的同步机制,就会发生数据竞态问题。使用互斥锁可以保证在任何时刻,只有一个线程可以获取到这个锁,从而避免多个线程同时访问共享资源导致的问题。
互斥锁通常包括 lock
和 unlock
两个操作。一个线程在进入临界区之前需要先获取锁,如果锁已经被其他线程占用,则当前线程需要等待。当一个线程退出临界区时,它需要释放锁,以便其他线程可以获取这个锁并访问共享资源。
sync.Mutex
在Go语言中,使用sync
包中提供的Mutex
类型来实现互斥锁,它能够保证同一时间只有一个goroutine可以访问共享资源。 sync.Mutex
提供了两个方法:
方法名 | 功能说明 |
---|---|
func (m *Mutex) Lock() |
获取互斥锁 |
func (m *Mutex) Unlock() |
释放互斥锁 |
使用互斥锁限制每次只能有一个goroutine
获取并修改全局变量i
,修改后的代码如下:
go
package main
import (
"fmt"
"sync"
)
var (
x int
wg sync.WaitGroup // 等待组
m sync.Mutex // 互斥锁
)
// increase 对全局变量i执行加一操作
func increase() {
defer wg.Done()
for i := 0; i < 5000; i++ {
m.Lock() // 获取互斥锁,即加锁
x++
m.Unlock()
}
}
func main() {
wg.Add(2)
go increase()
go increase()
wg.Wait()
fmt.Println(x) // 10000
}
上述代码加入互斥锁后,每一次运行都会得到预期的结果10000。
- 使用互斥锁能够保证同一时间有且只有一个
goroutine
进入临界区,其他的goroutine
则在等待锁; - 当互斥锁释放后,等待的
goroutine
才可以获取锁进入临界区,多个goroutine
同时等待一个锁时,唤醒的策略是随机的。
2、读写锁
在某些场景下,对于资源的访问可能是读多写少,当并发读取一个资源且不涉及资源的修改时,可以考虑使用读写锁。
不同于互斥锁的完全互斥,读写锁分为读锁与写锁。
- 当一个
goroutine
获取到读锁后,其他goroutine
如果是获取读锁,则可以继续获取该读锁,如果获取写锁,则会阻塞等待。 - 当一个
goroutine
获取到写锁后,其他goroutine
无论是获取读锁还是写锁,都会阻塞等待。
在Go中,读写锁使用sync
包中的RWMutex
类型。
读写锁在Go中使用sync
包中的RWMutex
类型。sync.RWMutex
提供的方法如下:
方法名 | 功能说明 |
---|---|
func (rw *RWMutex) Lock() |
获取写锁 |
func (rw *RWMutex) Unlock() |
释放写锁 |
func (rw *RWMutex) RLock() |
获取读锁 |
func (rw *RWMutex) RUnlock() |
释放读锁 |
func (rw *RWMutex) RLocker() Locker |
返回一个实现Locker 接口的读写锁 |
通过一个读多写少的场景,通过互斥锁与读写锁的使用来对比两者之间性能的差异。
go
package main
import (
"fmt"
"sync"
"time"
)
var (
x int
wg sync.WaitGroup
mutex sync.Mutex
rwmutex sync.RWMutex
)
// 使用互斥锁进行写操作
func writeByLock() {
mutex.Lock() // 加互斥锁
x = x + 1
time.Sleep(time.Millisecond * 10) // 模拟写操作耗时时长
mutex.Unlock()
wg.Done()
}
// 使用互斥锁进行读操作
func readByLock() {
mutex.Lock() // 加互斥锁
time.Sleep(time.Millisecond) // 模拟读操作耗时时长
mutex.Unlock()
wg.Done()
}
// 使用读写互斥锁进写操作
func writeByRWLock() {
rwmutex.Lock() // 加读写互斥锁
x = x + 1
time.Sleep(time.Millisecond * 10) // 模拟写操作耗时时长
rwmutex.Unlock()
wg.Done()
}
// 使用读写互斥锁进行读操作
func readByRWLock() {
rwmutex.RLock() // 加读写互斥锁
time.Sleep(time.Millisecond) // 模拟读操作耗时时长
rwmutex.RUnlock()
wg.Done()
}
func operate(wf func(), rf func(), wc int, rc int) {
start := time.Now()
// wc个并发写操作
for i := 0; i < wc; i++ {
wg.Add(1)
go wf()
}
// rc个并发读操作
for i := 0; i < rc; i++ {
wg.Add(1)
go rf()
}
wg.Wait()
cost := time.Since(start)
fmt.Printf("x: %v cost: %v\n", x, cost)
}
上述代码中,分别使用互斥锁与读写锁执行10次并发写与1000次并发读,并计算执行前后的耗时时间。分别调用查看执行结果:
- 使用互斥锁
go
func main() {
// 使用互斥锁,10并发写,1000并发读
operate(writeByLock, readByLock, 10, 1000) // x: 10 cost: 15.7973915s
}
- 使用读写锁
go
func main() {
// 使用读写互斥锁,10并发写,1000并发读
operate(writeByRWLock, readByRWLock, 10, 1000) // x: 10 cost: 171.1409ms
}
从执行结果可以看到,使用读写互斥锁在读多写少的场景下,能够极大地提高程序的性能。但如果程序中读操作与写操作的数量级差别不大,则读写锁的优势无法发挥出来。
3、sync.WaitGroup
在前面的代码中,基本都是用到了sync.WaitGroup
,通过使用sync.WaitGroup
从而来实现并发任务的同步。
在Go中,sync.WaitGroup
类型提供了如下几个方法:
方法名 | 功能说明 |
---|---|
func (wg * WaitGroup) Add(delta int) |
等待组计数器 + delta |
(wg *WaitGroup) Done() |
等待组计数器-1 |
(wg *WaitGroup) Wait() |
阻塞直到等待组计数器变为0 |
sync.WaitGroup
在内部实现上,维护了一个计数器,该计数器的值可以通过Add()
与Done()
两个方法来实现增减。goroutine
通过调用Wait
来等待并发任务执行完,当计数器值为 0 时,表示所有并发任务已经完成。
go
package main
import (
"fmt"
"sync"
)
// 声明全局等待组变量
var wg sync.WaitGroup
func printHello() {
fmt.Println("Hello World")
wg.Done() // 完成一个任务后,调用Done()方法,等待组减1,告知当前goroutine已经完成任务
}
func main() {
wg.Add(1) // 等待组加1,表示登记一个goroutine
go printHello()
fmt.Println("main")
wg.Wait() // 阻塞当前goroutine,直到等待组中的所有goroutine都完成任务
}
// 执行结果
main
Hello World
4、sync.Once
在高并发场景下,有时候需要保证某些操作只会被执行一次,例如只加载一次配置文件等。
在Go中,sync
包提供了一个针对只执行一次场景的解决方案,即sync.Once
类型,sync.Once
只提供了一个Do
方法,其方法签名如下:
go
func (o *Once) Do(f func())
上述方法中,如果要执行的函数f
需要传递参数,就需要搭配闭包来使用。
- 加载配置文件示例
延迟一个开销很大的初始化操作,在真正需要使用到时,再执行初始化是一个很好的实践。
因为预先初始化一个变量,例如在init
函数中完成初始化等操作,会增加程序的启动耗时,并且有可能在实际的执行过程中,这个变量都没有使用到,此时这个初始化操作就不是必要的。
举个例子:
go
var icons map[string]image.Image
func loadIcons() {
icons = map[string]image.Image{
"left": loadIcon("left.png"),
"up": loadIcon("up.png"),
"right": loadIcon("right.png"),
"down": loadIcon("down.png"),
}
}
// Icon 被多个goroutine调用时不是并发安全的
func Icon(name string) image.Image {
if icons == nil {
loadIcons()
}
return icons[name]
}
上述代码中,在多个goroutine
并发调用Icon
函数时并不是并发安全的,现代的编译器和CPU可能会在保证每个 goroutine
都满足串行一致的基础上自由地重排访问内存的顺序。loadIcons
函数可能会被重排为以下结果:
go
func loadIcons() {
icons = make(map[string]image.Image)
icons["left"] = loadIcon("left.png")
icons["up"] = loadIcon("up.png")
icons["right"] = loadIcon("right.png")
icons["down"] = loadIcon("down.png")
}
由于并非并发安全,就有可能出现即使判断icons
不是nil
,也有可能icons
并未初始化完成。考虑到这种情况,可以通过添加互斥锁来保证icons
初始化时不会被其他goroutine
操作,但又会引发性能问题。
通过使用sync.Once
可以很好的避免上述的问题。
go
var icons map[string]image.Image
var loadIconsOnce sync.Once
func loadIcons() {
icons = map[string]image.Image{
"left": loadIcon("left.png"),
"up": loadIcon("up.png"),
"right": loadIcon("right.png"),
"down": loadIcon("down.png"),
}
}
// Icon 并发安全
func Icon(name string) image.Image {
loadIconsOnce.Do(loadIcons) // 只会执行一次
return icons[name]
}
并发安全的单例模式
通过sync.Once
,可以实现并发安全下的单例模式。
go
type singleton struct {}
var instance *singleton
var once sync.Once
func GetInstance() *singleton {
once.Do(func() {
instance = &singleton{}
})
return instance
}
sync.Once
其实内部包含一个互斥锁和一个布尔值,互斥锁保证布尔值和数据的安全,而布尔值用来记录初始化是否完成。
这样的设计保证了初始化操作是并发安全且初始化操作也只执行一次。不会被执行多次。
5、sync.Map
在Go中,内置的map
类型不是并发安全的数据类型,在多个goroutine
对map
写入数据是不被允许的。例如:
go
package main
import (
"strconv"
"sync"
)
var m = make(map[string]int)
func get(key string) int {
return m[key]
}
func set(key string, value int) {
m[key] = value
}
func main() {
wg := sync.WaitGroup{}
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
key := strconv.Itoa(n)
set(key, n)
}(i)
}
wg.Wait()
}
上述代码编译执行后,终端会报错fatal error: concurrent map writesfatal error: concurrent map writes
,告知我们不能在多个goroutine下并发对map进行读写操作,否则会出现数据竞争的情况。
为了保证map的并发安全性,Go语言中的sync
包提供了一个开箱即用的并发安全的map
------ sync.Map
。sync.Map类型不需要使用make
函数进行初始化而可以直接使用,且该类型封装了许多操作方法。例如:
方法名 | 功能说明 |
---|---|
func (m *Map) Store(key, value interface{}) |
存储键值对数据 |
func (m *Map) Load(key interface{}) (value interface{}, ok bool) |
查询key 对应的value |
func (m *Map) LoadOrStore(key, value interface{}) (actual interface{}, loaded bool) |
查询或存储key 对应的value |
func (m *Map) LoadAndDelete(key interface{}) (value interface{}, loaded bool) |
查询并删除key 以及value |
func (m *Map) Delete(key interface{}) |
删除对应的key |
func (m *Map) Range(f func(key, value interface{}) bool) |
对map 中的每个key-value 依次调用f |
go
package main
import (
"fmt"
"strconv"
"sync"
)
var m = sync.Map{}
func main() {
wg := sync.WaitGroup{}
for i := 0; i < 20; i++ {
wg.Add(1)
go func(n int) {
key := strconv.Itoa(n)
m.Store(key, n) // 存储key
value, _ := m.Load(key) // 获取key对应的value
fmt.Printf("k=:%v,v:=%v\n", key, value)
wg.Done()
}(i)
}
wg.Wait()
}
二、原子操作
针对整数数据类型(int32、uint32、int64、uint64),在并发操作时,为了保证并发安全,可以使用原子操作来保证并发安全,使用原子操作比直接使用锁操作效率更高,因为加锁涉及内核态的上下文切换会比较耗时、代价比较高,而原子操作在用户态即可完成操作。
Go语言中原子操作由内置的标准库sync/atomic
提供。具体的一下方法如下:
读操作
func LoadInt32(addr *int32) (val int32)
func LoadInt64(addr *int64) (val int64)
func LoadUint32(addr *uint32) (val uint32)
func LoadUint64(addr *uint64) (val uint64)
func LoadUintptr(addr *uintptr) (val uintptr)
func LoadPointer(addr *unsafe.Pointer) (val unsafe.Pointer)
写操作
func StoreInt32(addr *int32, val int32)
func StoreInt64(addr *int64, val int64)
func StoreUint32(addr *uint32, val uint32)
func StoreUint64(addr *uint64, val uint64)
func StoreUintptr(addr *uintptr, val uintptr)
func StorePointer(addr *unsafe.Pointer, val unsafe.Pointer)
修改操作
func AddInt32(addr *int32, delta int32) (new int32)
func AddInt64(addr *int64, delta int64) (new int64)
func AddUint32(addr *uint32, delta uint32) (new uint32)
func AddUint64(addr *uint64, delta uint64) (new uint64)
func AddUintptr(addr *uintptr, delta uintptr) (new uintptr)
交换操作
func SwapInt32(addr *int32, new int32) (old int32)
func SwapInt64(addr *int64, new int64) (old int64)
func SwapUint32(addr *uint32, new uint32) (old uint32)
func SwapUint64(addr *uint64, new uint64) (old uint64)
func SwapUintptr(addr *uintptr, new uintptr) (old uintptr)
func SwapPointer(addr *unsafe.Pointer, new unsafe.Pointer) (old unsafe.Pointer)
比较并交换操作
func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool)
func CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool)
func CompareAndSwapUint64(addr *uint64, old, new uint64) (swapped bool)
func CompareAndSwapUintptr(addr *uintptr, old, new uintptr) (swapped bool)
func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool)
可以通过使用互斥锁修改变量与原子操作修改变量来比较两者的性能:
go
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
var x int64
var lock sync.Mutex
var wg sync.WaitGroup
// 使用互斥锁
func mutexAdd() {
lock.Lock()
x++
lock.Unlock()
wg.Done()
}
// 使用原子操作
func atomicAdd() {
atomic.AddInt64(&x, 1)
wg.Done()
}
func main() {
startTime := time.Now()
for i := 0; i < 10000; i++ {
wg.Add(1)
go mutexAdd() // 使用互斥锁add函数
//go atomicAdd() // 原子操作add函数
}
wg.Wait()
endTime := time.Now()
fmt.Println(x)
fmt.Println(endTime.Sub(startTime))
}
// go atomicAdd() 执行结果
10000
2.5564ms
// go mutexAdd() 执行结果
10000
3.1184ms