文章目录
- sync.Mutex 互斥锁
- sync.RWMutex 读写锁
- sync.Once 惰性初始化
- Goroutine 与线程
- 动态栈
- Goroutine 调度
- GOMAXPROCS
- Goroutine 没有 ID 号
上一篇文章当中我们已经系统性地回顾了在 Go 当中基于 Goroutine 和 Channel 进行并发控制的方法,Goroutine 指的是 Golang 的应用级线程,相比于系统线程,goroutine 是轻量级的,完全在用户态进行调度;Channel 可以被理解为 goroutine 之间进行通信的信道,它是 Golang 当中的引用类型,底层引用的数据结构是一个数组。基于 channel,在 Golang 当中我们可以直接进行 Goroutine 之间的通信,而无需基于共享内存进行通信。
这一节的内容是对 Golang 并发机制的深入,介绍了 Go 当中的锁机制,基于锁机制我们可以在 Go 当中基于共享变量来进行线程之间的通信。基于这一节的内容,我们将完整地回顾完与 Golang 并发机制以及 Goroutine 调度有关的知识,在这一节的最后,将会详细地回顾 goroutine 与操作系统线程的区别。

sync.Mutex 互斥锁
基于 channel,我们实际上可以实现在同一时刻只有一个并发执行的 goroutine 访问存储关键资源的共享变量。下例模拟了一个银行的存取逻辑,基于缓冲区大小为 1 的 channel 来确保不同 goroutine 对关键资源的访问互斥:
var (sema = make(chan struct{}, 1) // 二元信号量balance int
)func Deposit(amount int) {sema <- struct{}{} // 如果缓冲区为空, 就不会被阻塞balance = balance + amount<- sema // 释放锁信号
}func Balance() int {sema <- struct{}{} // 换言之, 如果缓冲区被填满, 这条写入操作就会被阻塞b := balance<- semareturn b
}
Golang 的sync包中有一个名为Mutex的类型同样能够实现上述逻辑,它具有两个方法,分别是Lock和Unlock,前者会加锁而后者会释放锁:
import "sync"var (mu sync.Mutex // guards balancebalance int
)func Deposit(amount int) {mu.Lock()balance = balance + amountmu.Unlock()
}func Balance() int {mu.Lock()b := balancemu.Unlock()return b
}
在Lock与Unlock之间代码段的内容可以被当前持有锁的 goroutine 随意读取或修改,这个代码段叫做临界区。锁的持有者在其他 goroutine 获取锁之前需要调用Unlock,这也就意味着持有锁的 goroutine 在结束之前必须将锁释放。
可以将Unlock行为与defer关键字组合,来确保锁最终被释放:
func Balance() int {mu.Lock()defer mu.Unlock()// ... ... ...return balance
}
接下来我们研究一个更加复杂的案例,考虑下面的Withdraw函数,在成功时,它会调用Deposit扣减余额并返回 true,如果银行资金不足,那么就恢复余额并返回 false:
func Withdraw(amount int) bool {Deposit(-amount)if Balance() < 0 {Deposit(amount)return false}return true
}
函数可以给出正确的结果,但这个函数有一个副作用,那就是过多的取款操作并发时,balance 可能会瞬间被减到 0,这可能会导致并发取款与支付被不合理的拒绝。产生上述问题的原因是,在当前的取款$ \rightarrow $支付逻辑不是一个原子性的操作,每一步都需要去单独地获取互斥锁,任何一次上锁都不会锁住整个流程。
理想情况下,取款$ \rightarrow $支付逻辑应该在开始时获取互斥锁,结束时释放,但由于我们还没有修改Deposit/Balance的逻辑,这就意味着下述代码会产生错误:
// ❌ INCORRECT
func Withdraw(amount int) bool {mu.Lock()defer mu.Unlock()Deposit(-amount)if Balance() < 0 {Deposit(amount)return false // insufficient funds}return true
}
在Withdraw开始时,我们去获取锁,但是在Deposit中我们会再次尝试获取锁,由于 Golang 的 Mutex 不可重入,无法对一个已经上锁的 Mutex 再次加锁,这就会导致程序死锁,没有办法继续执行下去(因为Deposit等待的锁永远不会释放)。
基于上述原因,我们能够做的就是对Deposit进行修改,新建一个它的非导出版本,在这个非导出版本当中,不需要基于锁进行并发控制,因为它将会被Withdraw调用:
func Withdraw(amount int) bool {mu.Lock()defer mu.Unlock()deposit(-amount)if balance < 0 {deposit(amount)return false // insufficient funds}return true
}func Deposit(amount int) {mu.Lock()defer mu.Unlock()deposit(amount)
}func Balance() int {mu.Lock()defer mu.Unlock()return balance
}// This function requires that the lock be held.
func deposit(amount int) { balance += amount }
sync.RWMutex 读写锁
上例当中的Balance函数实际的行为就是读取变量的状态,而不会对变量进行状态改变,所以实际上我们并发调用多个Balance是安全的。
sync.RWMutex是一种特殊类型的锁,它允许多操作并发执行,但是写操作互斥,这种锁叫做“多读单写”锁:
var mu sync.RWMutex
var balance int
func Balance() int {mu.RLock()defer mu.RUnlock()return balance
}
sync.Once 惰性初始化
在单例模式当中,我们已经见到了「惰性初始化」的基本用法。一个线程安全的单例模式的模版是:
package mainimport ("fmt""sync"
)var lock sync.Mutextype singleton struct{}var instance *singletonfunc GetInstance() *singleton {lock.Lock()defer lock.Unlock()if instance == nil {return new(singleton)} else {return instance}
}func (s *singleton) SomeThing() {fmt.Println("SomeThing is called")
}func main() {s := GetInstance()s.SomeThing()
}
在“懒汉式”的单例模式下,为了确保单例类实例只有在需要的时候才被初始化,我们引入了一个 Mutex 锁,来在调用GetInstance函数的时候,首先加锁判断单例类实例是否被创建,如果没有被创建,则新建这个单例类实例。
我们进行进一步的细化,考虑下面这样的一个 icons 变量:
var icons map[string]image.Image
我们尝试使用“懒汉式”的做法来对 icons 进行初始化:
var mu sync.Mutex
var icons map[string]image.Imagefunc loadIcon(path string) image.Image {// ... ... ...return /* ... */
}func loadIcons() {icons = map[string]image.Image{"spades.png": loadIcon("spades.png"),"hearts.png": loadIcon("hearts.png"),"diamonds.png": loadIcon("diamonds.png"),"clubs.png": loadIcon("clubs.png"),}
}// NOTE: not concurrency-safe!
func Icon(name string) image.Image {mu.Lock()defer mu.Unlock()if icons == nil {loadIcons() // one-time initialization}return icons[name]
}
上述做法下,icons 的初始化当然是安全的。如果有 goroutine 并发地调用Icon,由于锁机制的存在,如果当前有其他 goroutine 正在调用Icon,那么当前Icon将会被阻塞。
一个问题在于,如果Icon已经被初始化完成,那么并发的 goroutine 无法并发地读 icons 这个变量,这会导致性能的下降,我们可以进一步使用RLock来对上述Icon函数的逻辑进行修改:
var mu sync.RWMutex // guards icons
var icons map[string]image.Image
// Concurrency-safe.
func Icon(name string) image.Image {mu.RLock()if icons != nil {icon := icons[name]mu.RUnlock()return icon}mu.RUnlock()// acquire an exclusive lockmu.Lock()if icons == nil { // NOTE: must recheck for nilloadIcons()}icon := icons[name]mu.Unlock()return icon
}
修改后的Icon既在初始化时线程安全,又支持并发读,但是代码较为复杂,我们可以使用 Golang sync 包内置的sync.Once来专门解决这种“懒汉式”的一次性初始化的并发问题。
下例使用sync.Once继续优化Icon的逻辑:
var loadIconsOnce sync.Once
var icons map[string]image.Image
func Icon(name string) image.Image{loadIconsOnce.Do(loadIcons)return icons[name]
}
每一次Do(loadIcons)的调用都会锁定mutex,并检查记录初始化是否完成的 bool 变量。只有 bool 为 false 的时候才锁定 mutex 来完成初始化操作。
总结一下,对于“懒汉式”初始化这种典型场景,为了确保并发调用该函数访问单例类实例时,不会产生:
- 重复初始化;
- 读等待;
可以使用sync.Once当中的Do来优化初始化逻辑。
Goroutine 与线程
在这一小节当中,我们具体地来区分一下 goroutine 这个用户态线程与操作系统线程之间的区别。这些区别可以说是在面试 Golang 开发岗位时必知必会的细节。
动态栈
操作系统线程的栈有一个固定的内存块(一般为 2MB),这个内存块将会用作栈,这个栈用于存储当前正在被调用的函数或挂起的函数(指的是为了调用当前这个函数而被暂时挂起的其他函数)的内部变量。
2MB 对于一个线程的栈而言,很大又很小。与 goroutine 相比,初始的 goroutine 的栈内存大小仅为 2KB,2MB 是它的一千倍。
对于 Go 程序而言,同时创建成百上千个 goroutine 是很普遍的,如果每一个 goroutine 都需要这么大的内存用作栈的话,那么让成百上千这个数量级的 goroutine 同时运行是不可能的。一个 goroutine 的栈是动态的,在其生命周期开始时,栈的大小只有 2KB。goroutine 的栈的作用与操作系统线程的栈类似,都是用于保存当前活跃以及挂起的函数调用的本地变量。goroutine 栈的大小可以动态伸缩,最大值可以达到 1TB,比传统的固定大小的栈大得多得多,但实际上大多数情况下栈的内存空间不会达到这个数量级。
Goroutine 调度
操作系统线程会基于操作系统的内核进行调度。每几毫秒,一个硬件的计时器会中断处理器,调用一个名为 scheduler 的内核函数。这个函数会挂起当前执行的线程,并将它的寄存器内容保存到内存当中,检查线程列表并决定下一次调度哪一个线程到 CPU 上执行,scheduler 会从内存中恢复该线程的寄存器信息,然后恢复该线程的现场并开始执行线程。
显然,由于操作系统线程是被内核当中的 scheduler 函数调度的,所以一个线程向另一个线程移动需要进行完整的上下文切换(首先保存当前线程的上下文状态到内存,之后检查线程列表根据调度策略选择下一个要调度的线程,从内存当中将它的状态转移到寄存器,恢复线程的现场,开始执行线程)。上下文切换的操作很慢,需要经过若干次的内存访问,会增加 CPU 的运行周期。
Go 的运行时包含了自己的调度器(GMP 线程调度模型,Golang 开发面试时的考察热点),比如m:n调度,它会让n个操作系统线程多工调度m个 goroutine。Go 调度器的工作原理与内核当中的 scheduler 函数非常的相似,但是 Go 的调度发生在用户态,而非内核态。由于不需要进入内核进行上下文切换,所以 Go 调度 goroutine 的成本比操作系统线程的调度成本要低很多。
GOMAXPROCS
Go 的调度器会使用一个名为 GOMAXPROCS 的变量来决定会有多少个 OS 线程同时执行 Go 代码,其默认值是运行机器上的 CPU 的核数。比如对于一个 8 核的机器,调度器一次会在 8 个 OS 线程上调度 Go 代码。
GOMAXPROCS 可以设置为比 CPU 核数更多的值,但是这样做通常不会带来性能的提升,甚至可能会由于过多的线程切换而导致性能下降。
GOMAXPROCS 对应的是 GMP 调度模型当中的 P,即具体的“调度器”,负责协调 G 和 M 的执行,GOMAXPROCS 的值就是 P 的数量。
有关 Golang 的 GMP 调度模型,可以详见我之前的文章:https://blog.csdn.net/Coffeemaker88/article/details/146607091
Goroutine 没有 ID 号
大多数支持多线程的操作系统或程序设计语言,都会为线程分配一个独特的身份(ID),并且这个身份可以以一个普通值的形式(比如一个整型数值)被轻易地获取到。基于线程的 ID,我们可以对线程进行本地存储(Thread-Local Storage,TLS),只需要使用一个 map 将线程 ID 与实际的线程对应起来即可。
TLS 可能会被滥用,因为线程本身的身份信息可能会改变,这就会使得承载在这个线程上的函数的行为变得不可预测。因此直接在程序当中基于 TLS 与线程进行交互是不安全的。
Goroutine 没有身份信息的概念,避免了 TLS 的滥用。