#Golang# Golang与并发编程(6) 同步机制

#Golang# Golang与并发编程(6) 同步机制

本文主要讨论同步机制的相关内容。


目录 Table of Contents


前情提要

尽管 Go 提倡“不要通过共享内存来通信,而应该通过通信来共享内存”,亦即使用 channel 而非使用 sync。然而支持同步机制仍然十分重要,无论是出于兼容或是方便的目的。

《现代操作系统(第4版)》第2.3节-进程间通信中,提及了竞争条件 (race condition) 和临界区域 (critical region) 的概念,本节讨论重点落于通过互斥方案以解决经典 IPC 问题。其中包含了多种思路、工具和机制,如屏蔽中断、锁变量、严格轮换法、Peterson 解法、TSL 指令、睡眠与唤醒、互斥量、信号量、管程、消息传递、屏障、避免锁等。Gochannel 很明显借鉴了消息传递的解决方法,而 sync 包则对应了互斥量、信号量、睡眠与唤醒/管程、屏蔽中断的具体实现。

本文将讨论 Go 并发编程中的各式各样的同步机制,包括悲观并发控制和乐观并发控制,如有错漏,欢迎指出 ;P

互斥量

互斥量 (Mutex) 在 Golang 中对应了各种锁 (Lock),包括互斥锁、读写锁、双重检查锁。互斥量主要用于通过锁机制实现悲观并发控制,锁住临界区 (critical section) 以保证同时刻只有一个 goroutine 可以访问共享资源 (shared resource) 。

互斥锁 sync.Mutex

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 数据加锁
var mu sync.Mutex

func DoSthToData() {
mu.Lock()
defer mu.Unlock()

// do something to data in critical section
}()

// 对象加锁
type SomeObj struct {
mu *sync.Mutex
}

func NewSomeObj() *SomeObj {
return &SomeObj{
mu: &sync.Mutex{}
}
}

func (o *SomeObj) DoSthToObj() {
o.mu.Lock()
defer o.mu.Unlock()

// do something to object in critical section
}
  1. g1 已经加锁后,g2 再尝试加锁,g2 造成阻塞;g2 已经解锁后,g2 将加锁成功,g2 恢复运行。
  2. 无论是读还是写,均会互斥。

读写锁 sync.RWMutex

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var mu sync.Mutex

// 一路只写
func WriteMutex() {
mu.Lock()
defer mu.Unlock()

// write something to critical section
}

// 多路只读
func ReadMutex() {
mu.RLock()
defer mu.RUnlock()

// read something from critical section
}
  1. 读写锁将数据或对象设定为写模式(只写)或者读模式(只读)。
  2. 写模式下,多个写操作之间是互斥的,即一路只写;读模式下,多个读操作之间不会互斥,即多路只读;写操作和读操作之间是互斥的。
  3. 所有被读锁定 (RLock) 的 goroutine 会在写解锁 (Unlock) 时唤醒;读解锁 (RUnlock) 只会在没有任何读锁定时唤醒一个要进行写锁定 (Lock) 而被阻塞的 goroutine

双重检查锁 sync.Once

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 懒汉模式
type Singleton struct{}

var instance *Singleton
var mu sync.Mutex

func GetSingleton() *Singleton {
// 单例没被初始化才加锁
if instance == nil {
mu.Lock()
defer mu.Unlock()

// 单例没被初始化才创建
if instance == nil {
instance = &Singleton{}
}
}

return instance
}
  • 第一次检查:控制共享资源访问。
  • 第二次检查:解决锁竞争带来的重复创建单例问题。
1
2
3
4
5
6
7
8
9
10
11
12
13
// 饿汉模式
type Singleton struct{}

var instance *Singleton

// 初始化时仅创建一次
func init() {
instance = &Singleton{}
}

func GetSingleton() *Singleton {
return instance
}
  • 利用 init 函数只会被执行一次的特点,在程序运行前创建单例。
  • 由于 init 函数没有参数值和返回值,有一定的局限性。
1
2
3
4
5
6
7
8
9
10
11
12
13
// sync.Once
type Singleton struct{}

