教程集 www.jiaochengji.com
教程集 >  Golang编程  >  golang教程  >  正文 Go专家编程-常见控制结构实现原理

Go专家编程-常见控制结构实现原理

发布时间:2023-03-11   编辑:jiaochengji.com
教程集为您提供Go专家编程-常见控制结构实现原理等资源,欢迎您收藏本站,我们将为您提供最新的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实现逻辑

  1. 锁定scase语句中所有的channel
  2. 按照随机顺序检测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)
  3. 所有case都未ready,且没有default语句
    3.1 将当前协程加入到所有channel的等待队列
    3.2 当将协程转入阻塞,等待被唤醒
  4. 唤醒后返回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,不需要将协程转为睡眠状态。

自旋条件

  1. 自旋次数要足够小,通常为4,即自旋最多4次
  2. CPU核数要大于1,否则自旋没有意义,因为此时不可能有其他协程释放锁
  3. 协程调度机制中的Process数量要大于1,比如使用GOMAXPROCS()将处理器设置为1就不能启用自旋
  4. 协程调度机制中的可运行队列必须为空,否则会延迟协程调度

自旋的问题

如果自旋过程中获得锁,那么之前被阻塞的协程将无法获得锁,如果加锁的协程特别多,每次都通过自旋获得锁,那 么之前被阻塞的进程将很难获得锁,从而进入饥饿状态。
为了避免协程长时间无法获取锁,自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 //记录写阻塞时读者个数
}

接口定义

  1. RLock():读锁定
  2. RUnlock():解除读锁定
  3. Lock(): 写锁定,与Mutex完全一致
  4. 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专家编程-常见控制结构实现原理”的文章就介绍到这了,更多文章或继续浏览下面的相关文章,希望大家以后多多支持JQ教程网!

您可能感兴趣的文章:
龙芯平台构建Go语言环境指南
go 语言学习历程
golang微服务框架对比_斗鱼开源首秀——基于 Go 的微服务框架 Jupiter
Go 语言十年而立,Go2 蓄势待发
[GO语言基础] 一.为什么我要学习Golang以及GO语言入门普及
golang url 收集
go语言有哪些优势?Go语言的核心特性有哪些
Go 语言的核心优势
Go 开发关键技术指南 | 为什么你要选择 Go?(内含超全知识大图)
Go语言发展历史、核心、特性及学习路线

[关闭]
~ ~