Go专家编程-常见控制结构实现原理
本文为《Go专家编程》读书笔记~
Go专家编程
- 常见控制结构实现原理
- defer
- 规则一:延迟函数的参数在defer语句出现时就已经确定了
- 规则二:延迟函数执行按后进先出顺序执行,即先出现的 defer最后执行(栈)
- 规则三:延迟函数可能操作主函数的具名返回值
- 函数返回过程
- 主函数拥有匿名返回值,返回字面值
- 主函数拥有匿名返回值,返回变量
- defer原理
- select
- 实现原理
- case数据结构
- select实现逻辑
- 总结
- range迭代遍历
- 优化:
- range for slice
- range for map
- range for channel
- tips
- Mutex结构体类型
- Mutex定义
- 加解锁过程
- 简单加锁
- 加锁被阻塞
- 简单解锁
- 解锁并唤醒协程
- 自旋过程
- 自旋条件
- 自旋的问题
- Mutex模式
- Normal
- Starving 饥饿
- Woken状态(协程是否唤醒)
- 为什么重复解锁要panic
- rwmutex(读写互斥锁)
- 接口定义
- 写操作是如何阻止写操作的(互斥锁)
- 写操作是如何阻止读操作的(readerCount负值)
- 读操作是如何阻止写操作的(readerCount大于0)
- 为什么写锁定不会被饿死(readerWait为0唤醒写操作)
常见控制结构实现原理
defer
defer语句用于延迟函数的调用,每次defer都会把一个函数压入栈中,函数返回前再把延迟的函数取出并执行。
规则一:延迟函数的参数在defer语句出现时就已经确定了
func a() {
i := 0
defer fmt.Println(i)
i
return
}
defer语句中的fmt.Println()参数i值在defer出现时就已经确定下来,实际上是拷贝了一份。后面对变量i的修改不会影响fmt.Println()函数的执行,仍然打印”0”。
注意:对于指针类型参数,规则仍然适用,只不过延迟函数的参数是一个地址值,这种情况下,defer后面的语句对 变量的修改可能会影响延迟函数。
规则二:延迟函数执行按后进先出顺序执行,即先出现的 defer最后执行(栈)
设计defer的初衷是简化函数返回时资源清理的动作,资源往往有依赖顺序,比如先申请A资源,再跟据A资源申请B资 源,跟据B资源申请C资源,即申请顺序是:A—>B—>C,释放时往往又要反向进行。这就是把deffer设计成FIFO的原因。
每申请到一个用完需要释放的资源时,立即定义一个defer来释放资源是个很好的习惯。
规则三:延迟函数可能操作主函数的具名返回值
定义defer的函数,即主函数可能有返回值,返回值有没有名字没有关系,defer所作用的函数,即延迟函数可能会影响到返回值。
若要理解延迟函数是如何影响主函数返回值的,只要明白函数是如何返回的就足够了
函数返回过程
关键字return不是一个原子操作,实际上return只代理汇编指令ret,即将跳转程序执 行。比如语句 return i ,实际上分两步进行,即将i值存入栈中作为返回值,然后执行跳转,而defer的执行时机正是跳转前,所以说defer执行时还是有机会操作返回值的。
// 输出2
func deferFuncReturn() (result int) {
i := 1
defer func() {
result
}()
return i
}
加入defer后的执行过程如下:
1. result = i
2. result // 这里是加进去的
3. return
主函数拥有匿名返回值,返回字面值
一个主函数拥有一个匿名的返回值,返回时使用字面值,比如返回”1”、”2”、”Hello”这样的值,这种情况下defer 语句是无法操作返回值的。
主函数拥有匿名返回值,返回变量
一个主函数拥有一个匿名的返回值,返回使用本地或全局变量,这种情况下defer语句可以引用到返回值,但不会改变返回值。
// 输出0
func deferFuncReturn() int {
var i int
defer func() {
i
}()
return i
}
上面的函数拆解出来,如下所示:
1. anony = i
2. i
3. return
defer原理
defer后面一定要接一个函数的,所以defer的数据结构跟一般函数类似,也有栈地址、程序计数器、函数 地址等等。
type _defer struct {
sp uintptr //函数栈指针
pc uintptr //程序计数器
fn *funcval //函数地址
link *_defer //指向自身结构的指针,用于链接多个defer
}
一个goroutine可能连续调用多个函数,defer添加过程跟上述流程一致,进入函数时添加defer,离开函数时取出 defer,所以即便调用多个函数,也总是能保证defer是按FIFO方式执行的。
- defer定义的延迟函数参数在defer语句初始时就已经确定下来了
- defer定义顺序与实际执行顺序相反(FIFO)
- return不是原子操作,执行过程是: 保存返回值(若有)—>执行defer(若有)—>执行ret跳转
- 申请资源后立即使用defer关闭资源是好习惯
select
select是Golang在语言层面提供的多路IO复用的机制,其可以检测多个channel是否ready(即是否可读或可写)。
实现原理
Golang实现select时,定义了一个数据结构表示每个case语句(含defaut,default实际上是一种特殊的 case),select执行过程可以类比成一个函数,函数输入case数组,输出选中的case,然后程序流程转到选中的 case块。
case数据结构
- scase.c为当前case语句所操作的channel指针,这也说明了一个case语句只能操作一个channel。
- scase.kind 表示该case的类型,分为读channel、写channel和default
type scase struct {
c *hchan // chan
kind uint16
elem unsafe.Pointer // data element
}
select实现逻辑
- 锁定scase语句中所有的channel
- 按照随机顺序检测scase中的channel是否ready
2.1 如果case可读,则读取channel中数据,解锁所有的channel,然后返回(case index, true)
2.2 如果case可写,则将数据写入channel,解锁所有的channel,然后返回(case index, false)
2.3 所有case都未ready,则解锁所有的channel,然后返回(default index, false) - 所有case都未ready,且没有default语句
3.1 将当前协程加入到所有channel的等待队列
3.2 当将协程转入阻塞,等待被唤醒 - 唤醒后返回channel对应的case index
4.1 如果是读操作,解锁所有的channel,然后返回(case index, true)
4.2 如果是写操作,解锁所有的channel,然后返回(case index, false)
特别说明:对于读channel的case来说,如 case elem, ok := <-chan1: , 如果channel有可能被其他协程关闭的情 况下,一定要检测读取是否成功,因为close的channel也有可能返回,此时ok == false。
总结
- select语句中除default外,每个case操作一个channel,要么读要么写
- select语句中除default外,各case执行顺序是随机的
- select语句中如果没有default语句,则会阻塞等待任一case
- select语句中读操作要判断是否成功读取,关闭的channel也可以读取
range迭代遍历
range是Golang提供的一种迭代遍历手段,可操作的类型有数组、切片、Map、channel等。
优化:
- 可以在for-range中忽略value值,使用slice[index]引用value值。
循环内改变切片的长度,不影响循环次数
for index, _ := range slice {
range for slice
遍历slice前会先获以slice的长度len_temp作为循环次数,循环体中,每次循环会先获取元素值,如果for- range中接收index和value的话,则会对index和value进行一次赋值。
由于循环开始前循环次数就已经确定了,所以循环过程中新添加的元素是没办法遍历到的。
range for map
map底层使用hash表实 现,插入数据位置是随机的,所以遍历过程中新插入的数据不能保证遍历到。
range for channel
channel遍历是依次从channel中读取数据,读取前是不知道里面有多少个元素的。如果channel中没有元素,则会阻塞等待,如果channel已被关闭,则会解除阻塞并退出循环。
tips
- 遍历过程中可以适情况放弃接收index或value,可以一定程度上提升性能
- 遍历channel时,如果channel中没有数据,可能会阻塞
- 尽量避免遍历过程中修改原数据
- 使用index,value接收range返回值会发生一次数据拷贝
Mutex结构体类型
Mutex为结构体类型,对外暴露两个方法Lock()和Unlock()分别用于加锁和解锁。
Mutex定义
type Mutex struct {
// 表示互斥锁的状态,比如是否被锁定等。
state int32
// 表示信号量,协程阻塞等待该信号量,解锁的协程释放信号量从而唤醒等待信号量的协程。
sema uint32
}
Mutex.state是32位的整型变量,内部实现时把该变量分成四份,用于记录Mutex的四种状态。
- Locked: 表示该Mutex是否已被锁定,0:没有锁定 1:已被锁定。
- Woken: 表示是否有协程已被唤醒,0:没有协程唤醒 1:已有协程唤醒,正在加锁过程中。
- Starving:表示该Mutex是否处理饥饿状态, 0:没有饥饿 1:饥饿状态,说明有协程阻塞了超过1ms。
- Waiter: 表示阻塞等待锁的协程个数,协程解锁时根据此值来判断是否需要释放信号量。
协程之间抢锁实际上是抢给Locked赋值的权利,能给Locked域置1,就说明抢锁成功。抢不到的话就阻塞等待 Mutex.sema信号量,一旦持有锁的协程解锁,等待的协程会依次被唤醒。
加解锁过程
简单加锁
加锁过程会去判断Locked标志位是否为0,如果是0则把Locked位置1,代表加锁成功。从上图可见,加锁成功后, 只是Locked位置1,其他状态位没发生变化。
加锁被阻塞
当协程B对一个已被占用的锁再次加锁时,Waiter计数器增加了1,此时协程B将被阻塞,直到 Locked值变为0后才会被唤醒。
简单解锁
由于没有其他协程阻塞等待加锁,所以此时解锁时只需要把Locked位置为0即可,不需要释放信号量。
解锁并唤醒协程
协程A解锁过程分为两个步骤,一是把Locked位置0,二是查看到Waiter>0,所以释放一个信号量,唤醒一个阻塞的 协程,被唤醒的协程B把Locked位置1,于是协程B获得锁。
自旋过程
加锁时,如果当前Locked位为1,说明该锁当前由其他协程持有,尝试加锁的协程并不是马上转入阻塞,而是会持续的探测Locked位是否变为0,这个过程即为自旋过程。
自旋时间很短,但如果在自旋过程中发现锁已被释放,那么协程可以立即获取锁。此时即便有协程被唤醒也无法获取锁,只能再次阻塞。
自旋的好处是,当加锁失败时不必立即转入阻塞,有一定机会获取到锁,这样可以避免协程的切换。
自旋对应于CPU的”PAUSE”指令,CPU对该指令什么都不做,相当于CPU空转,对程序而言相当于sleep了一小段时间,时间非常短,当前实现是30个时钟周期。
自旋过程中会持续探测Locked是否变为0,连续两次探测间隔就是执行这些PAUSE指令,它不同于sleep,不需要将协程转为睡眠状态。
自旋条件
- 自旋次数要足够小,通常为4,即自旋最多4次
- CPU核数要大于1,否则自旋没有意义,因为此时不可能有其他协程释放锁
- 协程调度机制中的Process数量要大于1,比如使用GOMAXPROCS()将处理器设置为1就不能启用自旋
- 协程调度机制中的可运行队列必须为空,否则会延迟协程调度
自旋的问题
如果自旋过程中获得锁,那么之前被阻塞的协程将无法获得锁,如果加锁的协程特别多,每次都通过自旋获得锁,那 么之前被阻塞的进程将很难获得锁,从而进入饥饿状态。
为了避免协程长时间无法获取锁,自1.8版本以来增加了一个状态,即Mutex的Starving状态。这个状态下不会自旋,一旦有协程释放锁,那么一定会唤醒一个协程并成功加锁。
Mutex模式
每个Mutex都有两个模式,称为Normal和Starving。
Normal
默认情况下,Mutex的模式为normal。
该模式下,协程如果加锁不成功不会立即转入阻塞排队,而是判断是否满足自旋的条件,如果满足则会启动自旋过程,尝试抢锁。
Starving 饥饿
自旋过程中能抢到锁,一定意味着同一时刻有协程释放了锁,我们知道释放锁时如果发现有阻塞等待的协程,还会释 放一个信号量来唤醒一个等待协程,被唤醒的协程得到CPU后开始运行,此时发现锁已被抢占了,自己只好再次阻塞, 不过阻塞前会判断自上次阻塞到本次阻塞经过了多长时间,如果超过1ms的话,会将Mutex标记为”饥饿”模式,然后再阻塞。
处于饥饿模式下,不会启动自旋过程,也即一旦有协程释放了锁,那么一定会唤醒协程,被唤醒的协程将会成功获取 锁,同时也会把等待计数减1。
Woken状态(协程是否唤醒)
Woken状态用于加锁和解锁过程的通信,举个例子,同一时刻,两个协程一个在加锁,一个在解锁,在加锁的协程可能在自旋过程中,此时把Woken标记为1
,用于通知解锁协程不必释放信号量了,好比在说:你只管解锁好了,不必释 放信号量,我马上就拿到锁了。
为什么重复解锁要panic
如果多次Unlock(),那么可能每次都释放一个信号量,这样会唤醒多个协程
,多个协程唤醒后会继续在Lock()的逻辑里抢锁,势必会增加Lock()实现的复杂度,也会引起不必要的协程切换。
rwmutex(读写互斥锁)
读写锁RWMutex,完整的表述应该是读写互斥锁,可以说是Mutex的一个改进版, 在某些场景下可以发挥更加灵活的控制能力,比如:读取数据频率远远大于写数据频率的场景。
type RWMutex struct {
w Mutex //用于控制多个写锁,获得写锁首先要获取该锁,如果有一个写锁在进行,那么再到来的写锁将会阻塞于此
writerSem uint32 //写阻塞等待的信号量,最后一个读者释放锁时会释放信号量
readerSem uint32 //读阻塞的协程等待的信号量,持有写锁的协程释放锁后会释放信号量
readerCount int32 //记录读者个数
readerWait int32 //记录写阻塞时读者个数
}
接口定义
- RLock():读锁定
- RUnlock():解除读锁定
- Lock(): 写锁定,与Mutex完全一致
- Unlock():解除写锁定,与Mutex完全一致
写操作是如何阻止写操作的(互斥锁)
读写锁包含一个互斥锁(Mutex),写锁定必须要先获取该互斥锁,如果互斥锁已被协程A获取(或者协程A在阻塞等待 读结束),意味着协程A获取了互斥锁,那么协程B只能阻塞等待该互斥锁。
所以,写操作依赖互斥锁阻止其他的写操作。
写操作是如何阻止读操作的(readerCount负值)
我们知道RWMutex.readerCount是个整型值,用于表示读者数量,不考虑写操作的情况下,每次读锁定将该值 1, 每次解除读锁定将该值-1,所以readerCount取值为[0, N],N为读者个数,实际上最大可支持2^30个并发读者。
当写锁定进行时,会先将readerCount减去2^30,从而readerCount变成了负值,此时再有读锁定到来时检测到 readerCount为负值,便知道有写操作在进行,只好阻塞等待。而真实的读操作个数并不会丢失,只需要将 readerCount加上2^30即可获得。
所以,写操作将readerCount变成负值来阻止读操作的。
读操作是如何阻止写操作的(readerCount大于0)
读锁定会先将RWMutext.readerCount加1,此时写操作到来时发现读者数量不为0,会阻塞等待所有读操作结束。
所以,读操作通过readerCount来将来阻止写操作的。
为什么写锁定不会被饿死(readerWait为0唤醒写操作)
我们知道,写操作要等待读操作结束后才可以获得锁,写操作等待期间可能还有新的读操作持续到来,如果写操作等待所有读操作结束,很可能被饿死。然而,通过RWMutex.readerWait可完美解决这个问题。
写操作到来时,会把RWMutex.readerCount值拷贝到RWMutex.readerWait中,用于标记排在写操作前面的读者个数。
前面的读操作结束后,除了会递减RWMutex.readerCount,还会递减RWMutex.readerWait值,当 RWMutex.readerWait值变为0时唤醒写操作。
所以,写操作就相当于把一段连续的读操作划分成两部分,前面的读操作结束后唤醒写操作,写操作结束后唤醒后面的读操作。
您可能感兴趣的文章:
龙芯平台构建Go语言环境指南
go 语言学习历程
golang微服务框架对比_斗鱼开源首秀——基于 Go 的微服务框架 Jupiter
Go 语言十年而立,Go2 蓄势待发
[GO语言基础] 一.为什么我要学习Golang以及GO语言入门普及
golang url 收集
go语言有哪些优势?Go语言的核心特性有哪些
Go 语言的核心优势
Go 开发关键技术指南 | 为什么你要选择 Go?(内含超全知识大图)
Go语言发展历史、核心、特性及学习路线