var instance *Singleton
var once sync.Once

func GetSingleton() *Singleton {
once.Do(func() {
instance = &Singleton{}
})

return instance
}
  • 如果存在某个只执行一次的任务,不适合放在 init 函数,可考虑放在 sync.Once 类中。
  • sync.Once 内部使用了卫述语句、双重检查锁、原子操作等实现全局唯一操作。

信号量

信号量 (Semaphore) 在 Golang 中对应的是等待组 (sync.WaitGroup)。信号量主要用于通过计数来协调多个 goroutine 有序运行,等待批量并发任务运行结束。信道也可以实现类似功能,相比之下通过等待组的方式更为轻量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func main() {
var wg sync.WaitGroup
var mu sync.Mutex

for i:=0; i++; i<5 {
wg.Add(1)
go func() {
defer wg.Done()

mu.Lock()
defer mu.Unlock()

// do something
}()
}
wg.Wait()
}
  • 等待组内部拥有计数器,通常而言:计数器增加 (Add) 、计数器减少 (Done) 和计数器等待 (Wait) 这三个方法应该整体出现。
  • 等待组与同步锁配合使用,等待组解决批量任务的协调问题,同步锁解决单个任务的竞争问题。

条件变量

条件变量 (Condition Variables) 在 Golang 中对应的是条件变量 (sync.Cond)。条件变量主要用于通过条件来协调多个 goroutine 有序运行,唤醒单个或多个正在睡眠的协程。信道也可以实现类似功能,相比之下通过条件变量的方式更为轻量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
func main() {
cond := sync.Cond(&sync.Mutex{})
condition := false

go func() {
cond.L.Lock()
defer cond.L.Unlock()

// do something in anonymous goroutine

condition = true
cond.Signal() // wake up one sleep goroutine on this condition
}()

go func() {
cond.L.Lock()
defer cond.L.Unlock()

// do something in anonymous goroutine

condition = true
cond.Broadcast() // wake up all sleep goroutines on this goroutines
}()

cond.L.Lock()
defer cond.L.Unlock()

for !condition {
cond.Wait()

// do something in main goroutine
}
}
  • 条件变量内部拥有同步锁,通常而言:等待 (Wait) 与单发 (Signal) 或广播 (Broadcast) 这两个方法应该配对出现。
  • 条件变量与同步锁配合使用,条件变量解决协程处于睡眠或唤醒中的状态问题,同步锁解决单个任务的竞争问题。

原子操作

原子操作 (Atomic Operation) 在 Golang 中对应的是原子操作 (sync/atomic)。原子操作实现互斥的核心是:执行过程中 CPU 不能被中断,并基于这个基础上实现乐观并发控制

增加与减少 Add

1
2
newi32 := atomic.AddInt32(&i32, 1) // 原子操作:增加
newi32 := atomic.AddInt32(&i32, -1) // 原子操作:减少
  • 有符号整数型:增加使用正数,减少使用负数。
  • 无符号整数型:增加使用原码,减少使用补码。
  • 指针类型:在 Golang 中不支持直接对指针进行运算。

比较与替换 CAS

1
2
isSwap := atomic.CompareAndSwapInt32(&addr, oldVal, newVal) // 比较替换
oldVal := atomic.SwapInt32(&addr, newVal) // 直接替换
  • CAS 的优势:无锁机制,减少锁竞争的性能损耗。
  • CAS 的劣势:被操作值频繁变更,比较与替换则容易失败,需要引入循环进行多次尝试。
  • CAS 不会阻塞协程,但会暂时停滞。对于一些类型的值,乐观并发控制 (CAS) 优于悲观并发控制 (Lock)。

载入与存储 Load/Store

1
2
v := atomic.LoadInt32(&value) // 原子操作:载入
atomic.StoreInt32(&value, v) // 原子操作:存储
  • SwapCompareAndSwap 约束少(不做比较),比 Load 功能强(返回旧值)。
  • CompareAndSwap 可能会失败,Load 总是能成功。

参考链接


Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×